diff --git a/docs/acceptance-week1.md b/docs/acceptance-week1.md index 14e6355..09a0544 100644 --- a/docs/acceptance-week1.md +++ b/docs/acceptance-week1.md @@ -91,7 +91,7 @@ curl http://localhost:8080/api/v1/info # 发送验证码 curl -X POST "http://localhost:8080/api/v1/auth/sms/send?phone=13800000001&purpose=login" -# Mock 登录(从日志获取验证码) +# Mock 登录(验证码不输出到日志;本地按 docs/frontend-integration.md §3 受控取得) curl -X POST http://localhost:8080/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"phone":"13800000001","code":"<验证码>"}' @@ -145,11 +145,11 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev \ | STUDENT | 13800000002 | 学生测试 | | COMPANY | 13800000003 | 企业测试 | -> 注: 使用 Mock 短信验证码登录,验证码从应用日志中获取 +> 注: 使用 Mock 短信验证码登录。应用日志**不再输出验证码**,本地按 `docs/frontend-integration.md` §3 的受控方式取得(需 `SMS_MOCK_ENABLED=true` 且 `ALLOW_TEST_OTP_DB_READ=true`);生产环境禁止查询验证码表。 ## 已知限制 -1. **短信验证码 Mock**: 验证码打印到日志,不发送真实短信 +1. **短信验证码 Mock**: 应用日志**不输出验证码**,不发送真实短信;本地取码见 `docs/frontend-integration.md` §3(验证码当前仍为数据库明文存储) 2. **业务模块**: 岗位、投递、聊天、评分等模块仅定义接口骨架 3. **RabbitMQ 监听**: 队列已定义但未实现消费者(Week 2+) 4. **Actuator 健康**: RabbitMQ 健康检查未配置(仅 ping) diff --git a/docs/account-deletion-test-isolation-fix-20260917.md b/docs/account-deletion-test-isolation-fix-20260917.md new file mode 100644 index 0000000..b4dcbb9 --- /dev/null +++ b/docs/account-deletion-test-isolation-fix-20260917.md @@ -0,0 +1,161 @@ +# 账号注销测试夹具隔离修复报告 + +日期:2026-09-17 +分支:`refactor/r01-structure-performance` +基线 HEAD:`d5c37128e84b4fc83bf5334254dd8efeb88ccf64` +性质:**纯测试修复** —— 生产代码、Flyway、配置与接口零改动 + +--- + +## 1. 结论 + +> `AccountDeletionControllerTest` 与关联测试组合的**既有交叉污染已消除**;修复后两种执行顺序各 5 次全部 F=0 / E=0 / S=0。 + +--- + +## 2. 稳定复现(修复前) + +**致冲突组合**(连续运行 5 次,**5/5 稳定复现**): + +``` +mvn -Dtest="*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test, + *Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test" test +``` + +5 次运行**全部**失败于同一位置: + +``` +AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228 + -> createUser:75 +DataIntegrityViolation: Unique index or primary key violation: + USERS(PHONE) = '930000000' +``` + +### 诊断结论(逐项回答) + +| 问题 | 结论 | +|---|---| +| **谁先插入 `phone='930000000'`** | `com.mydreamjob.common.logging.RequestContextMigrationIntegrationTest`(**HEAD 既有类**)。其 `@BeforeEach` 用 `AtomicLong(930_000_000L)` 创建 user | +| **冲突发生在哪个方法** | `AccountDeletionControllerTest#createUser:75`(由 `lazyTransition_firstGet_returnsCompleted_andConsistent:228` 调用) | +| **共享 H2 数据库名** | `integrationdb`(`jdbc:h2:mem:integrationdb;MODE=MySQL;DB_CLOSE_DELAY=-1`)—— 同一 JVM 内所有 `@SpringBootTest` 共享 | +| **static 计数器何时重置** | **不重置** —— `PHONE` 为 per-class static,类加载时初始化为 `930_000_000`;冲突**不是**计数器重置所致,而是**号段被残留记录占用** | +| **未清理的记录** | `users`(`phone=930000000`);`user_roles` 随 users 级联删除 | + +### 根因链 + +1. **号段撞车**:`common/logging/RequestContextMigrationIntegrationTest` 与 `deletion/AccountDeletionControllerTest` **都使用基数 `930_000_000`** +2. **污染源不清理**:前者 `@AfterEach` 只清 `RequestContextProbe` 与 `MDC`,**不删 users** → 每个测试方法残留 1 条 `930000000+N` +3. **受害者删除失败**:后者的 `@AfterEach` 用 `userRepository.deleteAll()`(**全表删除**),会因**其他测试类残留 user**(被 `notifications`/`offers`/`resume_versions` 等**无 CASCADE** 的外键引用)而失败 → 自身 user 也未删净 +4. **执行顺序**:诊断运行中 `common.logging.RequestContextMigrationIntegrationTest` 排在第 **6** 位、`AccountDeletionControllerTest` 排在第 **13** 位 → 前者残留必然影响后者 + +### 与 A2/A3 的关系(如实记录) + +A2/A3 新建的测试类**均不创建 user**,故**无污染**: +- `common/r01a2/RequestContextMigrationIntegrationTest`:无 `AtomicLong`、无 `userRepository` 注入 +- A3 的三个测试类:均使用 `@MockBean UserRepository`(不实际写库) + +**该缺陷完全存在于基线**(污染源文件为 HEAD 既有),与 A2/A3 无关;此前用 HEAD 隔离 worktree 的归因结论**正确**。 + +> 同号段还有 `candidatescore/CandidateScoringClockBoundaryIntegrationTest`,但其标注 `@Transactional`(每方法自动回滚)→ **不残留**,无风险。 + +--- + +## 3. 修复内容 + +### 3.1 `common/logging/RequestContextMigrationIntegrationTest.java`(污染源) + +| 改动 | 内容 | +|---|---| +| 号段独占 | `930_000_000L` → **`986_000_000L`**(已确认全仓未占用) | +| **清理** | `@AfterEach` 增加:按自身 `studentId` **精确删除**本测试创建的 user(`findById(...).ifPresent(delete)`),并补 javadoc 说明隔离原因 | + +### 3.2 `deletion/AccountDeletionControllerTest.java`(受害者) + +| 改动 | 内容 | +|---|---| +| 号段独占 | `930_000_000L` → **`987_000_000L`**(已确认全仓未占用) | +| 记录归属 | 新增 `List createdUserIds`,`createUser()` 登记每个自建 userId | +| **精确清理** | `@AfterEach` 由 `deleteAll()`(整表)改为**按本测试记录**删除:`account_deletions`(按 userId)→ `users`(级联 `user_roles`),各步 `flush()`,遵循外键顺序;`@BeforeEach` 清空记录 | +| **防掩盖断言** | `createUser()` 内先断言该号在共享库中**不存在**,存在即抛出可读错误(暴露上轮清理失效),而不是被唯一键异常吞掉 | + +**未使用**:未改唯一约束、未捕获忽略 `DataIntegrityViolationException`、未用 `@DirtiesContext`、未清空整表、未 `DELETE FROM users` 无条件删除、未 sleep、未放宽任何业务断言、未依赖测试执行顺序。 + +### 3.3 新增反证测试 `deletion/AccountDeletionFixtureIsolationTest.java`(5 例) + +| 反证 | 断言 | +|---|---| +| 1 | 同一 Spring 上下文内连续创建 5 个夹具**不冲突**且 ID 不重复 | +| 2 | 注销记录写入后,按本测试路径清理 → `account_deletions` / `users` / `user_roles` 残留**均为 0** | +| 3 | **变异探测**:预置同号记录(等价于"上轮未清理")→ 防掩盖断言**必须失败**并检出该号,证明残留不会被静默吞掉 | +| 4 | 独占号段 `987_000_000` 在测试开始前为空(不与任何他类重叠) | +| 5 | 清理**精确到本测试记录**:预置的"他类"记录必须保留,证明不是整表删除 | + +--- + +## 4. 验证结果 + +### 4.1 两种执行顺序各 5 次 + +| | 组合 | 结果(5 次) | +|---|---|---| +| 顺序 A | `*Deletion*Test,*RequestContext*Test,*RequestId*Test` | **95/95,F=0 E=0 S=0** ×5 ✔ | +| 顺序 B | `*RequestContext*Test,*Deletion*Test,*RequestId*Test` | **95/95,F=0 E=0 S=0** ×5 ✔ | + +(修复前该组合稳定失败 5/5;修复后 10/10 全绿) + +### 4.2 单类与反证 + +| 命令 | 结果 | +|---|---| +| `mvn -Dtest="AccountDeletionControllerTest" test` | **14 / 14** ✔ | +| `mvn -Dtest="AccountDeletionFixtureIsolationTest,AccountDeletionControllerTest" test` | **19 / 19**(5 反证 + 14 原)✔ | + +### 4.3 全量 + +| 命令 | 结果 | +|---|---| +| `mvn clean verify` | **2225 / 2225**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(≥ 2220 ✔) | +| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ | + +较修复前(2220)新增 **5** 个测试,全部为反证测试。 + +### 4.4 边界核验 + +| 项 | 结果 | +|---|---| +| 生产源码 `git diff --stat -- src/main/` | **空**(零改动)✔ | +| V1–V45 迁移 | **零修改** ✔ | +| Flyway / 配置 / 接口 | 零改动 ✔ | +| `GlobalExceptionHandler.java` | 仍为工作区 stat 标记(内容零差异),**未处理** ✔ | +| Docker | 未操作 ✔ | +| 是否进入 A4 | 否 ✔ | + +--- + +## 5. 精确修改文件 + +``` +修改(2) + mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java + mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java + +新增(1) + mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionFixtureIsolationTest.java + +文档(1) + docs/account-deletion-test-isolation-fix-20260917.md +``` + +--- + +## 6. 提交 + +``` +提交信息:test: isolate account deletion controller fixtures +``` + +(精确暂存上述测试文件与报告,敏感扫描后独立提交;推送当前 R01 功能分支) + +--- + +> 账号注销测试夹具隔离修复完成;测试交叉污染已消除,生产代码零改动。 diff --git a/docs/api-v0.1.md b/docs/api-v0.1.md index 2eecb67..4f9abbd 100644 --- a/docs/api-v0.1.md +++ b/docs/api-v0.1.md @@ -24,7 +24,7 @@ Query: phone=13800000001, purpose=login **限制:** - 60秒内同一手机号只能发送1次 - 5次错误后锁定5分钟 -- Mock环境验证码打印到日志 +- Mock 环境验证码**不输出到日志**;本地按 `docs/frontend-integration.md` §3 的受控方式取得(生产禁止查表) ### 2. Mock 登录 ``` @@ -169,12 +169,65 @@ http://localhost:8080/swagger-ui.html http://localhost:8080/v3/api-docs ``` -## 已知限制 (Week 1) +### 4. 刷新 Token(S02) -1. 短信验证码为 Mock 实现,验证码打印到日志,不发送真实短信 +``` +POST /api/v1/auth/refresh +Content-Type: application/json + +{ "refreshToken": "" } +``` + +- 匿名可调用;是否成功取决于 Refresh Token 校验结果。 +- **只接受 Refresh Token**;把 Access Token 传进来一律 401。 +- 请求体只允许 `refreshToken` 一个字段;Token 不接受 query/path 参数。 +- 成功后返回新的 Token 对,**旧 Refresh Token 立即失效**(强制轮换)。 +- 已消费/已撤销的 Refresh Token 再次提交 → 401,并撤销该登录会话(重放处置)。 +- 失败响应不说明 Token 是"不存在""已消费"还是"已撤销"。 + +**成功响应:** +```json +{ + "code": 200, + "data": { + "accessToken": "eyJhbGciOi...", + "refreshToken": "eyJhbGciOi...", + "expiresIn": 86400 + }, + "requestId": "" +} +``` + +### 5. 登出(S02) + +``` +POST /api/v1/auth/logout +Authorization: Bearer +``` + +- 必须已认证;**无请求体**。 +- 身份与会话来自 Access Token,不接受客户端传入 `userId`/`sessionId`/`refreshToken`。 +- 只撤销**当前登录会话**,其他设备会话不受影响。 +- 重复登出按幂等处理;成功响应不包含任何 Token。 + +## Token 与会话模型(S02) + +| Token | 用途 | 说明 | +|---|---|---| +| Access Token | 普通业务接口的 `Authorization` 头 | 带 `type=access`,有效期 24h;每次请求都会校验服务端会话状态 | +| Refresh Token | 仅用于 `POST /auth/refresh` | 带 `type=refresh`,有效期 7 天;不落库,数据库只保存其 HMAC-SHA256 摘要 | + +- Access 与 Refresh **使用不同签名密钥**,交叉验签必然失败。 +- 每次登录创建独立的**登录会话**(`sessionId`);同一用户可同时存在多个会话。 +- 登出只撤销当前会话;账号注销完成、用户禁用会撤销该用户**全部会话**。 +- 本版**不再兼容**无 `type` 的旧 Token(所有旧 Token 失效,用户需重新登录一次)。 +- Token 原文不进入数据库、日志、审计、URL 或错误响应。 + +## 已知限制 + +1. 短信验证码为 Mock 实现,应用日志**不输出验证码**,不发送真实短信;未接入真实供应商时发码返回“服务不可用” 2. 无企业注册/认证流程 -3. 无岗位、投递、聊天、评分等业务功能 -4. 无 Token 刷新接口(Week 2+) -5. 无密码修改/重置功能 -6. RabbitMQ 未实现消息监听器(仅队列定义) -7. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN +3. 无密码修改/重置功能 +4. 未提供查看全部登录设备、远程踢出单个设备、Token introspection 等接口 +5. RabbitMQ 未实现消息监听器(仅队列定义) +6. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index ddcb7be..379ae69 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -11,6 +11,11 @@ | V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 | | 评分规则解释与合理性 | N02 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(dbd27e6) | 保持分数、权重、排名和企业评分契约不变 | | 消息撤回与会话一致性 | N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收 | 两分钟窗口、发送者权限、正文覆盖、幂等并发、DIRECT/邀约隐私均已锁定 | +| S01 认证安全整改 | 独立代码复验与真实 MySQL/Docker 黑盒全部通过(`b5084fd`) | 保持验证码受控取码、双开关与 `/jobs` 认证边界不变 | +| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | +| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | +| Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 | +| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成独立复验、提交并推送**(提交 `534afe68`);**R01-A6(最终批 5 个 Controller)已完成实施与独立复验**(实施报告 `docs/r01-a6-final-request-context-migration-report.md`;独立复验报告 `docs/r01-a6-independent-verification-20260917.md`)。**请求上下文收束 A 系列到此结束,不再拆分 A7/A8**;下一阶段为 R01-B。历史 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 至 A6 共迁移 37 个 Controller**(A2–A5 各 8 个 + A6 5 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A2–A5 各删除 **16 个**私有解析方法,A6 删除 **6 个**。**请求上下文收束终态:业务 Controller 身份解析 0、requestId 重复解析 0**;剩余命中仅为基础设施(身份 3 文件:CurrentUserAccessor / JwtAuthenticationFilter / DeletionGuardInterceptor;requestId 5 文件:AuditContext / GlobalExceptionHandler / RequestIdAccessor / RequestLoggingFilter / DeletionGuardInterceptor),**A7/A8 不再设置**。**A6 后夹具问题已关闭**:`FairnessMonitoringControllerTest` 曾用 `userId = 1/2` 与 V1_1 种子账号(被 V43 置为 inactive)冲突而在单跑时全部 401,已改为独占用户(`9_310_001/2`)并按精确 ID 清理,单跑稳定通过;新增反证测试 `FairnessMonitoringFixtureIsolationTest`(**已提交并推送,提交 `84fe9f5`**;报告 `docs/r01-a6-fairness-fixture-isolation-fix-20260917.md`)。**R01-B 角色授权与异常映射收束已完成独立复验、提交并推送**(提交 `70b9e10`;两个 Controller 测试夹具缺陷已由独立纯测试提交 `9954600` 修复,报告 `docs/r01-b-authorization-exception-mapping-report.md`、`docs/r01-b-independent-verification-20260918.md`):新增 `security/UserRoleGuard` 与 `common/exception/ErrorHttpStatusRegistry`,54 个调用点改走公共 guard,GlobalExceptionHandler 不再持有映射表。**R01-C 评分与推荐规则结构收束已实施完成,待独立代码复验**(报告 `docs/r01-c-scoring-recommendation-rules-refactor-report.md`):overviewStatus 经核验已是唯一实现(由 `b68519e` 完成);`JobRecommendationService.preferenceScore()` 已是编排方法;本轮新增 `candidatescore/rules/CandidateScoringRuleSet`(版本化强类型规则源,高校名单/阈值/权重),并把 `TalentDiscoveryService` 内联的第二套 30/20/20/15/15 偏好规则收束到 `JobPreferenceScoreCalculator`(规则实现处 2 → 1)。**评分/Fairness 组合测试污染已关闭**:`CandidateScoring*` 中 5 个测试类以当前 period 物化快照且未清理自有 userId,污染了 `FairnessMonitoringHttpIntegrationTest` 的 `populationSize`(+15 精确组成为 3+2+1+1+8);已按行级快照实测逐类归因并精确清理(5 个候选类经证明零残留未改),新增隔离门禁 `R01CScoringFixtureIsolationGateTest`;`*CandidateScoring*Test,*Fairness*Test` 双向顺序各 3 次均 260/260,生产代码 diff = 0(报告 `docs/r01-c-fairness-fixture-isolation-fix-20260918.md`);下一阶段为 R01-E | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/def-01-job-keyword-normalization-remediation-20260920.md b/docs/def-01-job-keyword-normalization-remediation-20260920.md new file mode 100644 index 0000000..6c4c3ab --- /dev/null +++ b/docs/def-01-job-keyword-normalization-remediation-20260920.md @@ -0,0 +1,224 @@ +# DEF-01 定向整改:`GET /jobs` 公开列表关键词规范化 + +日期:2026-09-20 +分支:`refactor/r01-structure-performance` +基线 HEAD:`2319f97203a2cafcf8012869fda8c30599ffb305`(整改前的 upstream 同点) +方案:**方案 A**(服务入口局部规范化),**不采用方案 B** +本轮性质:定向整改 + 独立复验;未暂存、未提交、未推送 + +--- + +## 1. 修复前最小复现 + +真实 HTTP(整改前,`mvn` 于 `2319f97`): + +``` +GET /api/v1/jobs?keyword=%20AlphaEngineer%20 → items=0 (期望 1:trim 后应命中) +GET /api/v1/jobs?keyword=%20%20%20 → items=0 (期望 20:空白应等同未传) +GET /api/v1/jobs/ch?keyword=AlphaEngineer → items=1 +GET /api/v1/jobs → items=20 +GET /api/v1/jobs/search?keyword=%20AlphaEngineer%20 → 1 (正确) +GET /api/v1/jobs/search?keyword=%20%20%20 → 20 (正确) +``` + +## 2. 两个端点的调用链 + +| 端点 | 调用链 | 关键词处理 | +|---|---|---| +| `GET /jobs` | `JobController.listJobs:97` → `JobService.listPublishedJobs:123` → `JobRepository.findPublishedJobs`(JPQL) | 只判 `:keyword IS NULL`,**无 trim、无 blank 归一** | +| `GET /jobs/search` | `JobController.searchJobs:134` → `JobService.searchJobs` → `JobSearchSpecification:52-53` | `!keyword.isBlank()` 判空 + `keyword.trim()` | + +## 3. 根因 + +`JobRepository.findPublishedJobs` 的 JPQL 为: + +```sql +AND (:keyword IS NULL OR j.title LIKE %:keyword% OR j.description LIKE %:keyword%) +``` + +- `" AlphaEngineer "` **不是 null** → 生成 `LIKE '% AlphaEngineer %'` → 前导/尾随空格进入匹配串 → **0 条**; +- `" "` **不是 null** → 生成 `LIKE '% %'` → 无意义的过滤条件 → **0 条**; +- `/jobs/search` 已做 `trim()` 与 `isBlank()` 归一 → 两条**公开**查询路径的输入语义不一致。 + +## 4. 为什么选方案 A + +- 缺陷是**输入规范化缺失**,不是查询实现缺陷; +- 方案 A 把规范化放在**唯一入口**(`listPublishedJobs`),一处生效、语义显式、可被单测直接覆盖; +- 改动面最小(1 个文件、+7/−1),**不触碰** Repository / Specification / Controller / DTO / OpenAPI / + 排序 / 分页 / 薪资 / 城市 / 状态,回归风险最低。 + +## 5. 为什么不选方案 B + +- 方案 B 让 `/jobs` 改走 `JobSearchSpecification`,会改变 `/jobs` 的**分页 DTO 形态、排序实现与 + 查询计划**(`JobSearchItem` 与现有 `toPublicView` 字段并不相同),属于超出缺陷范围的语义扩张; +- 两套实现并存是**既有事实**(`/jobs` 与 `/jobs/search` 本就服务不同调用方), + 合并应作为独立重构批次评估,不应与本次缺陷修复捆绑。 + +## 6. 精确生产修改 + +**唯一文件**:`mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java` +(`git diff --stat`:**1 file changed, 7 insertions(+), 1 deletion(-)**) + +```java +public Page> listPublishedJobs(String keyword, String city, ...) { + if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.trim(); // ← 新增 + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page result = jobRepository.findPublishedJobs(normalizedKeyword, city, employmentType, // ← 原为 keyword + salaryMin, salaryMax, pageable); + return result.map(this::toPublicView); +} +``` + +(另含一段 Javadoc 说明 DEF-01 的语义契约;不改变任何行为。) + +**未新增通用工具类**:项目内不存在语义完全相同的既有规范化工具,故保持这段局部逻辑。 + +## 7. 输入规范化决策表 + +| 输入 | 规范化结果 | +|---|---| +| `null` | `null` | +| `""` | `null` | +| `" "` | `null` | +| `"AlphaEngineer"` | `"AlphaEngineer"` | +| `" AlphaEngineer "` | `"AlphaEngineer"` | +| `"Alpha Engineer"` | `"Alpha Engineer"`(中间空格**不折叠**) | + +未做:大小写转换、中间空格折叠、SQL 拼接、Repository 改动、Specification 改动、Controller 改动、 +排序/分页/薪资/城市/状态语义改动、公开岗位范围改动。 + +## 8. 新增测试方法名 + +`mydreamjob-server/src/test/java/com/mydreamjob/job/JobListKeywordNormalizationIntegrationTest.java` +(9 项,走**真实链路** HTTP → SecurityFilterChain → JobController → JobService → JobRepository): + +`plainKeywordFindsPublishedJob`、`trimmedKeywordFindsSamePublishedJob`、`blankKeywordBehavesAsNoKeyword`、 +`emptyKeywordBehavesAsNoKeyword`、`nullOrMissingKeywordListsPublishedJobs`、 +`draftJobNeverAppearsAfterNormalization`、`normalizationDoesNotChangePagination`、 +`normalizationDoesNotChangeExistingSortContract`、`listJobsIsReadOnly` + +夹具:1 条标题含 `AlphaEngineer` 的 PUBLISHED + 2 条其它 PUBLISHED + 1 条 DRAFT + 1 条 CLOSED; +唯一前缀 `DEF01KEYWORD_`;清理**只按显式登记的主键**,不使用 `deleteAll()`;不依赖执行顺序。 + +## 9. 修复前失败结果 + +以**变异验证**给出权威的修复前证据(把 `normalizedKeyword` 改回 `keyword`,其余不动): + +``` +Tests run: 9, Failures: 4, Errors: 0, Skipped: 0 + JobListKeywordNormalizationIntegrationTest.trimmedKeywordFindsSamePublishedJob FAIL + JobListKeywordNormalizationIntegrationTest.blankKeywordBehavesAsNoKeyword FAIL + JobListKeywordNormalizationIntegrationTest.normalizationDoesNotChangePagination FAIL + JobListKeywordNormalizationIntegrationTest.normalizationDoesNotChangeExistingSortContract FAIL +``` + +即 **trim 与 blank 两类断言在缺失修复时必然失败**(≥2 项,满足要求)。 + +## 10. 修复后结果 + +| 命令 | 结果 | +|---|---| +| `mvn -Dtest="JobListKeywordNormalizationIntegrationTest" test` | **9 / 0 / 0 / 0** | +| `mvn -Dtest="*Job*Test,*JobSearch*Test" test` | **219 / 0 / 0 / 0** | + +## 11. 真实 MySQL HTTP 结果 + +最新 JAR(`mvn clean verify` 产物)+ 真实 MySQL/Redis/RabbitMQ,前缀 `DEF01_20260920_`: +**11 / 11 通过**。 + +| 断言 | 实测 | +|---|---| +| 普通关键词 HTTP 200 且只命中目标 | `200, [target]` ✔ | +| 两侧空白与普通关键词:ID/顺序/数量一致 | `pad=[T] plain=[T]` ✔ | +| 纯空白与无关键词:ID/顺序/总数一致 | `blank == none` ✔ | +| 空字符串与无关键词:一致 | `empty == none` ✔ | +| DRAFT 不进入公开结果 | `statuses=['PUBLISHED']` ✔ | +| 分页 metadata 不因规范化变化 | `total=1,page=0,size=1,totalPages=1` ✔ | +| 默认排序不变(夹具 createdAt 降序) | `none == blank`,desc ✔ | +| 不返回 4xx/5xx | `[200]×7` ✔ | +| 响应不泄露 SQL/异常/路径/堆栈 | `leaks=[]` ✔ | +| 对照 `/jobs/search` 的 trim/blank 语义一致 | `pad==plain`、`blank==全部` ✔ | +| 请求前后数据库逐字段零变化 | `changed=none` ✔ | + +## 12. 变异测试结果 + +- 保存 `JobService.java` **原始字节**(sha256 前 16 位 `b4fab0034b34d569`,14 699 字节); +- 变异:`findPublishedJobs(normalizedKeyword, …)` → `findPublishedJobs(keyword, …)`; +- 结果:**4 项失败**(见 §9); +- **按原始字节精确恢复**(**未使用** `git restore` / `git checkout`),恢复后 sha 一致; +- 复跑新增测试 **9/9 绿**;校验无变异残留(`findPublishedJobs(keyword,` 计数 = 0, + `String normalizedKeyword =` 计数 = 1)。 + +## 13. 岗位关联回归 + +`mvn -Dtest="*Job*Test,*JobSearch*Test" test` → **219 / 0 / 0 / 0**(未修改或删除任何既有断言)。 + +## 14. 全量测试数 + +| 命令 | Tests | Failures | Errors | Skipped | BUILD | +|---|---|---|---|---|---| +| `mvn test` | **2797** | 0 | 0 | 0 | SUCCESS | +| `mvn clean verify` | **2797** | 0 | 0 | 0 | SUCCESS | + +`2797 = 2788(基线)+ 9(本轮新增)`,满足「不低于 2788 + 新增数」。 +Testcontainers MySQL 用例实际执行(`Skipped=0`)。 + +## 15. OpenAPI + +``` +python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed! +``` + +`docs/openapi-rc.json` **未修改**(`git diff` 为空)。 + +## 16. 数据副作用 + +- 黑盒前后 7 张表(jobs / companies / users / user_roles / audit_logs / notifications / applications) + **逐字段零变化**; +- 单测 `listJobsIsReadOnly` 对 5 张表逐字段零变化; +- 夹具残留 **0**;历史未跟踪文件与历史数据(含 `users.id` 7938/7939 的 `user_roles`)完好; +- `audit_logs` **未删除任何行**。 + +## 17. 精确修改文件 + +``` +M mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java (+7 −1) +?? mydreamjob-server/src/test/java/com/mydreamjob/job/JobListKeywordNormalizationIntegrationTest.java (新增,9 项) +?? docs/def-01-job-keyword-normalization-remediation-20260920.md (本报告) +?? docs/final-independent-acceptance-20260918.md (追加 DEF-01 章节) +``` + +## 18. Git 状态(整改与复验阶段) + +| 项 | 结果 | +|---|---| +| 生产代码改动 | **仅 `JobService.java` 1 个文件** | +| `JobRepository` / `JobSearchSpecification` / `JobController` | **零修改** | +| 其他生产模块 / Flyway / `pom.xml` / `openapi-rc.json` | **零修改** | +| 验收脚本与原始产物 | **零修改** | + +## 19. 未涉及范围 + +未进入:简历隐私矩阵、管理员初始化矩阵、Q01 H2/MySQL 对账与 EXPLAIN 分表、写侧刷新链路 +(四项 SKIP 仍待验收)。 + +## 20. 本轮测试自身缺陷(非产品缺陷,已修正并记录) + +| # | 现象 | 判定 | 修正 | +|---|---|---|---| +| T-1 | 「带空白关键词」用例恒返回空 | **测试缺陷**:MockMvc 对 URL 模板二次编码(`%20` → `%2520`),测的是字面量 `%20` 而非空白参数 | 改用 `.param()` 传参 | +| T-2 | 分页/排序断言随更大范围运行而失败 | **测试缺陷**:断言了既有契约**不保证**的顺序 —— 默认排序 `createdAt DESC` 无 tiebreaker,并列时入选子集与顺序均不稳定 | 夹具改用**显式互异 `created_at`**;分页用例只断言确定性元数据 | +| T-3 | 黑盒夹具未创建 | **脚本缺陷**:`seed_sms_codes/login` 使用 `lib.phone()`(`199000` 号段),而建公司语句另写了 `199002` 号段,两者是**不同号码**,公司被挂到不存在的用户上(号码为本地验收自建夹具,报告中脱敏) | 统一为 `lib.phone(CO)` | +| T-4 | 黑盒「默认排序」断言失败 | **脚本缺陷**:把「关键词过滤后的结果」与「无关键词的完整结果」做相等比较,语义不同 | 只比较同为未过滤的 `none` 与 `blank` | + +## 21. 明确声明 + +**未修改** `/jobs/search`、`JobSearchSpecification`、`JobRepository` JPQL、Controller 路由、 +排序/分页/薪资/城市/状态过滤、DTO、OpenAPI、`ErrorCode`、`SecurityConfig`、V1–V45、`pom.xml`; +**未新增 V46**;**未修改任何既有测试断言**;**未**执行 `git reset/restore/checkout/clean`; +**未**使用 `git add -A` / `git add .` / 目录级暂存;**未**删除或移动历史未跟踪文件。 + +本报告与 `docs/final-independent-acceptance-20260918.md` 的 DEF-01 追加章节于提交前保持**待提交**状态。 diff --git a/docs/final-independent-acceptance-20260918.md b/docs/final-independent-acceptance-20260918.md new file mode 100644 index 0000000..7e2dea3 --- /dev/null +++ b/docs/final-independent-acceptance-20260918.md @@ -0,0 +1,2250 @@ +# 职来后端 S01/S02/S03/Q01/R01 全项目**最终独立总验收**报告 + +日期:2026-09-18 +性质:**只验收,不开发**。未修改生产代码、未修改已有测试断言、未改 V1–V45、未新增 V46、 +未改 `ErrorCode`/DTO/接口/OpenAPI/`pom.xml`、未执行任何 `git reset|restore|checkout|clean|add -A`。 +未暂存、未提交、未推送、未合并。 + +--- + +## 0. 最终判定 + +> ## **FAIL(未完成)** + +**判定依据**:本轮**未执行完**第九~十四节的全部运行时验收矩阵(令牌类型矩阵、刷新/重放/并发、 +岗位权限矩阵、短信生产失败关闭的运行时验证、V43 真实迁移、管理员初始化矩阵、简历隐私矩阵、 +Q01 运行时查询矩阵、GET 评分接口零写、写侧刷新任务、V45 claim token 100 轮、逐字段副作用)。 +按本轮验收规则「任一核心项 FAIL 或 SKIP:总结必须写 FAIL」,**不得**标记为通过。 + +**已完成且通过的项**见 §2;**未执行项**见 §9。本文不使用「基本通过」。 + +**不存在**生产缺陷判定(未发现需定向整改的产品缺陷);已发现的失败项全部定位为 +**本轮验收脚本自身的环境缺陷**或**瞬时基础设施故障**,均已在 §5、§6 中给出根因与最小复现。 + +--- + +## 1. 分支、HEAD、upstream + +| 项 | 期望 | 实测 | 结果 | +|---|---|---|---| +| 分支 | `refactor/r01-structure-performance` | `refactor/r01-structure-performance` | ✔ | +| HEAD | `2319f97203a2cafcf8012869fda8c30599ffb305` | `2319f97203a2fcf8012869fda8c30599ffb305` | ✔ | +| upstream | 同一提交 | `2319f972…` | ✔ | +| ahead/behind | `0 0` | `0 0` | ✔ | +| 暂存区 | 空 | 空 | ✔ | +| `git diff --check` | exit 0 | exit 0 | ✔ | +| `git log --merges origin/master..HEAD` | 空 | 空 | ✔ | + +### 1.1 两处 `M` 假阳性的三方 blob 验证 + +`git status --short --untracked-files=no` 曾显示这两个文件为 `M`。按指令做三方验证: + +| 文件 | `git hash-object --` | `HEAD:` | `git ls-files -s` | `git diff --quiet` | +|---|---|---|---|---| +| `…/candidatescore/CandidateOverviewStatusPolicy.java` | `2ebcff6a95a2b34b9d3f435072252598f6722247` | 同 | `100644 2ebcff6a…` | **exit 0** | +| `…/recommendation/JobPreferenceScoreCalculator.java` | `7368f4a1e21b79cf303a6e70b1b8d1080677ca40` | 同 | `100644 7368f4a1…` | **exit 0** | + +⇒ **blob 完全一致、`git diff --quiet` 为 0 → 内容零差异**,确认为 Windows stat 缓存假阳性。 +**未**对这两个文件执行 `restore` 或重新保存。终检时 `git diff --name-only` 已为空。 + +--- + +## 2. 已完成且通过的验收项 + +| 节 | 项目 | 结果 | +|---|---|---| +| §3 | 开工前核查(分支/HEAD/upstream/暂存区/`diff --check`/merge) | 全部符合预期 ✔ | +| §4 | 提交历史与阶段完整性(25 提交、390 文件、**零删除**) | ✔ | +| §4 | 迁移链(仅新增 V42–V45) | ✔ | +| §5.1 | S01 静态安全 | ✔ | +| §5.3 | S03 静态安全(Actuator/隐私/bootstrap/V43) | ✔ | +| §6 | Docker 基础设施 | ✔ | +| §7.1 | R01-E 门禁 | **42 / 0 / 0 / 0** ✔ | +| §7.2 | 领域关键测试 | **932 / 0 / 0 / 0** ✔ | +| §7.3 | `mvn test` | **2788 / 0 / 0 / 0** ✔ | +| §7.3 | `mvn clean verify` | 首次 FAIL / 重跑 **2788 / 0 / 0 / 0 SUCCESS**(见 §5) | +| §7.4 | OpenAPI 静态 | **176 paths / 214 operations**,`All checks passed!` ✔ | +| §8 | 最新 JAR + 真实 MySQL/Redis/RabbitMQ 启动 | ✔ 全部通过(见 §4) | +| §15 | 敏感信息扫描(`origin/master...HEAD`) | 无真实秘密 ✔ | +| §16 | RabbitMQ 队列收尾 | 四队列归零 ✔ | +| §16 | 验收数据清理与足迹 | ✔ | +| §17 | 最终 Git 核查 | ✔ | +| §21/§22 | E1/E2 SQL 数与 EXPLAIN 结论 | 由 §7.1 的 42 项门禁覆盖 ✔ | + +--- + +## 3. 关键提交清单与阶段映射 + +25 个提交(`origin/master..HEAD`,按时间正序),**无删除文件**;格式:`hash | prod/test/doc/fly | 提交信息` + +| 阶段 | hash | prod | test | doc | fly | 提交信息 | +|---|---|---|---|---|---|---| +| **S01** | `b5084fd` | 11 | 7 | 12 | 0 | fix: harden SMS authentication and job access control | +| 上传安全基线 | `96a920b` | 7 | 7 | 2 | 0 | fix: harden resume upload safety | +| **S02** | `f399060` | 21 | 63 | 9 | **1** | feat: isolate and revoke authentication token sessions | +| **S03** | `cb3bc57` | 10 | 11 | 10 | **1** | feat: harden production bootstrap and resume privacy | +| **Q01** | `77dc624` | 1 | 11 | 6 | 0 | fix: align job search queries across databases | +| R01-A | `0b5b849` | 12 | 9 | 4 | 0 | refactor: centralize request identity and request ID handling | +| R01-B | `15bd98b` | 1 | 1 | 3 | 0 | refactor: centralize business error HTTP status mapping | +| R01-C | `b68519e` | 5 | 3 | 3 | 0 | refactor: centralize scoring status and preference calculation | +| R01-D1 | `cf0c71e` | 2 | 1 | 2 | 0 | perf: optimize candidate percentile calculation | +| R01-D2 | `8c40fd4` | 7 | 1 | 2 | 0 | perf: batch personal data export queries | +| R01-D3 | `b56f792` | 2 | 1 | 2 | 0 | perf: batch conversation unread counts | +| R01-D4 设计 | `b10c4a7` | 0 | 1 | 2 | 0 | docs: design scoring snapshot materialization | +| R01-D4 | `52c8026` | 19 | 27 | 7 | **2** | refactor: materialize scoring snapshots outside GET requests | +| R01-A2/A3 | `72b82a3` / `d5c3712` | 8 / 8 | 3 / 3 | 2 / 3 | 0 | refactor: centralize second/third batch request context handling | +| R01-A4/A5/A6 | `0226977` / `534afe6` / `c6db74a` | 8 / 8 / 5 | 4 / 4 / 4 | 3 / 3 / 3 | 0 | refactor: centralize fourth/fifth batch + complete request context centralization | +| 夹具隔离 | `35baa84` / `84fe9f5` / `9954600` / `b893ed6` | 0 | 3/2/4/6 | 1/2/0/2 | 0 | test: isolate … fixtures | +| R01-B2 | `70b9e10` | 24 | 30 | 3 | 0 | refactor: centralize role authorization and HTTP status mapping | +| R01-C2 | `7d4704e` | 3 | 5 | 3 | 0 | refactor: centralize scoring and recommendation rules | +| **R01-E** | **`2319f97`** | **0** | **4** | **2** | **0** | **test: verify batch query and unread count performance** | + +**跨阶段混入检查**:`f399060`/`cb3bc57`/`52c8026` 之外的提交无 `src/main/resources` 与 `pom.xml` 变更。 +`cb3bc57` 修改了 `mydreamjob-server/pom.xml`,diff 内容为**仅新增** `io.micrometer:micrometer-registry-prometheus` +(注释标明 S03-C 需要),属 S03 范围内,**非意外混入**。无删除文件。 + +--- + +## 4. 迁移 V42–V45 说明与 V1–V41 未修改证明 + +| 迁移 | 用途 | 引入提交 | +|---|---|---| +| `V42__auth_token_sessions.sql` | 认证 Token 会话:`session_id`/`token_jti`/`parent_token_id`/`consumed_at`/`revoked_at`/`revocation_reason`/`version` + 索引与唯一键 | `f399060` | +| `V43__disable_legacy_seed_accounts.sql` | 精确禁用遗留演示账号:以「原始手机号 + 原始邮箱 + 原始 bcrypt 哈希 + 原始主角色 + `is_active=1`」五元指纹匹配,仅置 `is_active=0`;**不依赖固定 id**、不删除任何行、幂等 | `cb3bc57` | +| `V44__candidate_scoring_refresh_tasks.sql` | 评分快照刷新任务表 + 补偿扫描游标表 | `52c8026` | +| `V45__add_scoring_refresh_claim_token.sql` | 刷新任务新增不可复用 `claim_token VARCHAR(36) NULL`,仅新增列 | `52c8026` | + +**未修改证明**: + +``` +git diff --name-status origin/master...HEAD -- mydreamjob-server/src/main/resources/db/migration +→ 仅 4 条 A(V42/V43/V44/V45) + +git diff --diff-filter=M --name-only origin/master...HEAD -- → 空(V1–V41 零修改) +git diff --diff-filter=D --name-only origin/master...HEAD -- → 空(零删除) +ls | wc -l → 45;V46 → 不存在 +``` + +--- + +## 5. 自动化测试:总数、F/E/S 与首次失败/重跑记录 + +| # | 命令 | Tests | Failures | Errors | Skipped | BUILD | +|---|---|---|---|---|---|---| +| §7.1 | R01-E 门禁(4 类) | **42** | 0 | 0 | 0 | SUCCESS | +| §7.2 | 领域关键测试(17 个模式) | **932** | 0 | 0 | 0 | SUCCESS | +| §7.3a | `mvn test` | **2788** | **0** | **0** | **0** | SUCCESS | +| §7.3b | `mvn clean verify`(**首次**) | 2788 | **0** | **2** | 0 | **FAILURE** | +| §7.3b | `mvn clean verify`(**重跑**) | **2788** | **0** | **0** | **0** | **SUCCESS** | + +**总数 2788 ≥ 2788 达标**;`Failures=0`、`Skipped=0` 两次一致。 + +### 5.1 首次失败(如实记录,不隐去) + +``` +[ERROR] Tests run: 2788, Failures: 0, Errors: 2, Skipped: 0 +[ERROR] R01EIndependentCrossDatabaseVerificationTest.resetBothEngines:162->clearFixture:493 ? UndeclaredThrowable +[ERROR] R01EIndependentCrossDatabaseVerificationTest.mysqlExplainReportsAccessPathsPerTable:1268 + ->seedBackgroundMessages:1491 ? TransientDataAccessResource + PreparedStatementCallback; SQL [INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) VALUES (?,?,?,?,?)]; + No operations allowed after statement closed. +Caused by: com.mysql.cj.exceptions.StatementIsClosedException: No operations allowed after statement closed. + at com.mysql.cj.jdbc.ClientPreparedStatement.clearBatch(ClientPreparedStatement.java:279) + ... +HikariPool-39 - Failed to validate connection com.mysql.cj.jdbc.ConnectionImpl@… (No operations allowed after +connection closed.). Possibly consider using a shorter maxLifetime value. +``` + +**失败性质判定(按指令三分类)**: + +- **断言失败?** 否 —— `Failures: 0`,无任何断言不成立。 +- **产品失败?** 否 —— 失败点全部位于**测试自身夹具写入**(`seedBackgroundMessages` 的批量 INSERT) + 与夹具清理(`clearFixture`),不涉及生产代码路径;同批次其余 2786 项全绿。 +- **容器生命周期错误?** **是** —— 为 MySQL 驱动侧 `StatementIsClosedException` 与 Hikari + 连接校验失败,典型的连接被服务端/连接池提前关闭。 + +**原始证据已保存**(首次失败日志与 surefire 报告): +`target/surefire-reports/com.mydreamjob.common.r01e.R01EIndependentCrossDatabaseVerificationTest.txt` +及对应 XML,以及完整 `mvn clean verify` 日志副本。 + +**重跑前置检查**:`docker info` Server 29.6.2、3 个容器 Running、 +`mydreamjob-mysql` `OOMKilled=false RestartCount=0` → **Docker 健康**,符合「可原命令重跑一次」条件。 + +**重跑结果**:**2788 / 0 / 0 / 0,BUILD SUCCESS(8:50)**,同一错误**未复现**。 +按指令「若同一错误重复出现,不得继续重跑掩盖」——本错误**未重复**,故不继续重跑。 + +### 5.2 由此产生的新观察项 + +见 **OBS-NEW-01**(§8)。 + +--- + +## 6. 验收脚本自身的环境缺陷(不是产品缺陷) + +以下两项失败均由**我本轮的验收脚本**引起,已在报告与脚本中固化修正,**未修改任何生产代码或既有测试**。 + +### 6.1 MSYS 路径转换改写 RabbitMQ vhost(最小复现) + +- **现象**:§8 首启后管理端口 `/actuator/health` 返回 `{"status":"DOWN"}`;业务端口健康正常。 +- **最小复现**:在 Git Bash 中以 `RABBITMQ_VHOST=/`(来自 `backend-infra/.env`)启动 JAR, + 应用日志出现 + `ShutdownSignalException: connection error; protocol method: #method(reply-code=530, reply-text=NOT_ALLOWED - vhost D:/15642/Trae CN/Git/ not found …)`。 +- **根因**:MSYS 把环境变量 `RABBITMQ_VHOST=/` 转换成了 Windows 路径,应用连到不存在的 vhost。 + 容器实际只有 vhost `/`(`rabbitmqctl list_vhosts` → `/`)。 +- **修正**:以 `MSYS2_ENV_CONV_EXCL=RABBITMQ_VHOST` 启动(**只**排除该变量, + 不可用 `MSYS_NO_PATHCONV=1`——那会同时禁用 JAR 路径转换,导致 `Unable to access jarfile /c/Users/...`)。 +- **修正后**:`/actuator/health` → `{"status":"UP"}` HTTP 200,RabbitMQ 错误计数归零。 +- **结论**:**产品无缺陷**;属验收环境缺陷。 + +### 6.2 管理健康 `DOWN` 不暴露组件明细 + +即使处于 `DOWN`,管理端口响应仅 `{"status":"DOWN"}`,**不含** db/redis/rabbit/diskSpace 明细 —— +这正是 `management.endpoint.health.show-details: never` 的预期行为 ✔。 + +--- + +## 7. §8 最新 JAR 与真实基础设施启动验收(通过) + +| 项 | 值 | +|---|---| +| JAR | `mydreamjob-server/target/mydreamjob-server-0.0.1-SNAPSHOT.jar`(82 433 459 B,来自 `mvn clean verify`) | +| 应用 PID | `198`(本轮唯一启动的 JAR;已按精确 PID 停止) | +| 业务端口 | `8080`(`0.0.0.0`) | +| 管理端口 | `8081`(**仅 `127.0.0.1`**,`netstat` 实测) | +| 启动耗时 | `Started MyDreamjobApplication in 15.963 seconds` | +| Flyway | 启动成功、schema 迁移状态正常(仅 Flyway 对 MySQL 8.4 的「升级建议」告警) | +| Hibernate | `ddl-auto=validate` 通过(否则启动失败) | +| SMS | `app.sms.mock.enabled=false`(生产模拟) | +| 管理员 bootstrap | `ADMIN_BOOTSTRAP_ENABLED=false`(默认关闭) | +| 密钥 | Access / Refresh / Token-Hash 三项**本进程内随机生成、≥32 字符、两两不同**;仅存在于进程环境,未写入仓库文件或本报告 | + +**双端口矩阵(实测)**: + +| 端点 | 业务端口 `/api/v1/actuator/*` | 管理端口 `/actuator/*` | +|---|---|---| +| `health` | **404** | **200**(`{"status":"UP"}`) | +| `metrics` | **404** | **200** | +| `prometheus` | **404** | **200** | +| `env` | **404** | **404** | +| `beans` | **404** | **404** | +| `heapdump` | **404** | **404** | +| `threaddump` | **404** | **404** | +| `configprops` | **404** | **404** | + +**业务健康响应**:`{"code":200,"message":"ok","data":{"application":"mydreamjob-server","version":"0.0.1-SNAPSHOT","status":"UP"},"requestId":"…","timestamp":"…"}` —— +**不含** `db`/`redis`/`rabbit`/`diskSpace`/`hikari`/连接串/组件明细 ✔ + +--- + +## 8. 观察项 + +### 既有观察项(按要求保留,未擅自写成已解决) + +| ID | 内容 | 是否阻断 | 影响范围 | 证据 | 推荐后续动作 | +|---|---|---|---|---|---| +| **OBS-01** | 验证码仍可能在数据库**明文保存**(`sms_verification_codes.code`) | 否(既有设计) | 数据库静态数据泄露面 | `V1__base_schema.sql` 中 `code VARCHAR(10) NOT NULL`;本轮未改表结构 | 另立批次评估对 `code` 加盐摘要 + 短 TTL | +| **OBS-02** | **无真实短信供应商**;生产 `mock=false` 下必须安全失败 | 否(已安全失败) | 生产环境无法真实发码 | `SmsSenderConfiguration` 仅在字面量 `true` 时装配 `MockSmsCodeSender`,否则 `UnavailableSmsCodeSender` **抛 `SERVICE_UNAVAILABLE`**,不伪造成功;本轮**静态**验证通过 | 接入真实网关后做运行时回归 | +| **OBS-E01** | **本人发送且没有本人 `message_reads` 记录的消息,当前也计入本人未读数** | 否(待产品确认) | 会话未读数的业务语义 | R01-E 独立复验实测:会话内「本人 1 条 + 他人 2 条均未读」→ 未读 **3**(非 2);H2/MySQL 两侧一致 | 待产品负责人确认;如需变更属业务语义变更,另立批次 | + +### 本轮新增观察项 + +| ID | 内容 | 是否阻断 | 影响范围 | 证据 | 推荐后续动作 | +|---|---|---|---|---|---| +| **OBS-NEW-01** | `R01EIndependentCrossDatabaseVerificationTest` 在 `mvn clean verify` 首次运行出现 **MySQL 连接生命周期瞬时错误**(2 Errors,`Failures=0`),重跑 2788/0/0/0 通过,**未复现** | **潜在阻断**(CI 稳定性) | 仅影响该测试类的稳定性,不影响生产行为 | §5.1 原文堆栈:`StatementIsClosedException` @ `seedBackgroundMessages` 批量 INSERT;`HikariPool-39 - Failed to validate connection … shorter maxLifetime` | 下一轮定向整改:为该类的手工 MySQL 上下文设置更短的 `maximum-pool-size`/`max-lifetime` 并对批量写入加重试,或改为独立容器生命周期;**本轮不改测试** | +| **OBS-NEW-02** | `ErrorCode` 中 **`UNAUTHORIZED`→400**、**`SERVICE_UNAVAILABLE`→400**、**`COMPANY_NOT_FOUND`→404** 为**既有契约事实** | 否(既有契约) | 影响岗位权限矩阵的期望状态码 | `src/test/resources/r01b/error-code-http-status-baseline.tsv`:`UNAUTHORIZED 2001 400`、`SERVICE_UNAVAILABLE 9001 400`、`COMPANY_NOT_FOUND 5001 NOT_FOUND` | 本轮**未改**;记录以说明「STUDENT 创建岗位」的既有语义为 `COMPANY_NOT_FOUND`,**运行时矩阵未执行故不下结论** | +| **OBS-NEW-03** | `9103` 同码多常量事实仍被显式记录 | 否 | 无 | `ErrorHttpStatusRegistry.java` 注释「同一数字码对应多个常量是**既有事实**(9103)」;`ErrorCode.REVIEW_ALREADY_DECIDED(9103, …)`、`DUPLICATE_FEEDBACK(9103, …)` | 保持 | + +--- + +## 9. **未执行**的验收项(本轮 FAIL 的直接原因) + +下列项目本轮**未执行**,因此**不得**判定为通过。每项给出续跑方式。 + +| 节 | 未执行项 | 续跑方式 | +|---|---|---| +| §7.4 运行时 | 运行时 OpenAPI(`/v3/api-docs` 计数) | JAR 启动后 `GET /api/v1/v3/api-docs` 统计 paths/operations | +| §9.1 | Token 类型矩阵(Access/Refresh/无 type/错误密钥/错误 aud/iss/缺 iat/未来 iat/过期/非法 sid·jti·sub) | 脚本已就绪:`backend-infra/final_acc_20260918_a_startup_token.py`(`§8`+`§9`);需 JAR 运行 | +| §9.2 | 10 轮双客户端并发刷新、重放、活动 Refresh=0、无锁异常 | 同上脚本 `section9_concurrency()` | +| §9.3 | 单设备登出、重复登出幂等、注销撤销全部 session、inactive 不可登录 | 同上脚本 `section9_logout()` | +| §10 | 岗位权限矩阵(匿名读写、STUDENT/ADMIN/COMPANY/跨企业) | 待编写并执行 | +| §10 | 短信生产失败关闭**运行时**验证(已完成静态验证) | `POST /auth/sms/send` 期望安全失败 | +| §11.1 | 简历隐私矩阵(PRIVATE/APPLIED_COMPANIES/VERIFIED_COMPANIES/跨企业/拒绝前零文件读取/字节一致/`no-store`) | 待编写并执行 | +| §11.2 | 管理员初始化矩阵(默认关闭、非法值、并发双 JAR、事务、无 Token/短信/MQ) | 需隔离配置多次启动 JAR | +| §11.3 | V43 在**真实 MySQL** 的执行结果(指纹命中/单字段不命中/不依赖 ID/重复执行稳定/登录被拒) | 对 `mydreamjob-mysql` 执行并可回滚验证 | +| §12 | Q01 真实数据库查询矩阵(18 项) | 运行时 + EXPLAIN 分表报告 | +| §13.1 | 三个 GET 评分接口的 22 张表逐字段快照(零写) | 待编写并执行 | +| §13.2 | 写侧刷新任务(Offer 过期/Feedback/Resume 等) | 待编写并执行 | +| §13.3 | V45 claim token **100 轮**并发验收 | 待编写并执行 | +| §14 | 22 张表逐字段副作用 + 改/插/删反证(**运行时**) | 独立测试已覆盖 25 张表快照与四类反证(§7.1 通过),但**未**在运行 JAR 上做 | + +**已完成的部分覆盖**:§13.4(E1/E2 SQL 数、H2/MySQL 深比较、500/501 窗口、`m` 命中 +`idx_messages_conversation_created`、`mr` 为 `eq_ref`+`uk_message_reads`、`ranked` 派生结果扫描、 +`temporary`/`filesort`、不新增 V46)与 §14 的**测试内**副作用快照,已由 §7.1 的 42 项门禁覆盖并通过 +(详见 `docs/r01-e-independent-verification-20260918.md`)。 + +--- + +## 10. §15 敏感信息扫描结果(逐条分类) + +**扫描范围**:`git diff origin/master...HEAD` 全量(2 349 103 字符)。 + +| 模式 | 命中 | 分类 | +|---|---|---| +| `eyJ…`(JWT 原文) | **0** | — | +| `Authorization: Bearer ` | **0** | — | +| `JWT_*_SECRET=<值>` | **0** | — | +| `MYSQL_PASSWORD=<值>` | 2 | 均为 **shell 变量引用**(`$2`、`$T…`),非真实口令 | +| `DB_PASSWORD=<值>` | 1 | **文档正文**中的模式列举,非真实口令 | +| `PRIVATE KEY` | **0** | — | +| `AKIA…`(AWS) | **0** | — | +| `password_hash='$2a$…'`(bcrypt) | **0** | — | +| 11 位连续数字(手机号) | **0** | — | +| `claim_token=` | 19 | 全部为 **SQL 列引用**(`… AND claim_token=?`、`SET claim_token=NULL`)与文档描述,**非令牌值** | + +**真实口令比对**:以 `backend-infra/.env` 中 6 个真实值与全部命中逐一比对 → **无一相等**, +故**无真实秘密泄漏** ✔。扫描器已用探针验证有效性(可命中注入的 `Authorization: Bearer`、 +11 位手机号、`DB_PASSWORD=` 等模式)。 + +**未扫描(本轮未执行,故不声明通过)**:最新 JAR 运行日志、数据库 Token/审计字段、 +本轮 HTTP 响应、验收脚本与报告(脚本中不落密钥;`final_acc_20260918_run.sh` 只在进程内生成密钥并写入 +一个**已删除**的临时文件 `.final_acc_secrets.env`)。 + +--- + +## 11. §6/§16 RabbitMQ 队列结果 + +| 队列 | 验收开始前 | 验收结束后 | +|---|---|---| +| `queue.sms.verification` | ready=0 unacked=0 total=0 | ready=0 unacked=0 total=0 | +| `queue.notification` | 0 / 0 / 0 | 0 / 0 / 0 | +| `queue.audit` | 0 / 0 / 0 | 0 / 0 / 0 | +| `queue.notification.dlq` | 0 / 0 / 0 | 0 / 0 / 0 | + +**四队列归零 ✔**,前后一致,无需 purge、无阻断。 + +**基础设施状态**(未重建、未改配置、未删 volume): + +| 容器 | 镜像 | 端口 | 状态 | +|---|---|---|---| +| `mydreamjob-mysql` | mysql:8.4 | `127.0.0.1:3307→3306` | Up (healthy),`OOMKilled=false`,`RestartCount=0` | +| `mydreamjob-redis` | redis:7.4-alpine | `127.0.0.1:6379` | Up (healthy) | +| `mydreamjob-rabbitmq` | rabbitmq:3.13-management-alpine | `127.0.0.1:5672`、`127.0.0.1:15672` | Up (healthy) | + +Docker Engine **29.6.2**;volume:`backend-infra_mysql_data`、`backend-infra_redis_data`、`backend-infra_rabbitmq_data`(**未删除**)。 +三个容器以 `docker start <精确容器名>` 启动;**未**执行 `compose down`、未重建、未换镜像、未重置数据库。 + +--- + +## 12. §16 验收数据清理与保留足迹 + +| 项 | 结果 | +|---|---| +| 本轮创建的夹具用户(`1990000*` 虚构号段) | **0 条残留**(实测 `SELECT COUNT(*) FROM users WHERE phone LIKE '1990000%'` = 0) | +| 本轮 JAR | 已按**精确 PID `198`** 停止;未停止任何未知 Java 进程 | +| 端口 | `8080`/`8081` 均已空闲 | +| 临时密钥文件 | `backend-infra/.final_acc_secrets.env` **已删除** | +| 临时扫描文件 | `backend-infra/_finalacc_scan.tmp` **已删除** | +| `audit_logs` | **未删除任何行**(本轮未产生审计行;如后续续跑产生,须保留并记录引用对象) | +| Docker 容器 | 保持运行(按指令) | +| 历史未跟踪文件 | 完整保留(未跟踪总数 255,仅新增本轮 3 个脚本 + 本报告) | +| 本轮新增未跟踪文件 | `backend-infra/final_acc_20260918_lib.py`、`final_acc_20260918_a_startup_token.py`、`final_acc_20260918_run.sh`、`docs/final-independent-acceptance-20260918.md` | + +--- + +## 13. §17 最终 Git 状态 + +| 项 | 结果 | +|---|---| +| 分支 | `refactor/r01-structure-performance` | +| HEAD | `2319f97203a2fcf8012869fda8c30599ffb305`(**未变**) | +| upstream | `2319f972…`(**0 / 0**) | +| 暂存区 | **空** | +| 已跟踪文件修改 | **无**(`git diff --name-only` 空) | +| `git diff --check` | exit 0 | +| V1–V45 迁移修改 | **无** | +| V46 | **不存在** | +| master 合并 | **无**(`git log --merges origin/master..HEAD` 空) | +| commit / push | **未执行** | +| 历史未跟踪文件 | 完整保留 | + +--- + +## 14. 是否具备创建 PR 的条件 + +> **不具备(本轮 FAIL)**。 + +**理由**:本轮**未执行完**第九~十四节的运行时应验收矩阵(令牌类型矩阵、刷新/重放/并发、 +岗位权限矩阵、简历隐私矩阵、管理员初始化矩阵、V43 真实迁移、Q01 运行时查询矩阵、 +GET 评分零写、写侧刷新、claim token 100 轮、运行时 22 表副作用)。按本轮验收规则, +任一核心项 SKIP 即判 FAIL,故**不得**标记为通过。 + +**但需明确**:本轮**未发现任何生产缺陷**。已完成部分(静态审查、提交/迁移完整性、 +2788 项自动化测试、OpenAPI 176/214、最新 JAR + 真实 MySQL/Redis/RabbitMQ 启动与双端口隔离、 +RabbitMQ 队列归零、敏感信息扫描、Git 无变化)**全部通过**。 + +--- + +## 15. 下一轮定向整改指令(不修改生产代码) + +1. **OBS-NEW-01(优先)**:稳定 `R01EIndependentCrossDatabaseVerificationTest` 的手工 MySQL 上下文—— + 为该上下文单独配置 `hikari.maximum-pool-size` 与 `max-lifetime`,并对 `seedBackgroundMessages` + 的批量写入增加幂等重试;或改为独立容器生命周期与更小的背景数据集。目标:`mvn clean verify` + 连续 3 次 `2788/0/0/0`。**仅改测试,不改生产代码。** +2. **补齐 §9**:直接运行已就绪的 `backend-infra/final_acc_20260918_a_startup_token.py` + (先 `bash final_acc_20260918_run.sh start`,**必须以 `MSYS2_ENV_CONV_EXCL=RABBITMQ_VHOST` 启动**)。 +3. **补齐 §10–§13**:按 §9 表格逐项编写执行;§11.2 需要隔离配置下的双 JAR 并发启动; + §13.3 需要 100 轮 claim token 并发。 +4. **补齐 §7.4 运行时 OpenAPI** 与 **§15 运行日志/数据库/响应扫描**。 +5. 全部补齐后重新出报告;OBS-NEW-01 若再现同一错误即判 FAIL,不得继续重跑掩盖。 + +--- + +## 16. 明确声明 + +**本轮未修改任何生产代码;未修改任何已有测试断言;未修改 V1–V45;未新增 V46; +未修改 `ErrorCode`、DTO、接口、OpenAPI 或 `pom.xml`;未修改评分、排名、推荐、邀约、Offer、申诉、 +消息撤回或简历上传语义。** + +**未执行 `git reset` / `git restore` / `git checkout` / `git clean` / `git add -A` / `git add .`。** + +**未暂存、未 commit、未 push、未 merge、未 rebase、未 force push。** + +本报告 `docs/final-independent-acceptance-20260918.md` 保持**未跟踪**。 + +--- +--- + +# 续跑记录(2026-09-18 第二轮,追加) + +本节为**续跑追加**。上文 §0–§16 的初次 FAIL、首次 `clean verify` 的 2 个 Error、 +MSYS vhost 环境缺陷、OBS-NEW-01 及全部原始失败证据**原样保留、未删改**。 + +## 17. 续跑前置核查 + +| 项 | 期望 | 实测 | 结果 | +|---|---|---|---| +| 分支 | `refactor/r01-structure-performance` | 同 | ✔ | +| HEAD | `2319f97203a2fcf8012869fda8c30599ffb305` | 同 | ✔ | +| upstream | 同步 | `2319f972…`,`0 0` | ✔ | +| 暂存区 | 空 | 空 | ✔ | +| `git diff --check` | exit 0 | exit 0 | ✔ | +| 已跟踪内容差异 | 无 | `git diff --name-status` 空 | ✔ | +| Docker | 三容器 healthy | MySQL `3307`、Redis `6379`、RabbitMQ `5672/15672` 均 Up (healthy),Engine 29.6.2 | ✔ | + +两处生产文件本轮**未再**显示 `M`(`git diff --name-status` 为空),未执行任何覆盖操作。 + +## 18. §3 OBS-NEW-01 稳定性:三轮 `mvn clean verify` + +| 轮次 | 起止 | Tests | Failures | Errors | Skipped | BUILD | `StatementIsClosedException` | Hikari 校验告警 | Docker OOM/重启 | +|---|---|---|---|---|---|---|---|---|---| +| 1 | 17:01:35 → 17:09:52 | **2788** | **0** | **0** | **0** | **SUCCESS** | **0** | 12 | false / 0 | +| 2 | 17:09:52 → 17:19:21 | **2788** | **0** | **0** | **0** | **SUCCESS** | **0** | 12 | false / 0 | +| 3 | 17:19:22 → 17:28:50 | **2788** | **0** | **0** | **0** | **SUCCESS** | **0** | 12 | false / 0 | + +**3/3 通过,硬性要求全部满足。** + +**Testcontainers 真实执行证据**(maven 控制台不携带容器日志,改用「类耗时 + `Skipped=0`」判定; +两类均标注 `disabledWithoutDocker = true`,若 Docker 不可用会被 skip): + +| 轮次 | `R01EMySqlQueryCountAndExplainTest` | `R01EIndependentCrossDatabaseVerificationTest` | +|---|---|---| +| 1 | 5 项 / **47.27 s** | 16 项 / **109.6 s** | +| 3 | 5 项 / **48.27 s** | 16 项 / **134.9 s** | + +**OBS-NEW-01 判定变更**: + +> **OBS-NEW-01 由「潜在阻断」降级为「非阻断 CI 稳定性观察项」。** + +**根因收敛(新证据)**:三轮中**每轮都出现 12 次** `HikariPool - Failed to validate connection … +No operations allowed after connection closed`,但 **`StatementIsClosedException = 0`** 且全部通过。 +说明该 Hikari 告警本身**不足以致败**;首次 FAIL 的致败因素是 `StatementIsClosedException` +(批量 INSERT 执行期语句被关闭)。三轮未能复现 → 判定为**瞬时连接生命周期抖动**。 +按指令「任一轮再次出现相同错误则升级为测试基础设施阻断项」——本错误**未再现**,故不升级。 +**未修改任何测试以获取通过。** + +## 19. §4 最新 JAR 与真实基础设施启动 + +| 项 | 值 | +|---|---| +| JAR | `mydreamjob-server-0.0.1-SNAPSHOT.jar`(82 433 459 B,来自本轮第 3 次 `clean verify`) | +| **PID** | **1419**(本轮唯一启动的 JAR,已按精确 PID 停止) | +| 业务端口 | `8080` | +| 管理端口 | `8081`(`netstat`:`127.0.0.1:8081 LISTENING`,**仅回环**) | +| 启动 | `Started MyDreamjobApplication in 16.538 seconds` | +| 启动方式 | `MSYS2_ENV_CONV_EXCL=RABBITMQ_VHOST`(**未**设置 `MSYS_NO_PATHCONV=1`) | +| `app.sms.mock.enabled` | `false` | +| `ddl-auto` | `validate`(启动成功即通过) | +| 管理员 bootstrap | 默认关闭 | +| 密钥 | Access / Refresh / Token-Hash 三项**进程内随机生成、≥32 字符、两两不同**;未落盘(临时文件已删)、未打印、未写入本报告 | + +**Flyway(真实 MySQL 查询 `flyway_schema_history`)**: + +| version | description | success | +|---|---|---| +| 45 | add scoring refresh claim token | **1** | +| 44 | candidate scoring refresh tasks | **1** | +| 43 | disable legacy seed accounts | **1** | +| 42 | auth token sessions | **1** | + +`SELECT COUNT(*) … WHERE success = 0` → **0**(**failed migration = 0 ✔**)。 + +**双端口矩阵(实测)**: + +| 端点 | 业务 `/api/v1/actuator/*` | 管理 `/actuator/*` | +|---|---|---| +| `health` | **404** | **200**(`{"status":"UP"}`) | +| `metrics` | **404** | **200** | +| `prometheus` | **404** | **200** | +| `env` / `beans` / `heapdump` / `threaddump` / `configprops` | **404** | **404** | + +业务健康响应仅含 `application` / `version` / `status`,**不含** db、redis、rabbit、diskSpace、 +hikari、连接串或任何组件明细 ✔ + +## 20. §5 运行时 OpenAPI 与静态文件**逐集合**比对 + +| 项 | 静态 `docs/openapi-rc.json` | 运行时 `/api/v1/v3/api-docs` | +|---|---|---| +| paths | **176** | **176** | +| operations | **214** | **214** | +| 路径集合是否完全一致 | — | **True**(`only_static=[]`、`only_runtime=[]`) | +| operation 集合是否完全一致 | — | **True**(`only_static=[]`、`only_runtime=[]`) | + +**RUNTIME_OPENAPI_RESULT = PASS**(**非**仅比较数量:逐路径、逐 `METHOD path` 做集合相等判定)。 +`docs/openapi-rc.json` **未修改**。 + +## 21. §6 Token 与会话矩阵(真实 HTTP) + +**结果:36 / 36 全部 PASS。** + +| 分段 | 项数 | 结果 | +|---|---|---| +| §8 端口与健康(复跑) | 15 | **15/15 PASS** | +| §9.1 Token 类型矩阵 | 13 | **13/13 PASS** | +| §9.2 并发刷新与重放 | 3 | **3/3 PASS** | +| §9.3 登出与会话注销 | 5 | **5/5 PASS** | + +### 21.1 类型矩阵(每格均为实测) + +| Token | 普通接口 `/auth/me` | 刷新接口 `/auth/refresh` | +|---|---|---| +| Access | **200** | **401** | +| Refresh | **401** | **200** | +| 无 `type` | 401 | 401 | +| 错误密钥 | 401 | 401 | +| 错误 `aud` | 401 | 401 | +| 错误 `iss` | 401 | 401 | +| 缺 `iat` | 401 | 401 | +| 未来 `iat` | 401 | 401 | +| 已过期 | 401 | 401 | +| 非法 `jti` | 401 | 401 | +| 非法 `sid` | 401 | 401 | +| 非法 `sub`(非正数) | 401 | 401 | + +**拒绝响应泄漏扫描**:Token 原文 / `principal` / `claim` / 异常类 / 堆栈 / SQL / 本地路径 +→ **全部 0 命中** ✔ + +### 21.2 并发刷新与重放(10 轮 × 2 独立客户端) + +| 断言 | 结果 | +|---|---| +| 每轮最多 1 个成功 | **10/10 轮** ✔ | +| 失败方只能 401,不得 500 | ✔ | +| 该 session 活动 Refresh **严格为 0** | `act=0`(每轮实测)✔ | +| 原 Access 失效 | `old=401`(每轮实测)✔ | +| 无锁异常 / 无 `could not take locks` | ✔ | +| 数据库不存 JWT 原文 | `auth_tokens` 中 `token_hash LIKE 'eyJ%'` → **0** ✔ | + +### 21.3 登出与会话 + +| 断言 | 结果 | +|---|---| +| 单设备登出 | 200 ✔ | +| 只撤销当前 session,其他设备保持有效 | `A=0 B=1`,B 的 `/auth/me` → 200 ✔ | +| 重复登出幂等 | 已撤销的 Access → 401 ✔ | +| inactive 用户不能登录 | 拒绝(HTTP 400,<500,非 500)✔ | + +### 21.4 本轮两项**脚本缺陷**(非产品缺陷,已修正) + +| # | 现象 | 判定 | 修正 | +|---|---|---|---| +| S-1 | 夹具手机号 `1990000`(7 位)+5 位 = **12 位**,登录被拒 `phone must contain exactly 11 digits` | **脚本缺陷**;产品校验**正确生效** | 前缀改为 `199000`(6 位)+5 位 = 11 位 | +| S-2 | 「业务健康不泄露组件明细」误报,`act=db` | **脚本缺陷**:对整段 JSON 做子串匹配,随机 `requestId` 十六进制串恰好含 `db` | 改为仅解析 `data` 对象的键并做集合判定 | +| S-3 | 控制台 GBK 无法输出 `⊆` | 脚本缺陷 | 改为 ASCII 文案 | + +## 22. §12 运行时敏感扫描 + +**扫描范围**:最新应用日志、本轮 HTTP 响应、`auth_tokens`、`audit_logs`、`notifications`、`messages`、验收脚本输出。 + +| 目标 | 模式 | 命中 | 分类 | +|---|---|---|---| +| 应用日志 | `eyJ…`(JWT 原文) | **0** | — | +| 应用日志 | `Bearer ` | **0** | — | +| 应用日志 | 11 位手机号 | **0** | — | +| 应用日志 | `authorization` | **0** | — | +| 应用日志 | `password` | 2 | Spring Boot 默认 `Using generated security password=`;`SecurityConfig` **未启用** `httpBasic`/`formLogin`/`UserDetailsService`,该用户**惰性**。见 OBS-NEW-05 | +| HTTP 响应 | Token/principal/claim/异常类/堆栈/SQL/本地路径 | **0** | 见 §21.1 | +| `auth_tokens` | JWT 原文 | **0** | 仅存摘要 ✔ | +| `audit_logs` | `eyJ…` | **0** | — | +| `notifications` / `messages` | `eyJ…` | **0 / 0** | — | +| `audit_logs` | 11 位数字 | **818** | 见 **OBS-NEW-04**(**历史遗留**) | +| `audit_logs` | `claim%` | **0** | — | + +**扫描器探针有效性**:以注入式探针(`Authorization: Bearer`、11 位手机号、`DB_PASSWORD=`)验证后清理; +`audit_logs` 的 818 命中即为扫描器**确实能命中**的实证。 + +## 23. 本轮**新增**观察项 + +| ID | 内容 | 是否阻断 | 影响范围 | 证据 | 推荐后续动作 | +|---|---|---|---|---|---| +| **OBS-NEW-04** | `audit_logs` 中 `action='SMS_SEND'` 的 **818 行** `details` 含**完整手机号**(文本形态 `Mock SMS sent to <11 位号码> for login`,全部匹配真实移动号段 `1[3-9]…`) | **否**(不阻断本分支) | 本地验收数据库**历史数据**;生产审计表的 PII 留存面 | 时间范围 **2026-08-06 17:21 → 2026-09-17 14:46**,**全部早于**本分支 S01 加固;**本轮贡献 0 行**(本轮 `mock=false`,不产生该链路);`grep "Mock SMS sent to" src/main` → **当前代码已无该写入路径**(S01 已移除) | ① 属**数据留存**问题而非代码缺陷;② 生产侧需制定审计 PII 保留/脱敏策略;③ 如需清理须按数据治理流程单独执行,**本轮未删除任何 `audit_logs` 行** | +| **OBS-NEW-05** | 启动日志在 INFO 级输出 `Using generated security password=` | **否** | 日志噪声 + 潜在的惰性默认账号 | 应用日志第 21/23 行;`SecurityConfig` 中 `httpBasic`/`formLogin`/`UserDetailsService` **均无命中**,故无认证入口可到达该账号 | 排除 `UserDetailsServiceAutoConfiguration`(或在生产 profile 关闭)以消除该日志与默认账号 | + +## 24. §13 收尾:清理、保留足迹、RabbitMQ 终态 + +| 项 | 结果 | +|---|---| +| 本轮夹具用户(`199000%` 号段) | 清理前 **12** → 清理后 **0** ✔ | +| `sms_verification_codes`(`1990%`) | **0** ✔ | +| **`audit_logs`** | **未删除任何行**;验收开始前 **2868** 行 → 结束后 **2868** 行(**保留足迹,数量不变**)✔ | +| 应用 PID | 精确停止 **1419**;未停止任何未知 Java 进程 | +| `8080` / `8081` | 均已空闲 ✔ | +| 临时密钥文件 | `backend-infra/.final_acc_secrets.env` **已删除** | +| Docker 容器 | 保持运行(`mydreamjob-mysql`/`redis`/`rabbitmq`),未重建、未删 volume | +| 历史未跟踪文件 | 完整保留 | +| 本轮新增未跟踪文件 | 4 个验收脚本 + 3 个 JSON/日志产物 + 本报告 | + +**RabbitMQ 四队列终态**: + +| 队列 | ready | unacked | +|---|---|---| +| `queue.sms.verification` | **0** | **0** | +| `queue.notification` | **0** | **0** | +| `queue.audit` | **0** | **0** | +| `queue.notification.dlq` | **0** | **0** | + +**四队列归零 ✔**,与验收开始前一致;**未执行 purge**。 + +## 25. 续跑后的逐项状态(PASS / FAIL / SKIP) + +| 节 | 项目 | 状态 | +|---|---|---| +| §1 | 分支/HEAD/upstream/暂存区/两处 `M` 三方核验 | **PASS** | +| §2 | 提交历史与阶段完整性、迁移 V42–V45、V1–V41 未修改 | **PASS** | +| §2 | S01/S03 静态安全 | **PASS** | +| §3 | 三轮 `mvn clean verify` | **PASS**(3/3) | +| §4 | 最新 JAR + 真实 MySQL/Redis/RabbitMQ + 双端口 + Flyway V45 | **PASS** | +| §5 | 运行时 OpenAPI 逐集合比对 | **PASS** | +| §6 | Token 类型矩阵 | **PASS**(13/13) | +| §6 | 并发刷新与重放(10 轮) | **PASS**(3/3) | +| §6 | 登出与会话注销 | **PASS**(5/5) | +| §12 | 运行时敏感扫描(日志/响应/DB) | **PASS**,附 OBS-NEW-04/05 | +| §13 | 收尾清理与队列终态 | **PASS** | +| **§7** | **S01 岗位权限矩阵 + 短信失败关闭运行时** | **SKIP(未执行)** | +| **§8** | **S03 简历隐私矩阵** | **SKIP(未执行)** | +| **§8** | **管理员初始化矩阵** | **SKIP(未执行)** | +| **§8** | **V43 真实迁移验证** | **SKIP(未执行)** | +| **§9** | **Q01 真实数据库查询矩阵** | **SKIP(未执行)** | +| **§10** | **三个 GET 评分接口 22 表零写快照** | **SKIP(未执行)** | +| **§10** | **写侧刷新任务链路** | **SKIP(未执行)** | +| **§10** | **claim token 100 轮并发** | **SKIP(未执行)** | +| **§11** | **运行时 22 张表逐字段副作用 + 四类反证** | **SKIP(未执行)** | + +## 26. 续跑后最终 Git 状态 + +| 项 | 结果 | +|---|---| +| HEAD | `2319f97203a2fcf8012869fda8c30599ffb305`(**未变**) | +| upstream | `2319f972…`,ahead/behind **0 / 0** | +| 暂存区 | **空** | +| 已跟踪文件修改 | **无**(`git diff --name-status` 空) | +| `git diff --check` | exit 0 | +| V1–V45 迁移修改 | **无**;**无 V46** | +| master 合并 | **无** | +| commit / push / merge / rebase / force push | **均未执行** | + +## 27. 续跑后最终判定 + +> ## **FAIL(仍未通过)** + +**原因**:§25 中标为 **SKIP** 的 9 项(§7 岗位权限与短信、§8 简历隐私/管理员初始化/V43、 +§9 Q01 运行时、§10 评分零写/写侧刷新/claim token 100 轮、§11 运行时 22 表副作用)**本轮未执行**。 +按验收规则「任一核心项 FAIL 或 SKIP:总结必须写 FAIL」,**不得**标记为通过,**不得**使用「基本通过」。 + +**本轮新增的通过项**(均已在 §17–§24 给出实测证据): +三轮 `clean verify` 2788/0/0/0 稳定、最新 JAR + 真实三中间件启动与双端口隔离、 +运行时 OpenAPI 逐集合一致、Token 类型矩阵与并发重放登出共 36/36、运行时敏感扫描、 +队列归零与夹具零残留。 + +**未发现任何产品缺陷**。本轮发现的 3 项脚本缺陷与 1 项历史数据观察(OBS-NEW-04)均已如实分类, +未现场修改任何生产代码或既有测试。 + +**下一轮定向整改指令(承接 §15,不修改生产代码)**: + +1. 补齐 §7 岗位权限矩阵与短信失败关闭运行时(注意 `COMPANY_NOT_FOUND`→404 的既有契约,见 OBS-NEW-02)。 +2. 补齐 §8 简历隐私矩阵(含拒绝路径 storage read = 0)、管理员初始化隔离矩阵、V43 真实迁移验证。 +3. 补齐 §9 Q01 真实数据库查询矩阵与 EXPLAIN 分表报告。 +4. 补齐 §10 三 GET 零写 22 表快照、写侧刷新链路、claim token 100 轮。 +5. 补齐 §11 运行时 22 表逐字段副作用与改/插/删/单列四类反证。 +6. 就 **OBS-NEW-04** 制定审计 PII 留存策略(数据治理,非代码改动);就 **OBS-NEW-05** 排除默认安全用户自动配置。 + +--- +--- + +# 续跑记录(2026-09-18 第三轮,追加) + +上文全部内容(初次 FAIL、首次 `clean verify` 的 2 个 Error、MSYS vhost 缺陷、OBS-NEW-01、 +第二轮三轮构建结果与全部原始证据)**原样保留、未删改**。 + +> ### ⚠ 本轮发生一次**验收脚本误操作历史数据**事件,已复原并如实记录(见 §32)。 + +## 28. §7 S01 岗位权限矩阵(真实 HTTP):**20/20 PASS** + +| 用例 | 期望 | 实测 | +|---|---|---| +| 匿名 GET `/jobs`(列表保持公开) | 200 | **200** ✔ | +| 匿名 GET `/jobs/public/{id}`(公开详情) | 200 | **200** ✔ | +| 匿名 GET `/jobs/{id}`(企业端详情) | 401 | **401** ✔ | +| 匿名 POST `/jobs` | 401 | **401** ✔ | +| 匿名 PUT `/jobs/{id}` | 401 | **401** ✔ | +| 匿名 PATCH `/jobs/{id}/status`(发布) | 401 | **401** ✔ | +| 匿名 DELETE `/jobs/{id}` | 401 | **401** ✔ | +| COMPANY 创建岗位 | 200/201 | **成功** ✔ | +| COMPANY 发布岗位 | 200 | **200** ✔ | +| STUDENT 创建岗位 | 拒绝且非 500 | **403 / code=2002** ✔ | +| ADMIN 创建岗位 | 拒绝且非 500 | **403 / code=2002** ✔ | +| 其他企业修改他人岗位 | 非 200 且非 500 | **400** ✔ | +| 失败请求不写入岗位表 | jobCount 不变 | **27 → 27** ✔ | +| 异常主体 `anonymousUser` | 401 | **401** ✔ | +| 异常主体 非数字 | 401 | **401** ✔ | +| 异常主体 20 位 Long 溢出 | 401 | **401** ✔ | +| 异常主体 零值 `sub` | 401 | **401** ✔ | +| 生产 `mock=false` 短信安全失败 | 非 200 且非 5xx | **400** ✔ | +| 不伪造发送成功 | 响应无成功文案 | **无** ✔ | +| 失败响应不含手机号/验证码/Token | 无泄漏 | **无** ✔ | + +**说明**:STUDENT/ADMIN 创建岗位返回 **403 / code=2002(FORBIDDEN)**,与指令期望一致; +该项在本轮**实测**,修正了第二轮基于静态基线的推断(见 OBS-NEW-02)。 + +## 29. §9 Q01 岗位搜索真实数据库矩阵:**13/15**,发现 **DEF-01** + +### 29.1 通过项(13) + +空条件返回结果、标题关键词、描述关键词、大小写不敏感、特殊字符(`Delta_100%`)、 +城市过滤(`city=上海` → 1)、最低薪资下限、最高薪资上限、边界相等、无结果、 +分页 `size=1`、非公开(DRAFT)岗位不进入公开搜索 —— 全部符合预期。 + +### 29.2 **DEF-01:`/jobs` 与 `/jobs/search` 关键词语义分歧(产品行为缺陷)** + +**最小复现(真实 HTTP,已保存)**: + +``` +GET /api/v1/jobs?keyword=%20AlphaEngineer%20 → items=0 (期望 1:应 trim 后命中) +GET /api/v1/jobs?keyword=%20%20%20 → items=0 (期望 20:空白应等同未传) +GET /api/v1/jobs/search?keyword=%20AlphaEngineer%20 → items=1 (正确) +GET /api/v1/jobs/search?keyword=%20%20%20 → items=20 (正确) +GET /api/v1/jobs → items=20 +GET /api/v1/jobs?keyword=AlphaEngineer → items=1 +``` + +**根因(静态定位,未修改代码)**: + +| 端点 | 调用链 | 关键词处理 | trim | 空白关键词 | +|---|---|---|---|---| +| `GET /jobs` | `JobController:97` → `JobService.listPublishedJobs` → `JobRepository:26`(JPQL `LIKE %:keyword%`) | 仅判 `:keyword IS NULL` | **无** | 空白串**非 NULL** → 谓词生效 → **0 条** | +| `GET /jobs/search` | `JobController:134` → `JobService.searchJobs` → `JobSearchSpecification:53` | `"%" + keyword.trim() + "%"` + `isBlank()` 判空 | **有** | 空白被 `isBlank()` 跳过 → **全部** | + +**判定**:指令 §12 明确列出 **trim** 与 **空白关键词** 两个验收用例。`/jobs` 端点两条均不满足 +(trim 未生效、空白关键词返回 0 而非全部),且与同一产品的另一公开端点 `/jobs/search` 行为不一致。 +按「产品缺陷直接 FAIL,不现场修复」→ **判定为 FAIL 项**,**未修改任何生产代码**。 + +**定向整改指令(下一轮,不属本轮范围)**: +统一 `GET /jobs` 与 `GET /jobs/search` 的关键词语义 —— 使 `listPublishedJobs` 路径同样对 +`keyword` 执行 `trim` + `isBlank` 判空(或将 `/jobs` 直接委托给 `JobSearchSpecification`), +并补充「带前后空白的关键词」与「纯空白关键词」两条 HTTP 回归用例。注意 +`JobRepository:26` 与 `JobSearchSpecification` 是**两套并行实现**,整改时需评估是否合并。 + +### 29.3 本轮 Q01 未覆盖项 + +排序(`sort`)、H2/MySQL 逐项对账(ID 与顺序一致)、EXPLAIN 分表报告 —— **SKIP(未执行)**。 + +## 30. §7/§9 本轮脚本缺陷(非产品缺陷,已修正) + +| # | 现象 | 判定 | 修正 | +|---|---|---|---| +| S-4 | 匿名公开详情断言 401(期望 200) | **脚本缺陷**:调用了企业端 `/jobs/{id}`;公开端点是 `/jobs/public/{id}` | 改用正确端点,并**新增**一条「企业端详情匿名 401」断言 | +| S-5 | 中文城市与含空格关键词全部 0 条 | **脚本缺陷**:查询参数**未做 URL 编码** | 改用 `urllib.parse.quote` | +| S-6 | 清理时 `fk_jobs_company` 外键失败 | **脚本缺陷**:未按外键顺序删除 | 改为 applications → jobs → companies → users | + +## 31. 第三轮收尾 + +| 项 | 结果 | +|---|---| +| 本轮夹具残留(`199000%` 用户 / `FINALACC_20260918_%` 公司·岗位 / 短信码) | **全部 0** ✔ | +| **`audit_logs`** | **未删除任何行**;本轮开始前 **2868** → 第三轮开始前 **2888** → 结束后 **2888**(本轮 HTTP 产生的审计行**保留**,未删) | +| 应用 PID | 精确停止 **1438** | +| `8080` / `8081` | 均已空闲 ✔ | +| 临时密钥文件 | `.final_acc_secrets.env` **已删除** | +| RabbitMQ 四队列 | 全部 `ready=0 unacked=0` ✔,**未 purge** | +| Docker | 三容器保持运行,未重建、未删 volume | + +## 32. ⚠ 验收脚本误操作历史数据事件(已复原,如实记录) + +**经过**:第三轮首次运行 `final_acc_20260918_b_jobs_search.py` 时,夹具清理使用了**过宽谓词** +`phone LIKE '1990%'`(本意是匹配本轮 `199000 + 5 位` 夹具),导致两条**既有历史用户** +(`users.id = 7938 / 7939`,创建于 **2026-09-14 09:48**,非本轮产生)的 `user_roles` 行被删除。 + +**发现**:紧随其后的 `DELETE FROM users WHERE phone LIKE '1990%'` 因 +`fk_applications_student` 外键失败而中止,据此定位到该前缀下存在非本轮用户。 + +**影响面(已逐项核实)**: + +| 对象 | 状态 | +|---|---| +| `users` 7938 / 7939 | **未删除**(删除语句因外键失败,未生效)✔ | +| `user_roles` 7938 / 7939 | **被误删** ✗ → **已复原** | +| `applications`(学生 7938/7939,共 2 条) | **未受影响** ✔ | +| `auth_tokens`(7938/7939) | 原本为 0,未受影响 ✔ | +| 其它表 | 未触及 | + +**复原操作与依据**:`INSERT INTO user_roles (user_id, role) VALUES (7938,'STUDENT'),(7939,'STUDENT');` +依据:`users.role` 两行均为 `STUDENT`,且库内 **256** 条 STUDENT 用户的 `user_roles` **全部**为 +`('STUDENT')`(1:1 形态)。复原后已核验两行存在且角色正确。 + +**同类错误未再次发生**:脚本已改为精确前缀 `199000%` 并补全外键删除顺序;第三轮后续运行未再触碰历史行。 + +**教训与建议**:验收夹具前缀应避免使用过短的数字前缀(`1990` 会与既有测试号段冲突), +建议改用更长且唯一的号段,并在任何 DELETE 前先 `SELECT COUNT(*)` 输出命中范围。 + +## 33. 第三轮后的逐项状态 + +| 节 | 项目 | 状态 | +|---|---|---| +| §7 S01 岗位权限矩阵(20 项) | 真实 HTTP 全量执行 | **PASS** | +| §7 S01 短信生产失败关闭 | 真实 HTTP | **PASS** | +| §9 Q01 搜索矩阵(15 项) | 真实 HTTP + MySQL | **13 PASS / 2 FAIL(DEF-01)** | +| §9 Q01 排序 / H2-MySQL 对账 / EXPLAIN | — | **SKIP** | +| §8 S03 简历隐私矩阵 | — | **SKIP** | +| §8 S03 管理员初始化矩阵 | — | **SKIP** | +| §8 S03 V43 真实迁移验证 | — | **SKIP** | +| §10 R01 三 GET 零写 22 表快照 | — | **SKIP** | +| §10 R01 写侧刷新链路 | — | **SKIP** | +| §10 R01 claim token 100 轮 | — | **SKIP** | +| §11 运行时 22 表逐字段副作用 + 四类反证 | — | **SKIP** | +| §12 运行时敏感扫描(日志/响应/DB) | 已在第二轮执行 | **PASS**(附 OBS-NEW-04/05) | + +## 34. 第三轮后最终 Git 状态 + +| 项 | 结果 | +|---|---| +| HEAD | `2319f97203a2cafcf8012869fda8c30599ffb305`(**未变**) | +| upstream | 同提交,ahead/behind **0 / 0** | +| 暂存区 | **空** | +| 已跟踪文件修改 | **无** | +| `git diff --check` | exit 0 | +| V1–V45 迁移修改 | **无**;**无 V46** | +| master 合并 / commit / push / merge / rebase / force push | **均未执行** | + +## 35. 第三轮后最终判定 + +> ## **FAIL(仍未通过)** + +**新增 FAIL 项:DEF-01**(`/jobs` 关键词 trim 与空白关键词行为不符合 Q01 验收判据, +与 `/jobs/search` 语义分歧)—— 属**产品行为缺陷**,已给出最小复现与定向整改指令,**未修改生产代码**。 + +**仍为 SKIP 的项**:§8 全部(简历隐私矩阵、管理员初始化矩阵、V43 真实迁移)、 +§9 排序与 H2/MySQL 对账与 EXPLAIN、§10 全部(三 GET 零写、写侧刷新、claim token 100 轮)、 +§11 运行时 22 表副作用与四类反证。 + +**第三轮新增通过项**:S01 岗位权限矩阵 20/20(真实 HTTP)、短信生产失败关闭、 +Q01 搜索矩阵 13/15(真实 HTTP + MySQL)、夹具零残留与审计保留足迹、队列归零。 + +**下一轮定向整改指令**: + +1. **DEF-01(优先,产品缺陷)**:统一 `GET /jobs` 与 `GET /jobs/search` 的关键词语义 + (`trim` + `isBlank` 判空),并补充 HTTP 回归用例;评估 `JobRepository:26` 与 + `JobSearchSpecification` 两套并行实现是否合并。 +2. 补齐 §8 简历隐私矩阵、管理员初始化隔离矩阵、V43 真实迁移验证。 +3. 补齐 §9 排序、H2/MySQL 逐项对账与 EXPLAIN 分表报告。 +4. 补齐 §10 三 GET 零写 22 表快照、写侧刷新链路、claim token 100 轮。 +5. 补齐 §11 运行时 22 表逐字段副作用与改/插/删/单列四类反证。 +6. 验收夹具改用更长唯一号段;任何 DELETE 前先输出命中范围(见 §32)。 + +--- +--- + +# 续跑记录(2026-09-18 第四轮,追加) + +上文全部内容(初次 FAIL、首次 2 个 Error、MSYS vhost 缺陷、OBS-NEW-01、第二/三轮结果、 +§32 历史数据误操作事件)**原样保留、未删改**。 + +## 36. §8 S03 —— V43 真实 MySQL 验证:**PASS** + +在 `mydreamjob-mysql`(真实 MySQL 8.4,V43 已随 Flyway 执行、`success=1`)上逐项验证: + +| 断言 | 期望 | 实测 | +|---|---|---| +| 完整五要素指纹命中并禁用 | 三个演示账号 `is_active=0` | `13800000001 ADMIN 0`、`13800000002 STUDENT 0`、`13800000003 COMPANY 0` ✔ | +| `users` / `user_roles` **保留**(不删除) | 3 / 3 | **3 / 3** ✔ | +| 重复执行稳定(幂等) | 重跑三段 UPDATE 受影响行数 = 0 | **0 / 0 / 0** ✔ | +| **任意单字段改变不命中** | 改 phone / email / password_hash / role 各自 0 行 | **0 / 0 / 0 / 0** ✔(证明五个合取项全部生效) | +| 不依赖固定 ID | V43 的 `WHERE` 无 `id =` 谓词 | 静态核对 V43 SQL:仅用 phone+email+password_hash+role+is_active 五元指纹 ✔ | +| 执行后演示账号未被误改 | 仍为禁用且字段不变 | 复核 `1/2/3` 三行 `is_active=0` ✔ | + +**未覆盖**:V43「原演示账号登录被拒绝」的**运行时**验证 —— **SKIP**(需构造 `sms_verification_codes` 并调用登录, +本轮未执行;其等价语义已由 S01 的「inactive 用户不能登录」HTTP 用例覆盖并通过,见 §21.3)。 + +## 37. §10 三个 GET 评分接口零写(24 张表逐字段快照):**PASS(7/7)** + +| 断言 | 结果 | +|---|---| +| `GET /profile/me/candidate-scoring`(综合评分) | **200** ✔ | +| `GET /profile/me/candidate-scoring/dimensions/EDUCATION`(单维度) | **200** ✔ | +| `GET /company/candidates/{applicationId}/scoring`(企业评分) | **200** ✔ | +| **三个 GET 前后 24 张表逐字段零变化** | `changed=none` ✔ | +| GET 不新增 `candidate_scoring_refresh_tasks` | `0 -> 0` ✔ | +| `candidate_dimension_scores` 行数与 `version` 不推进 | `rows 1->1`、`ver 0->0` ✔ | +| 无快照学生请求后仍为 **0 行** | `0` ✔ | + +**快照覆盖的 24 张表**:users、user_roles、student_profiles、companies、jobs、applications、 +resume_versions、auth_tokens、audit_logs、notifications、messages、message_reads、conversations、 +conversation_members、candidate_dimension_scores、candidate_scoring_refresh_tasks、 +candidate_scoring_appeals、offers、interviews、candidate_feedback、talent_invitations、 +scoring_risk_events、scoring_risk_actions、personal_data_export_tasks。 +每张表**显式列出稳定字段**(含 `version` / `updated_at` 等),按主键排序,逐字段深比较 —— +**未**只比较 COUNT / SUM / CRC。 + +## 38. §11 副作用快照比较器的四类反证:**PASS(4/4)** + +每条反证都要求**变更真正命中 1 行**且**被比较器检出**(避免「没改到任何行」造成的假通过): + +| 反证 | 变更语句 | 受影响行数 | 是否检出 | +|---|---|---|---| +| 改一行 | `UPDATE users SET nickname=…` | **1** | **True** ✔ | +| 插一行 | `INSERT INTO candidate_scoring_refresh_tasks …` | **1** | **True** ✔ | +| 改单列 | `UPDATE candidate_dimension_scores SET status='PENDING' …` | **1** | **True** ✔ | +| 删一行 | `DELETE FROM candidate_dimension_scores …` | **1** | **True** ✔ | + +⇒ 快照比较器**确实具备检出能力**,§37 的「零变化」不是「没在比」。 + +## 39. 第四轮脚本缺陷(非产品缺陷,已修正) + +| # | 现象 | 判定 | 修正 | +|---|---|---|---| +| S-7 | 反证「插一行 / 删一行 / 改单列」未检出 | **脚本缺陷**:变更语句**未命中任何行**(`scoring_risk_events` 无 `student_user_id` 列而静默失败;`candidate_feedback` 无对应行;`dimension_scores` 已被前序步骤删除) | 改用确定存在的行,并断言 `ROW_COUNT()=1` | +| S-8 | 快照列名错误 | **脚本缺陷**:`scoring_risk_events` 实际列为 `subject_user_id`,非 `student_user_id` | 按 `information_schema` 实测列名修正 | +| S-9 | 收尾清理报 `Unknown column` | 同 S-8 | 同上 | + +## 40. 第四轮收尾 + +| 项 | 结果 | +|---|---| +| 夹具残留(用户 / 公司 / 岗位 / 短信码 / refresh task) | **全部 0** ✔ | +| **`audit_logs`** | **未删除任何行**;本轮 HTTP 产生的审计行**保留**,第三轮结束 2888 → 本轮结束 **2900** | +| 历史用户 `7938 / 7939` | `users.role` 与 `user_roles.role` 均为 `STUDENT`,**完好** ✔ | +| 应用 PID | 精确停止 **1452** | +| `8080` / `8081` | 空闲 ✔ | +| 临时密钥文件 | 已删除 | +| RabbitMQ 四队列 | 全部 `ready=0 unacked=0` ✔,未 purge | +| Docker | 三容器保持运行,未重建、未删 volume | + +## 41. 第四轮后逐项状态 + +| 节 | 项目 | 状态 | +|---|---|---| +| §8 S03 V43 真实 MySQL 验证 | — | **PASS** | +| §10 三 GET 零写(24 表逐字段) | — | **PASS(7/7)** | +| §11 四类反证 | — | **PASS(4/4)** | +| §8 S03 简历隐私矩阵 | — | **SKIP** | +| §8 S03 管理员初始化矩阵 | — | **SKIP** | +| §8 S03 V43 登录拒绝(运行时) | — | **SKIP** | +| §9 Q01 排序 / H2-MySQL 对账 / EXPLAIN | — | **SKIP** | +| §10 写侧刷新链路 | — | **SKIP** | +| §10 claim token 100 轮 | — | **SKIP** | + +## 42. 第四轮后最终 Git 状态 + +| 项 | 结果 | +|---|---| +| HEAD | `2319f97203a2cafcf8012869fda8c30599ffb305`(**未变**) | +| upstream | 同提交,**0 / 0** | +| 暂存区 | **空** | +| 已跟踪文件修改 | **无** | +| V1–V45 修改 / V46 | **无 / 不存在** | +| master 合并 / commit / push / merge / rebase / force push | **均未执行** | + +## 43. 全轮次累计判定 + +> ## **FAIL(仍未通过)** + +**累计 FAIL 项**:**DEF-01**(`/jobs` 关键词 trim 与空白关键词不符合 Q01 判据,与 `/jobs/search` 语义分歧;产品缺陷,已给最小复现与整改指令,未修改生产代码)。 + +**累计 SKIP 项**:§8 简历隐私矩阵、管理员初始化矩阵、V43 运行时登录拒绝;§9 排序 / H2-MySQL 逐项对账 / EXPLAIN 分表报告;§10 写侧刷新链路、claim token 100 轮。 + +**累计 PASS 项**:开工核查与两处 `M` 三方核验、提交与迁移完整性、S01/S03 静态审查、 +三轮 `clean verify` 2788/0/0/0、最新 JAR + 真实三中间件 + 双端口 + Flyway V45、 +运行时 OpenAPI 逐集合一致、Token/会话矩阵 36/36、S01 岗位权限矩阵 20/20、 +S03 V43 真实 MySQL 验证、Q01 搜索矩阵 13/15、三 GET 零写 24 表快照 7/7、 +四类反证 4/4、运行时敏感扫描、夹具零残留与审计保留足迹、队列归零。 + +**最终 Git**:HEAD 未变、upstream 0/0、暂存区空、无已跟踪修改、未提交、未推送、未合并。 + +--- +--- + +# 续跑记录(2026-09-18 第五轮,追加) + +上文全部内容(初次 FAIL、首次 2 个 Error、MSYS vhost 缺陷、OBS-NEW-01、第二/三/四轮结果、 +§32 历史数据误操作事件)**原样保留、未删改**。 + +## 44. §8 S03 —— V43 运行时登录拒绝:**PASS(4/4)** + +对 V43 禁用的两个 Flyway 演示账号,注入合法验证码后走真实登录链路: + +| 断言 | 期望 | 实测 | +|---|---|---| +| 已禁用演示 **ADMIN**(`13800000001`)登录 | 非 200 且无 Token | **HTTP 400,无 `accessToken`** ✔ | +| 已禁用演示 **STUDENT**(`13800000002`)登录 | 非 200 且无 Token | **HTTP 400,无 `accessToken`** ✔ | +| 被拒登录未产生会话行 | `auth_tokens` = 0 | **0** ✔ | +| 演示账号行未被删除 | 2 行存在 | **2** ✔ | + +⇒ V43 的**目标语义**(禁用后无法取得会话)在真实运行实例上成立;结合 §36 的真实 MySQL 指纹验证, +V43 的静态 + 数据 + 运行时三层证据齐备。 + +## 45. §9 Q01 排序:**部分 PASS,排序语义 SKIP** + +| 用例 | 结果 | +|---|---| +| 白名单校验:未知 `sort` 值一律 400 | `salary_desc` / `salary_asc` / `salaryDesc` / `salaryAsc` → **400** ✔(**正确的白名单拒绝**) | +| `sort=latest` | **200** ✔ | +| `sort=salary` | **200** ✔(白名单内) | +| `sort` 省略 | **200** ✔ | +| **`sort=salary` 的升降序与 NULL 排序语义** | **SKIP** —— 未用夹具验证顺序 | +| **`sort=relevance` 的相关度语义** | **SKIP** | + +**白名单事实(静态)**:`JobService:201` `ALLOWED_SORTS = Set.of("latest", "salary", "relevance")`; +`JobService:216` 非白名单值抛 `BusinessException(BAD_REQUEST)` → 400。 +`JobService:207` 注释说明 `sort=salary` 用 CriteriaQuery 的 CASE 排序使 `salaryMax=NULL` 排在最后。 + +## 46. 第五轮脚本缺陷(非产品缺陷,已修正) + +| # | 现象 | 判定 | 修正 | +|---|---|---|---| +| S-10 | 排序用例 4 项 400 | **脚本缺陷**:使用了**不存在的** `sort` 值(`salary_desc`/`salaryAsc` 等);产品白名单只有 `latest`/`salary`/`relevance`,返回 400 是**正确行为** | 记录真实白名单;顺序语义因缺夹具未验证,标记 SKIP | +| S-11 | 排序段 `TypeError: int() … NoneType` | **脚本缺陷**:只登录创建了 STUDENT 用户,未建公司夹具 | 补 SQL 公司夹具 | + +## 47. 第五轮收尾 + +| 项 | 结果 | +|---|---| +| 夹具残留(用户 / 公司 / 岗位 / 短信码) | **全部 0** ✔ | +| **`audit_logs`** | **未删除任何行**;第四轮 2900 → 本轮 **2904**(本轮产生的审计行保留) | +| Flyway 演示账号 | 仍为 `13800000001=0, 13800000002=0, 13800000003=0`(**禁用状态未被改动**)✔ | +| 历史用户 `7938 / 7939` 的 `user_roles` | **2 行存在**(§32 复原成果保持)✔ | +| 应用 PID | 精确停止 **1791** | +| `8080` / `8081` | 空闲 ✔ | +| 临时密钥文件 | 已删除 | +| RabbitMQ 四队列 | 全部 `ready=0 unacked=0` ✔,未 purge | + +## 48. 全轮次最终状态汇总 + +| 节 | 项目 | 状态 | +|---|---|---| +| §1 | 分支/HEAD/upstream/暂存区/两处 `M` 三方核验 | **PASS** | +| §2 | 提交与迁移完整性、V1–V41 未修改、S01/S03 静态审查 | **PASS** | +| §3 | 三轮 `mvn clean verify` | **PASS(3/3)** | +| §4 | 最新 JAR + 真实 MySQL/Redis/RabbitMQ + 双端口 + Flyway V45 | **PASS** | +| §5 | 运行时 OpenAPI 逐集合一致 | **PASS** | +| §6 | Token/会话矩阵(类型 + 并发重放 + 登出) | **PASS(36/36)** | +| §7 | S01 岗位权限矩阵 + 短信失败关闭 | **PASS(20/20)** | +| §8 | S03 V43 真实 MySQL 指纹验证 | **PASS** | +| §8 | S03 V43 运行时登录拒绝 | **PASS(4/4)** | +| §9 | Q01 搜索矩阵 | **13 PASS / 2 FAIL(DEF-01)** | +| §9 | Q01 排序白名单 | **PASS** | +| §9 | Q01 排序语义 / H2-MySQL 逐项对账 / EXPLAIN 分表 | **SKIP** | +| §10 | 三 GET 零写(24 表逐字段) | **PASS(7/7)** | +| §10 | 写侧刷新链路 | **SKIP** | +| §10 | claim token 100 轮 | **SKIP** | +| §11 | 四类反证 | **PASS(4/4)** | +| §12 | 运行时敏感扫描 | **PASS**(附 OBS-NEW-04/05) | +| §13 | 收尾清理、队列归零、审计保留足迹 | **PASS** | +| §14 | 简历隐私矩阵 | **SKIP** | +| §14 | 管理员初始化矩阵 | **SKIP** | + +## 49. 最终判定(全轮次) + +> ## **FAIL(仍未通过)** + +**FAIL 项(1)**:**DEF-01** —— `GET /jobs` 的关键词 `trim` 与空白关键词行为不符合 Q01 验收判据, +且与 `GET /jobs/search` 语义分歧(`/jobs?keyword=%20%20%20` → 0 条;`/jobs/search?keyword=%20%20%20` → 20 条)。 +**产品行为缺陷**,已给出最小复现与定向整改指令,**未修改任何生产代码**。 + +**SKIP 项(5)**:§14 简历隐私矩阵、§14 管理员初始化矩阵、§9 排序语义与 H2/MySQL 逐项对账与 EXPLAIN 分表、 +§10 写侧刷新链路、§10 claim token 100 轮。 + +**观察项(不阻断)**:OBS-01、OBS-02、OBS-E01、OBS-NEW-01(已降级)、OBS-NEW-02、OBS-NEW-03、 +OBS-NEW-04(审计历史 PII)、OBS-NEW-05(默认安全口令日志)。 + +**产品缺陷数**:**1**(DEF-01)。**未发现其它产品缺陷**;本轮其余失败全部定位为**验收脚本/环境缺陷**(S-1…S-11)。 + +**最终 Git 状态**: + +| 项 | 结果 | +|---|---| +| HEAD | `2319f97203a2cafcf8012869fda8c30599ffb305`(**未变**) | +| upstream | `2319f972…`,ahead/behind **0 / 0** | +| 暂存区 | **空** | +| 已跟踪文件修改 | **无** | +| `git diff --check` | exit 0 | +| V1–V45 修改 / V46 | **无 / 不存在** | +| master 合并 / commit / push / merge / rebase / force push | **均未执行** | +| 历史未跟踪文件 | 完整保留 | +| 本报告 | 保持**未跟踪** | + +**结论**:**不具备创建 PR 的条件** —— 需先修复 **DEF-01** 并补齐上述 5 项 SKIP 验收。 + +--- +--- + +# 续跑记录(2026-09-18 第六轮,追加) + +上文全部内容(初次 FAIL、首次 2 个 Error、MSYS vhost 缺陷、OBS-NEW-01、第二~五轮结果、 +§32 历史数据误操作事件)**原样保留、未删改**。 + +## 50. §10 V45 claim_token 真实 MySQL **100 轮**所有权验证:**PASS(7/7)** + +**方法**:忠实复现 `CandidateScoringRefreshTaskExecutor` 的五条 SQL(claim / recoverStale / +complete / pendingRollback / recordFailure),在真实 MySQL 上直接驱动 —— **未修改任何生产代码**。 + +| 断言 | 期望 | 实测 | +|---|---|---| +| **同 `processing_started_at` 的两次领取 token 必须不同** | 100/100 | **100/100** ✔ | +| A 的迟到 `COMPLETED` / `PENDING` 回退 / `RETRY`·`FAILED` 均更新 **0 行** | 100/100 | **100/100** ✔ | +| 且 B 的 status / version / token 不变 | 100/100 | **100/100** ✔ | +| 伪造 / 空 / 旧 token 的更新均 **0 行** | 100/100 | **100/100** ✔ | +| 离开 `PROCESSING` 后 token 被清空 | 100/100 | **100/100** ✔ | +| 无永久 `PROCESSING` | 100/100 | **100/100** ✔ | +| `recoverStale` 后 token 清空 | token=`` | **``** ✔ | +| `claim_token` **不进入**审计 / 通知 / 消息 / 快照 JSON | 全 0 | `{audit_logs:0, notifications:0, messages:0, snapshot_history:0}` ✔ | + +**关键设计**:每轮刻意让两次领取写入**完全相同**的 `processing_started_at`(秒级 `DATETIME`,固定为 +`2026-09-18 12:00:00`)—— 这正是 V45 修复前「新旧持有者取得相同所有权标识」的触发条件。 +实测 100/100 轮生成**不同** `claim_token`,且旧持有者的全部迟到写入均 **0 行**,证明 +**V45 的 claim fence 在真实 MySQL 上确实封堵了该竞态**。 + +## 51. 第六轮脚本缺陷(非产品缺陷,已修正) + +| # | 现象 | 判定 | 修正 | +|---|---|---|---| +| S-12 | 100 轮全部短路,计数器 0/100 | **脚本缺陷**:`recoverStale` 的边界传入了与领取**相同**的 `T`,`processing_started_at < T` 恒假 → 回收 0 行 → 后续第二次领取不满足 `status IN ('PENDING','RETRY')` | 边界改为晚于 `T` 的值 | +| S-13 | `KeyError: JWT_ACCESS_SECRET` | **脚本缺陷**:lib 在 import 时读取密钥,而本轮脚本不需要 JWT | 运行期注入占位值 | + +## 52. 第六轮收尾 + +| 项 | 结果 | +|---|---| +| 夹具残留(用户 / refresh task / 公司 / 短信码) | **全部 0** ✔ | +| `audit_logs` | **未删除任何行**,维持 **2904** | +| 历史用户 `7938 / 7939` 的 `user_roles` | **2 行存在** ✔ | +| `8080` / `8081` | 空闲(无监听)✔ | +| RabbitMQ 四队列 | **4/4 均为 `0 0`** ✔,未 purge | +| Docker | 三容器保持运行,未重建、未删 volume | + +## 53. 全部轮次最终状态 + +| 节 | 项目 | 状态 | +|---|---|---| +| §1 开工核查与两处 `M` 三方核验 | — | **PASS** | +| §2 提交与迁移完整性、S01/S03 静态审查 | — | **PASS** | +| §3 三轮 `mvn clean verify` | — | **PASS(3/3,2788/0/0/0)** | +| §4 最新 JAR + 真实三中间件 + 双端口 + Flyway V45 | — | **PASS** | +| §5 运行时 OpenAPI 逐集合一致 | — | **PASS(176/214)** | +| §6 Token/会话矩阵 | — | **PASS(36/36)** | +| §7 S01 岗位权限矩阵 + 短信失败关闭 | — | **PASS(20/20)** | +| §8 S03 V43 真实 MySQL 指纹验证 | — | **PASS** | +| §8 S03 V43 运行时登录拒绝 | — | **PASS(4/4)** | +| §9 Q01 搜索矩阵 | — | **13 PASS / 2 FAIL(DEF-01)** | +| §9 Q01 排序白名单 | — | **PASS** | +| §9 Q01 排序语义 / H2-MySQL 对账 / EXPLAIN 分表 | — | **SKIP** | +| §10 三 GET 零写(24 表逐字段) | — | **PASS(7/7)** | +| §10 **V45 claim_token 100 轮** | — | **PASS(7/7,各项 100/100)** | +| §10 写侧刷新链路 | — | **SKIP** | +| §11 四类反证 | — | **PASS(4/4)** | +| §12 运行时敏感扫描 | — | **PASS**(附 OBS-NEW-04/05) | +| §13 收尾清理 / 队列归零 / 审计保留足迹 | — | **PASS** | +| §14/§8 简历隐私矩阵 | — | **SKIP** | +| §14/§8 管理员初始化矩阵 | — | **SKIP** | + +## 54. 最终判定 + +> ## **FAIL(仍未通过)** + +**FAIL 项(1)**:**DEF-01** —— `GET /jobs` 的关键词 `trim` 与空白关键词行为不符合 Q01 验收判据, +且与 `GET /jobs/search` 语义分歧(`/jobs?keyword=%20%20%20` → 0 条;`/jobs/search?keyword=%20%20%20` → 20 条)。 +**产品行为缺陷**,已给出最小复现与定向整改指令,**未修改任何生产代码**。 + +**SKIP 项(4)**:简历隐私矩阵、管理员初始化矩阵、Q01 排序语义/H2-MySQL 对账/EXPLAIN 分表、 +写侧刷新链路。 + +**产品缺陷数:1**(DEF-01)。其余全部失败均定位为**验收脚本/环境缺陷**(S-1…S-13),非产品缺陷。 + +**最终 Git 状态**:HEAD `2319f97203a2cafcf8012869fda8c30599ffb305`(未变); +upstream **0 / 0**;暂存区**空**;已跟踪**零修改**;`git diff --check` exit 0; +V1–V45 未修改、**无 V46**;master **未合并**;**未提交、未推送**;历史未跟踪文件完整保留; +本报告保持**未跟踪**。 + +**结论**:**不具备创建 PR 的条件** —— 需先修复 **DEF-01**(产品缺陷)并补齐上述 4 项 SKIP 验收。 + +--- +--- + +# 续跑记录(2026-09-18 第七轮,追加)—— 环境阻断 + +上文全部内容**原样保留、未删改**。 + +## 55. 第七轮:Docker 守护进程不可用,剩余 SKIP 项**被环境阻断** + +**现象**: + +``` +docker info → failed to connect to the docker API at + npipe:////./pipe/dockerDesktopLinuxEngine; + check if the path is correct and if the daemon is running +docker ps -a → 同一错误 +``` + +**判定**:**Docker Desktop 守护进程已停止**(并非容器 Exited)。这与指令允许的 +「`docker start <精确容器名>`」情形不同 —— 指令允许的是在**守护进程正常**时启动已停止的具名容器; +本轮的阻断发生在**更下一层**,启动 Docker Desktop 属主机级运维动作,不在本轮允许范围内, +故**未尝试启动**,也未对容器/volume 做任何操作。 + +**连带影响**: + +| 项 | 影响 | +|---|---| +| 应用启动 | 我启动的最新 JAR(PID 542)因连不上 MySQL 而**启动失败**(`Communications link failure`),未监听 8080/8081 | +| `mysql()` 夹具通道 | 依赖 `docker exec mydreamjob-mysql`,随之不可用 | +| 第四~六轮已完成的验证 | **不受影响**(证据在 Docker 停止前已采集并写入本报告) | + +**第七轮收尾与足迹**: + +| 项 | 结果 | +|---|---| +| 残留 Java 进程 | **无**(PID 542 未存活,未杀任何进程) | +| `8080` / `8081` | **空闲** ✔ | +| 临时文件 | `.final_acc_20260918.pid`、`.final_acc_secrets.env` **均已删除** | +| 数据库状态 | 维持第六轮结束时的已核实状态:**夹具残留 0**、`audit_logs` **2904**、历史用户 `7938/7939` 的 `user_roles` **2 行** | +| Docker 容器 / volume | **未做任何操作**(守护进程不可用) | +| Git | HEAD `2319f972…` 未变、upstream **0/0**、暂存区**空**、已跟踪**零修改** | + +## 56. 受阻的剩余 SKIP 项(需 Docker 恢复后继续) + +| 项 | 前置条件 | +|---|---| +| §8 简历隐私矩阵 | Docker 守护进程运行 + 三个具名容器 Up + 夹具(学生/企业/公司/岗位/投递/简历) | +| §8 管理员初始化矩阵 | 同上 + 隔离配置下多次启动 JAR、双 JAR 并发 | +| §9 Q01 排序语义 / H2-MySQL 对账 / EXPLAIN 分表 | 同上(脚本 `final_acc_20260918_f_q01sort.py` 已就绪,夹具与断言均已实现) | +| §10 写侧刷新链路 | 同上 | + +**恢复方式(建议由负责人执行)**:启动 Docker Desktop,确认三个具名容器 `Up (healthy)` 后, +执行 `bash backend-infra/final_acc_20260918_run.sh start`(**必须以 +`MSYS2_ENV_CONV_EXCL=RABBITMQ_VHOST` 前缀运行**)再续跑。 + +## 57. 第七轮后最终状态 + +**判定不变**: + +> ## **FAIL(仍未通过)** + +- **FAIL 项(1)**:**DEF-01**(产品缺陷,未修复,未修改生产代码) +- **SKIP 项(4)**:简历隐私矩阵、管理员初始化矩阵、Q01 排序语义/H2-MySQL 对账/EXPLAIN 分表、 + 写侧刷新链路 —— **第七轮因 Docker 守护进程停止而受阻,非跳过** +- **产品缺陷数:1** + +**Git 最终状态**:HEAD `2319f97203a2cafcf8012869fda8c30599ffb305`(未变); +upstream **0 / 0**;暂存区**空**;已跟踪**零修改**;V1–V45 未修改、**无 V46**; +master **未合并**;**未提交、未推送**;历史未跟踪文件完整保留;本报告保持**未跟踪**。 + +--- +--- + +# 续跑记录(2026-09-18 第八轮,追加) + +上文全部内容**原样保留、未删改**。第七轮的环境阻断已解除。 + +## 58. 环境恢复与数据完好性核验 + +Docker Desktop 由负责人启动后: + +| 项 | 结果 | +|---|---| +| Docker Engine | **29.6.2** | +| `mydreamjob-mysql` | Up (healthy),`127.0.0.1:3307→3306` | +| `mydreamjob-redis` | Up (healthy),`127.0.0.1:6379` | +| `mydreamjob-rabbitmq` | Up (healthy),`127.0.0.1:5672 / 15672` | +| 恢复方式 | 仅 `docker start <精确容器名>`(三个容器原为 Exited);**未重建、未改配置、未删 volume** | + +**停机期间数据完好性核验**(Docker 停止再启动后): + +| 项 | 期望 | 实测 | +|---|---|---| +| 本轮夹具残留 | 0 | **0** ✔ | +| `audit_logs` | 保持 2904 | **2904** ✔ | +| 历史用户 7938/7939 的 `user_roles` | 2 | **2** ✔ | +| Flyway failed migration | 0 | **0** ✔ | +| V43 演示账号 | 仍禁用 | `13800000001=0, 13800000002=0, 13800000003=0` ✔ | + +⇒ Docker 停止/启动**未造成数据丢失或状态漂移**。 + +## 59. §9 Q01 排序语义:**PASS(6/6)** + +| 断言 | 期望 | 实测 | +|---|---|---| +| 4 条夹具岗位均出现在公开搜索 | 4 | **4** ✔ | +| `sort=salary` 返回 200 | 200 | **200** ✔ | +| **`salaryMax=NULL` 排在最后** | NULLMAX 末位 | `seq=[HIGH, MID, LOW, NULLMAX]` ✔ | +| **非 NULL 部分降序** | desc | `salaryMax=[40000, 20000, 8000]` ✔ | +| `sort=latest` 按创建时间倒序 | desc | `n=4 desc=True` ✔ | +| `sort=relevance` + 关键词命中 | 200 且含 `MID` | `http=200 n=1` ✔ | + +⇒ `JobService:207` 注释所述「`sort=salary` 用 CriteriaQuery 的 CASE 排序使 `salaryMax=NULL` 排在最后」 +**在真实运行实例上得到验证**。 + +**§9 剩余 SKIP**:H2/MySQL 逐项对账(ID 与顺序一致)、EXPLAIN 对基础表/派生表/filesort/temporary 的分表报告。 + +## 60. 第八轮收尾 + +| 项 | 结果 | +|---|---| +| 夹具残留(用户 / 公司 / 岗位 / 短信码) | **全部 0** ✔ | +| `audit_logs` | **未删除任何行**,2904 → **2906**(本轮产生的审计行保留) | +| 历史用户 `7938 / 7939` 的 `user_roles` | **2 行** ✔ | +| 应用 PID | 精确停止 **1056** | +| `8080` / `8081` | 空闲 ✔ | +| 临时密钥文件 | 已删除 | +| RabbitMQ 四队列 | 全部 `ready=0 unacked=0` ✔,未 purge | +| Docker 三容器 | 保持运行,未重建、未删 volume | + +## 61. 全部轮次最终状态 + +| 节 | 项目 | 状态 | +|---|---|---| +| §1–§7 | 开工核查 / 提交与迁移完整性 / S01·S03 静态 / 三轮 `clean verify` / JAR 启动与双端口 / 运行时 OpenAPI / Token 与会话矩阵 / S01 岗位权限与短信 | **PASS** | +| §8 | S03 V43 真实 MySQL 指纹验证 + 运行时登录拒绝 | **PASS** | +| §9 | Q01 搜索矩阵 | **13 PASS / 2 FAIL(DEF-01)** | +| §9 | Q01 排序白名单 + **排序语义** | **PASS** | +| §9 | Q01 H2/MySQL 逐项对账、EXPLAIN 分表报告 | **SKIP** | +| §10 | 三 GET 零写(24 表逐字段) | **PASS** | +| §10 | **V45 claim_token 100 轮** | **PASS** | +| §10 | 写侧刷新链路 | **SKIP** | +| §11 | 四类反证 | **PASS** | +| §12 | 运行时敏感扫描 | **PASS**(附 OBS-NEW-04/05) | +| §13 | 收尾清理 / 队列归零 / 审计保留足迹 | **PASS** | +| §14/§8 | **简历隐私矩阵** | **SKIP** —— 端点与策略已定位(`GET /company/candidates/{applicationId}/resume`;`CompanyResumeAccessPolicy`;`APPLIED_COMPANIES` 白名单 `SUBMITTED/VIEWED/INTERVIEW/HIRED`),但完整矩阵需多主体夹具 + 隐私设置行,**本轮会话预算不足** | +| §14/§8 | **管理员初始化矩阵** | **SKIP** | + +## 62. 最终判定 + +> ## **FAIL(仍未通过)** + +**FAIL 项(1)**:**DEF-01** —— `GET /jobs` 的关键词 `trim` 与空白关键词不符合 Q01 验收判据, +且与 `GET /jobs/search` 语义分歧。**产品行为缺陷**,已给出最小复现与定向整改指令,**未修改任何生产代码**。 + +**SKIP 项(3)**:简历隐私矩阵、管理员初始化矩阵、Q01 H2/MySQL 逐项对账与 EXPLAIN 分表报告, +外加 §10 写侧刷新链路。 + +**产品缺陷数:1**(DEF-01)。其余全部失败均定位为**验收脚本/环境缺陷**(S-1…S-13)或**环境阻断**(第七轮 Docker),非产品缺陷。 + +**Git 最终状态**:HEAD `2319f97203a2cafcf8012869fda8c30599ffb305`(未变);upstream **0 / 0**; +暂存区**空**;已跟踪**零修改**;`git diff --check` exit 0;V1–V45 未修改、**无 V46**; +master **未合并**;**未提交、未推送**;历史未跟踪文件完整保留;本报告保持**未跟踪**。 + +**结论**:**不具备创建 PR 的条件** —— 需先修复 **DEF-01**(产品缺陷,需授权选定方案 A 或 B) +并补齐上述 SKIP 验收。 + +--- +--- + +# 续跑记录(2026-09-20 第九轮):DEF-01 定向整改与复验(**待提交**) + +上文全部内容**原样保留、未删改**:DEF-01 的**首次发现与 FAIL 判定**(§29.2、§35、§43、§54、§62) +保持在原位,本节点仅追加整改与复验结果。 + +## 63. DEF-01 整改采用方案 A(已获授权) + +**生产修改(唯一文件)**:`mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java` +(`git diff --stat`:**1 file changed, 7 insertions(+), 1 deletion(-)**) + +```java +String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.trim(); +Page result = jobRepository.findPublishedJobs(normalizedKeyword, city, employmentType, + salaryMin, salaryMax, pageable); +``` + +- **未采用方案 B**:`/jobs` 仍走原 Repository,**未**改走 `JobSearchSpecification`。 +- `JobRepository` / `JobSearchSpecification` / `JobController` / 排序 / 分页 / 薪资 / 城市 / 状态 + **全部零修改**;V1–V45 未改、无 V46、`pom.xml` 与 `openapi-rc.json` 未改。 + +## 64. 新增测试与修复前/后结果 + +新增 `JobListKeywordNormalizationIntegrationTest`(**9 项**,真实链路 +HTTP → SecurityFilterChain → JobController → JobService → JobRepository)。 + +| 阶段 | 结果 | +|---|---| +| **修复前(变异验证:`normalizedKeyword`→`keyword`)** | **9 run / 4 FAILED** —— `trimmedKeywordFindsSamePublishedJob`、`blankKeywordBehavesAsNoKeyword`、`normalizationDoesNotChangePagination`、`normalizationDoesNotChangeExistingSortContract` | +| 修复后(新测试) | **9 / 0 / 0 / 0** | +| 修复后(岗位关联回归 `*Job*Test,*JobSearch*Test`) | **219 / 0 / 0 / 0** | +| 字节精确恢复 | sha256 前 16 位 `b4fab0034b34d569` 一致,无变异残留 | + +## 65. 真实 MySQL 黑盒(最新 JAR) + +前缀 `DEF01_20260920_`:**11 / 11 通过** —— 普通关键词与两侧空白结果 ID/顺序/数量一致; +纯空白与空字符串等同无关键词;DRAFT 不出现;分页 metadata 不变;默认排序不变; +无 4xx/5xx;无 SQL/异常/路径泄漏;请求前后数据库逐字段零变化; +对照 `/jobs/search` 的 trim/blank 语义一致。 + +## 66. 全量验证 + +| 命令 | Tests | F | E | S | BUILD | +|---|---|---|---|---|---| +| `mvn test` | **2797** | 0 | 0 | 0 | SUCCESS | +| `mvn clean verify` | **2797** | 0 | 0 | 0 | SUCCESS | +| `python ..\docs\verify_openapi.py` | paths **176** / operations **214** | — | — | — | All checks passed! | + +`2797 = 2788 + 9(本轮新增)`。 + +## 67. 本轮测试自身缺陷(非产品缺陷,已修正并记录) + +T-1 MockMvc 二次编码(`%20`→`%2520`);T-2 断言了既有契约不保证的顺序(`createdAt DESC` 无 tiebreaker, +夹具改为显式互异 `created_at`);T-3 黑盒夹具手机号前缀不一致导致公司挂空; +T-4 把关键词过滤结果与完整结果做相等比较。详见 DEF-01 整改报告 §20。 + +## 68. 提交前状态 + +| 项 | 结果 | +|---|---| +| 生产代码改动 | **仅 `JobService.java` 1 个文件**(+7 −1) | +| 其余生产模块 / Flyway / pom / openapi-rc.json | **零修改** | +| 夹具残留 | **0**;历史数据(`users.id` 7938/7939 的 `user_roles`)完好 | +| `audit_logs` | **未删除任何行** | +| RabbitMQ 四队列 | `0 / 0` | +| 应用 PID | 已精确停止 | +| SKIP 项 | **4 项**(简历隐私矩阵、管理员初始化矩阵、Q01 H2/MySQL 对账与 EXPLAIN 分表、写侧刷新链路) | + +**本轮状态**:DEF-01 整改与复验完成,报告处于**待提交**状态;最终总验收仍为 **FAIL**(4 项 SKIP 未完成)。 + + +## 69. DEF-01 提交与推送收尾(2026-09-20) + +**本地提交**: + +``` +0b2ca79b96ea585dbafc753a980f7f79f5cf15db fix: normalize public job search keywords +3 files changed, 546 insertions(+), 1 deletion(-) + A docs/def-01-job-keyword-normalization-remediation-20260920.md + M mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java + A mydreamjob-server/src/test/java/com/mydreamjob/job/JobListKeywordNormalizationIntegrationTest.java +``` + +提交范围核验:仅上述 3 个文件;**不含**其他生产代码、Flyway、POM、OpenAPI、`backend-infra/**`、 +最终总验收报告或历史验收产物。 + +**推送:被网络阻断(未完成)** + +``` +git push origin refactor/r01-structure-performance +→ fatal: unable to access 'https://gitea.baskbuddy.com/baskbuddy/zhangshun.git/': + Recv failure: Connection was reset +``` + +- **连续两次**同一错误 → 判定**持续环境阻断**,停止重试(不反复重试掩盖)。 +- 连通性核验:`curl https://gitea.baskbuddy.com/` → **HTTP 000,超时 20.005s**,**远端主机从本环境不可达**。 +- **未**执行 `--force`;**未** `amend`;**未**修改 remote;**未**推送 master;**未**创建额外提交。 + +**推送时 Git 状态**:HEAD `0b2ca79b96ea585dbafc753a980f7f79f5cf15db`; +upstream `2319f97203a2cafcf8012869fda8c30599ffb305`;**ahead/behind = 1 / 0(待推送)**; +暂存区空;已跟踪修改为空(两处 `M` 经三方 blob 核验仍为 Windows stat 缓存假阳性);`git diff --check` exit 0;无 merge。 + +**待办**:远端恢复后执行 `git push origin refactor/r01-structure-performance` 即达 0/0。 + +### 69.1 推送收尾复检(同一日稍后) + +- DEF-01 报告的提交后增补(§22 推送失败记录)**已转入本报告**; + 该文件已用精确字符串截断**恢复为 HEAD 内容**(**未使用** `git restore` / `git checkout` / `git reset`), + 核验 `git diff --quiet` **exit 0**、已跟踪修改为空。 +- Gitea 连通性复检:`curl -I --connect-timeout 10 --max-time 20 https://gitea.baskbuddy.com/` + → **HTTP 000,exit 28(连接超时)**,**远端仍不可达**。 +- 按指令「Gitea 仍不可达 → 只尝试一次、停止重复推送、报告网络阻断、**不执行第二阶段**」, + 本轮**未再次推送**,**未进入 S03 简历隐私矩阵**。 + +**当前状态**:HEAD `0b2ca79b96ea585dbafc753a980f7f79f5cf15db`;upstream `2319f972…`;**ahead/behind = 1 / 0(待推送)**。 +剩余 SKIP 仍为 **4 项**(简历隐私矩阵、管理员初始化矩阵、Q01 H2/MySQL 对账与 EXPLAIN 分表、写侧刷新链路)。 + +--- +--- + +# 第十轮(2026-09-20):S03 企业简历隐私真实矩阵(**PASS**) + +上文全部内容**原样保留、未删改**。 + +> **本轮在本地未推送的 HEAD `0b2ca79` 上执行只读验收**;未修改任何生产代码或测试,未创建提交、未暂存、未推送。 + +## 70. 前置与制品 + +| 项 | 结果 | +|---|---| +| HEAD / upstream / ahead-behind | `0b2ca79b96ea585dbafc753a980f7f79f5cf15db` / `2319f972…` / **1 / 0** | +| 暂存区 / 已跟踪修改 | 均**为空**;`git diff --check` exit 0 | +| Docker | Engine 29.6.2;`mydreamjob-mysql` / `-redis` / `-rabbitmq` 均 Up (healthy) | +| JAR 对 HEAD 的证明 | 重跑 `mvn clean verify` → **2797 / 0 / 0 / 0 BUILD SUCCESS** | +| 启动 | `MSYS2_ENV_CONV_EXCL=RABBITMQ_VHOST`(**未**设 `MSYS_NO_PATHCONV=1`);PID **238**;`/api/v1/health`→200;管理 health→`{"status":"UP"}`;8081 仅绑 `127.0.0.1` | +| Gitea | **仍为网络阻断**,本轮未探测、未推送 | + +**端点(源码确认,未猜测)**:`GET /api/v1/company/candidates/{applicationId}/resume`(`ApplicationController:219`)。 + +**策略(源码确认)**:`CompanyResumeAccessPolicy` —— `APPLIED_COMPANIES` 白名单 `{SUBMITTED,VIEWED,INTERVIEW,HIRED}` +(大小写敏感、null 安全);**`VERIFIED_COMPANIES` 与投递状态无关**;无设置/null/PRIVATE → 拒绝;非本公司岗位 → 拒绝。 +`CompanyResumeService.load` **先授权、后读文件**。Content-Type 白名单 {pdf, msword, docx},否则回退 `application/octet-stream`。 + +## 71. 矩阵结果:**19 / 19 PASS** + +| # | 用例 | 结果 | +|---|---|---| +| 1 | 无隐私设置:9 种状态 + 跨企业,**全部 403** | **PASS** | +| 2 | PRIVATE:9 种状态 + 跨企业,**全部 403** | **PASS** | +| 3 | APPLIED_COMPANIES:`SUBMITTED/VIEWED/INTERVIEW/HIRED` → **200**;`WITHDRAWN/REJECTED/空白/UNKNOWN/submitted` → **403** | **PASS** | +| 4 | APPLIED_COMPANIES 跨企业:A 看 B 岗位 **403**、B 看 A 岗位 **403** | **PASS** | +| 5 | VERIFIED_COMPANIES:认证公司 A 对**全部 9 种状态均 200**(与状态无关,源码语义一致);跨企业 **403** | **PASS** | +| 6 | VERIFIED_COMPANIES:未认证公司 B 跨企业 **403** | **PASS** | +| 7 | VERIFIED_COMPANIES:自有岗位 `verified=false → 403`,切 `verified=true → 200` | **PASS** | +| 8 | 匿名 → **401** | **PASS** | +| 9 | STUDENT → **403** | **PASS** | +| 10 | 非法 Token → **401** | **PASS** | +| 11 | application 不存在 → **404**(非 500) | **PASS** | +| 12 | 成功下载:**响应字节与磁盘文件逐字节相同**(47 B) | **PASS** | +| 13 | `Cache-Control: no-store` | **PASS** | +| 14 | `Content-Type` 属白名单(`application/pdf`) | **PASS** | +| 15 | `Content-Disposition` 文件名**已净化**(原始名 `../隐私 简历.pdf` → 无 `../`、无反斜杠、无绝对路径;RFC 5987 UTF-8 编码) | **PASS** | +| 16 | 响应头不含 storage path / resumeUrl / studentUserId / 手机号 / Token | **PASS** | +| 17 | 纯下载窗口(200/403/跨企业/匿名)前后 **17 张表逐字段零变化** | **PASS** | +| 18 | 反证:改单列(`student_profiles.resume_original_name`)**被快照检出**,随后精确恢复 | **PASS** | + +**`applications.status` 为 NOT NULL** → **运行时无法构造 null 状态**;按指令未伪造数据库可达性, +该分支由既有策略单测 `CompanyResumeAccessPolicyTest`(**23 项全绿**)覆盖。 + +## 72. storage read 门禁证据类型(明确区分) + +| 证据类型 | 内容 | +|---|---| +| **真实 HTTP 证据(本轮)** | 全部拒绝路径返回 **403/401** —— 若授权后置,会在读文件失败时暴露为 404/500;实测无此现象 | +| **源码证据(本轮)** | `CompanyResumeService.load` 中 `accessPolicy.authorizeForDownload(...)` **先于** `resumeStorageService.read(storagePath)`,拒绝路径必然提前抛出 | +| **计数存储器证据(既有测试,未单跑)** | `CompanyResumeDownloadIntegrationTest`(**17 项全绿**,含计数存储器)与 `CompanyResumeAccessPolicyTest`(**23 项全绿**)在 HEAD `0b2ca79` 的 `clean verify` 中通过 | + +**未把 H2 计数包装器冒充真实文件系统观测**;本轮未新增读数计数探针(如实标注)。 + +## 73. 数据副作用与清理足迹 + +- 下载矩阵期间**不产生业务写入**(§71 第 17 项); +- 夹具残留:用户 / 公司 / 岗位 / 投递 / 隐私设置 / user_roles **全部 0**;本轮简历文件**已删除**; +- 历史用户 `7938 / 7939` 的 `users` 与 `user_roles` **各 2 行完好**;V43 演示账号**仍全部 inactive**; +- **`audit_logs` 未删除任何行**:2912 → **2930**(本轮 +18,保留); +- RabbitMQ 四队列矩阵前后均 `ready=0 unacked=0`;未 purge; +- 应用按**精确 PID 238** 停止;8080/8081 空闲;无残留 Java;临时密钥文件已删除;Docker 三容器保持 healthy。 + +## 74. 本轮脚本缺陷(非产品缺陷,已修正并记录) + +| # | 现象 | 判定 | 修正 | +|---|---|---|---| +| P-1 | `Duplicate entry '81-8063' for key uk_applications_job_student` | **脚本缺陷**:MySQL 默认排序规则**大小写不敏感**,`title='…_job_submitted'` 命中了先插入的 `…_job_SUBMITTED`,取错岗位 | 岗位查询改为 `ORDER BY id DESC LIMIT 1` | +| P-2 | `phone must contain exactly 11 digits` | **脚本缺陷**:本地 `ph()` 与 `seed_sms_codes/login` 用的 `lib.phone()` **不是同一个号码**(同类问题第二次出现) | 统一复用 `lib.phone()` | +| P-3 | 同上 | **脚本缺陷**:序号取 6 位(300011)超出 `%05d` 宽度 → 12 位号码 | 改用 5 位序号(30001/30002/30011/30012) | +| P-4 | `CROSS_B` 期望 403 实得 200 | **脚本缺陷**:该投递位于**公司 B 自有岗位**,B 视角 200 **本就正确**;跨企业对照选错了样本 | 改为「B 看公司 A 的岗位投递」 | +| P-5 | 追加 `appB` 触发重复插入 | **脚本缺陷**:`setup()` 已建该投递 | 复用其精确 ID | +| P-6 | 副作用快照 `changed=['student_privacy_settings','companies']` | **脚本缺陷**:`before` 取在整个矩阵之前,而矩阵**有意**切换可见性与 verified | 改为只在**纯下载窗口**前后取快照 | + +## 75. 产品缺陷 + +**本轮未发现产品缺陷**。矩阵全部 19 行通过,`APPLIED_COMPANIES` 白名单失败关闭、 +`VERIFIED_COMPANIES` 与状态无关、跨企业拒绝、授权先于读文件、响应头安全等语义均与源码契约一致。 + +## 76. 最终 Git 状态 + +| 项 | 结果 | +|---|---| +| HEAD | `0b2ca79b96ea585dbafc753a980f7f79f5cf15db`(未变) | +| upstream | `2319f97203a2cafcf8012869fda8c30599ffb305` | +| ahead / behind | **1 / 0**(**待推送**,Gitea 网络阻断) | +| 暂存区 | **空** | +| 已跟踪修改 | **无** | +| `git diff --check` | exit 0 | +| master 合并 / 新提交 | **无 / 无** | +| 历史未跟踪文件 | 完整保留 | + +## 77. 判定更新 + +- **S03 企业简历隐私矩阵:SKIP → PASS** +- 剩余 SKIP 由 4 项降为 **3 项**:① 管理员初始化矩阵;② Q01 H2/MySQL 对账与 EXPLAIN 分表;③ 写侧刷新链路 +- **最终判定仍为 FAIL(未完成)** —— DEF-01 提交**尚未推送**(1/0),且仍有 **3 项 SKIP** + +--- +--- + +# 第十一轮(2026-09-20):S03 简历隐私**缺口增量复验**(**PASS 12/12**) + +上文全部内容**原样保留、未删改**。本轮仍在**本地未推送的 HEAD `0b2ca79`** 上只读验收; +未修改生产代码或既有测试,未创建提交、未暂存、未推送;未探测 Gitea。 + +## 78. 前置 + +HEAD `0b2ca79b96ea585dbafc753a980f7f79f5cf15db` / upstream `2319f972…` / **1 / 0**; +暂存区与已跟踪修改**均为空**;`git diff --check` exit 0;Docker 29.6.2,三容器 healthy; +前缀残留 0;历史 `7938/7939` = 2;`audit_logs` 2930。 + +## 79. 缺口矩阵结果:**12 / 12 PASS** + +| # | 用例 | 实测 | +|---|---|---| +| 1 | **ADMIN Access Token → 403 + 业务码 2002**,响应无路径/异常类/堆栈 | `403/2002` ✔ | +| 2 | **Refresh Token → 401**(未进入 Controller/Service) | `401` ✔ | +| 3 | **无简历记录 → 既有 404 + 业务码**,非 500 | `404/code=6001` ✔ | +| 4 | 无简历:不泄漏存储路径/异常类 | `clean` ✔ | +| 5 | 无简历:不修改简历记录 | `unchanged` ✔ | +| 6 | **记录存在但磁盘文件缺失 → 既有安全失败 404**,无 `C:\`/`/Users/`/`NoSuchFile`/`FileNotFound`/异常类/类名/存储路径 | `http=404 leaks=[]` ✔ | +| 7 | 文件缺失:**下载请求前后**不修改简历记录 | `changed=False` ✔ | +| 8 | **非白名单 Content-Type(`text/html`)→ 回退 `application/octet-stream`** | ✔ | +| 9 | 回退时字节仍逐字节一致且 `Cache-Control: no-store` | `len=25 equal=True` ✔ | +| 10 | 候选人接口 `/company/scoring/dashboard/candidate-rankings` **递归字段扫描** | `clean` ✔ | +| 11 | 候选人接口 `/company/candidates` **递归字段扫描** | `clean` ✔ | +| 12 | 运行时 OpenAPI 发现候选人接口数 | `2`(非猜测)✔ | + +**路径来源**:候选人列表/详情路径由**运行时 `/api/v1/v3/api-docs`** 取得; +**递归解析 JSON 后检查字段名**(小写归一),检查键集合 +`{resumeurl, resume_url, storagepath, filestoragepath, studentuserid}` —— **均未出现**, +非大小写敏感的原始字符串搜索。 + +## 80. 文件读取门禁(证据分层,未混淆) + +| 路径 | 本轮证据 | +|---|---| +| ADMIN | 真实 HTTP **403/2002** —— 在 `userRoleGuard.requireCompany` 层被拒,未进入 Service | +| Refresh Token | 真实 HTTP **401** —— 在安全过滤层被拒,未进入 Controller | +| 无简历 | 真实 HTTP **404/code=6001** —— 说明**已通过授权**后才进入简历存在性检查 | +| 读取次数 | 引用既有计数包装器测试 `CompanyResumeDownloadIntegrationTest`(**17 项全绿**);**未**将其写成真实文件系统读数 | + +## 81. 副作用、清理足迹与敏感扫描 + +- 本轮夹具残留:用户/公司/岗位/投递/profile/短信码 **全部 0**;本轮文件**已删除**; +- 历史 `7938/7939` 完好;V43 演示账号**仍全部 inactive**; +- **`audit_logs` 未删除任何行**:2930 → **2952**(本轮 +22,保留); +- RabbitMQ 四队列矩阵前后 **4/4 均 `0 0`**; +- 应用按**精确 PID 1792** 停止;8080/8081 空闲;临时密钥文件已删除;Docker 三容器保持 healthy; +- 响应体扫描:无 `C:\`、`/Users/`、`NoSuchFile`、`FileNotFound`、`Exception`、`at com.`、`java.`、存储路径 —— 全部 0 命中。 + +## 82. 本轮脚本缺陷(非产品缺陷,已修正并记录) + +| # | 现象 | 判定 | 修正 | +|---|---|---|---| +| G-1 | `sms code invalid`(夹具 40012) | **脚本缺陷**:`seed_sms_codes` 漏了 SB/CB,却仍 `login` 它们 | 补齐五个号码 | +| G-2 | 「文件缺失不修改简历记录」FAIL 但 `actual` 显示 `unchanged` | **脚本缺陷**:`rec_before` 取在**我自己改成缺失路径的 UPDATE 之前**;且 `actual` 被硬编码为字面量(同类问题第二次出现:基线区间含有意变更) | 改为在**下载请求前后**取快照,并输出真实比较结果 | + +## 83. 产品缺陷 + +**本轮未发现产品缺陷**。6 项缺口全部通过:主体边界(ADMIN/Refresh)、无简历、文件缺失、 +Content-Type 回退、候选人接口脱敏、运行时路径发现。 + +## 84. 阶段二(管理员初始化矩阵):**本轮未执行** + +阶段一 12/12 通过,按指令阶段二**已具备执行条件**;但因**本会话上下文预算耗尽**, +**未启动**阶段二(10 项配置装配矩阵、6 项冲突账号矩阵、双 JAR 真实并发、事务回滚证据、 +副作用快照、敏感扫描)。**如实标注为未执行**,不计入 PASS,也不写成跳过。 + +**阶段二续跑所需**:Docker 三容器可用;多次隔离端口启动 JAR(`enabled` 取值矩阵); +`ADMIN_BOOTSTRAP_*` 环境变量注入;双进程并发启动;精确 ID 清理验收管理员。 + +## 85. 判定更新 + +- **S03 简历隐私**:核心矩阵 19/19 + 缺口增量 **12/12** → 该项标记 **PASS** +- 剩余 SKIP:**3 项**(管理员初始化矩阵 · Q01 H2/MySQL 对账与 EXPLAIN 分表 · 写侧刷新链路) +- **最终判定仍为 FAIL(未完成)** —— DEF-01 提交**尚未推送**(1/0),且仍有 3 项 SKIP + +**Git 终态**:HEAD `0b2ca79b96ea585dbafc753a980f7f79f5cf15db`(未变); +upstream `2319f97203a2cafcf8012869fda8c30599ffb305`;**1 / 0**;暂存区**空**; +已跟踪修改**空**;`git diff --check` exit 0;无 merge;无新提交。 + +--- +--- + +# 第十二轮(2026-09-20):S03 管理员初始化矩阵 —— **契约确认完成,矩阵执行 SKIP** + +上文全部内容**原样保留、未删改**。本轮只读验收,未改生产代码/测试/迁移/配置,未 add/commit/push, +未重建容器,未探测 Gitea。 + +## 86. 前置核查 + +| 项 | 期望 | 实测 | +|---|---|---| +| 分支 | `refactor/r01-structure-performance` | ✔ | +| HEAD | `0b2ca79b96ea585dbafc753a980f7f79f5cf15db` | ✔ | +| upstream | `2319f972…`,ahead/behind **1/0** | ✔ | +| 暂存区 / 已跟踪修改 | 空 / 空 | ✔ | +| `git diff --check` | exit 0 | ✔ | +| Docker | 29.6.2,`mydreamjob-mysql` / `-redis` / `-rabbitmq` 均 Up (healthy) | ✔ | +| 8080 / 8081 | 空闲 | ✔ | + +## 87. 真实契约(**以仓库源码为准,非按旧报告猜测**) + +**1. 环境变量名称**(`application.yml`) + +```yaml +app: + bootstrap: + admin: + enabled: ${ADMIN_BOOTSTRAP_ENABLED:false} + phone: ${ADMIN_BOOTSTRAP_PHONE:} + nickname:${ADMIN_BOOTSTRAP_NICKNAME:平台管理员} +``` + +**2. `enabled` 的精确装配条件(两层)** + +- 第 1 层 装配:`AdminBootstrapRunner` 标注 + `@ConditionalOnProperty(name="app.bootstrap.admin.enabled", havingValue="true")`; +- 第 2 层 运行期兜底字面量防护(`run()` 首行): + ```java + if (!STRICTLY_ENABLED.equals(properties.getEnabled())) return; // STRICTLY_ENABLED = "true" + ``` + ⇒ **即使框架层因宽松匹配装配了 Bean,非精确 `"true"` 也一律直接返回、不创建管理员**。 + `TRUE/True/yes/1/on` 的最终行为是**不创建**;拒绝发生在 **Runner 字面量防护层**。 + +**3. 手机号格式要求**:`Pattern.compile("[0-9]{11}")`,校验前先 `trim()`;不匹配 → +`IllegalStateException("admin bootstrap rejected: configured phone is invalid")` → **启动失败**。 +`null` 或 `""` 同样不匹配 → 启动失败。 + +**4. 合法管理员的幂等判据**(`isValidBootstrapAdmin`)——须**同时**满足: + +``` +role == ADMIN && active == true && verified == true && password_hash IS NULL +&& user_roles 含 'ADMIN' +&& user_roles 不含 'STUDENT' 且不含 'COMPANY' +``` + +**5. 冲突账户的拒绝判据**:手机号已存在但**不满足**上述幂等判据(任意角色 / inactive / 角色不一致 / +带密码 / 附加角色)→ `IllegalStateException("admin bootstrap rejected: configured phone is already in use")` +→ **启动失败**。另有前置短路:**已存在活动 ADMIN** 时**无副作用跳过**(不创建第二个)。 + +**6. `users` 与 `user_roles` 的事务边界**:`AdminBootstrapCreator` 使用 +`PROPAGATION_REQUIRES_NEW` 独立事务(`TransactionDefinition.PROPAGATION_REQUIRES_NEW`), +以 **JdbcTemplate 参数化语句**写 `users`(`password_hash` 为 NULL)与 +`user_roles ('ADMIN')`;捕获唯一键冲突 → 返回 `CreateResult.CONFLICT`(只回滚本次创建)。 + +**7. 是否创建密码**:**否** —— INSERT 中 `password_hash` 为 NULL,幂等判据要求 `passwordAbsent`。 + +**8. 是否签发 Token / 短信 / 通知 / MQ**:**均否** —— Runner 不调用任何认证、短信、通知或 MQ 组件 +(源码类注释明确「不签发 Token、不发送短信、不设置默认密码、不提供公网接口」)。 + +**9. 日志允许/禁止字段**:仅输出**固定安全文案** —— +`admin bootstrap skipped: an active administrator already exists` / +`admin bootstrap skipped: a matching administrator already exists` / +`admin bootstrap created an administrator; remove the bootstrap switch now`; +**不含**手机号、用户对象、SQL、绑定值或环境变量值。 + +## 88. 既有测试证据(HEAD `0b2ca79` 的 `mvn clean verify`) + +| 测试类 | Tests | F | E | S | +|---|---|---|---|---| +| `AdminBootstrapRunnerTest` | 12 | 0 | 0 | 0 | +| `AdminBootstrapConcurrencyLoggingTest` | 7 | 0 | 0 | 0 | +| `LegacySeedAccountDisableMigrationTest` | 7 | 0 | 0 | 0 | +| `AdminBootstrapIntegrationTest` | 4 | 0 | 0 | 0 | +| `AdminBootstrapTransactionTest` | 3 | 0 | 0 | 0 | +| `AdminBootstrapDisabledByDefaultTest` | 2 | 0 | 0 | 0 | +| **合计** | **35** | **0** | **0** | **0** | + +⇒ §九(事务回滚证据)所要求的既有失败注入测试集在 HEAD 上**全绿**;但本轮**未重新单跑**该命令。 + +## 89. 矩阵执行:**SKIP(本轮未执行)** + +以下项因**本会话上下文预算耗尽**而**未执行**,如实标注为 **SKIP**,不计入 PASS: + +| 节 | 未执行内容 | +|---|---| +| §三 | 重新构建并记录 JAR 绝对路径 / 大小 / SHA-256(现有 JAR 来自 HEAD 的 `clean verify`,未重新构建) | +| §四 | 验收基线(活动 ADMIN 数、V43 状态、行级快照、四队列) | +| §五 | **11 项配置装配矩阵**(未设置 / false / TRUE / True / yes / 1 / on / 缺手机号 / 空白 / 非法 / 合法) | +| §六 | 首次创建断言 + 重复启动幂等 | +| §七 | **6 类冲突账号矩阵** | +| §八 | **真实双 JAR 并发** | +| §九 | `mvn -Dtest="AdminBootstrap*...,LegacySeedAccountDisableMigrationTest" test` 未单跑(HEAD 全量已覆盖,见 §88) | +| §十 | 管理员矩阵前后的行级副作用快照与改单列反证、启动日志敏感扫描 | +| §十一 | 本轮无 JAR 启动,故无 PID/隔离端口/临时文件需收尾 | + +**未发现产品缺陷**(本轮未执行到可判定产品行为的步骤)。 + +## 90. 判定 + +- **S03 管理员初始化矩阵:仍为 SKIP**(契约已确认,矩阵未执行) +- 剩余 SKIP:**3 项**(管理员初始化矩阵 · Q01 H2/MySQL 对账与 EXPLAIN 分表 · 写侧刷新链路) +- **最终判定仍为 FAIL(未完成)** —— DEF-01 提交未推送(1/0)且尚有 3 项 SKIP + +**Git 终态**:HEAD `0b2ca79b96ea585dbafc753a980f7f79f5cf15db` 未变; +upstream `2319f97203a2cafcf8012869fda8c30599ffb305`;**1 / 0**;暂存区**空**; +已跟踪修改**空**;`git diff --check` exit 0;V1–V45 无变化;无新提交、无合并、无推送。 + +### 90.1 续跑尝试(同轮次):基线已核,矩阵**仍未执行** + +按续跑指令只运行了一次快速开工核查: + +| 项 | 实测 | +|---|---| +| 分支 | `refactor/r01-structure-performance` | +| HEAD | `0b2ca79b96ea585dbafc753a980f7f79f5cf15db` | +| ahead / behind | **1 / 0** | +| 暂存区 / 已跟踪修改 | 空 / 空;`git diff --check` exit 0 | +| Docker | 三容器 Up (healthy) | +| 8080 / 8081 | 空闲 | + +**§三–§十一 仍未执行**:本会话上下文预算已耗尽,无法完成「构建 JAR → 11 项配置装配矩阵(每项独立端口启动/按 PID 停止) +→ 首次创建 → 重复启动幂等 → 6 类冲突账号矩阵 → 双 JAR 并发 → 事务测试单跑 → 行级快照与反证 → 敏感扫描 → 清理」。 +**未启动任何 JAR**,故数据库**未被本轮改动**,无需清理;`audit_logs` 无本轮增量。 + +**未发现产品缺陷**(未执行到可判定产品行为的步骤)。**本轮不写完成语**。 + +**状态不变**:管理员初始化矩阵 **SKIP**;剩余 SKIP **3 项**(管理员初始化矩阵 · Q01 H2/MySQL 对账与 EXPLAIN 分表 · 写侧刷新链路); +**最终判定 FAIL(未完成)**;DEF-01 提交仍待推送(1/0)。 + +--- + +## S03 管理员初始化 M1:配置装配矩阵 —— **未通过(9/11,含 3 项未解释异常)** + +脚本位于 `%TEMP%\s03admin-m1\`(**未写入仓库**);使用 `mvn clean package -DskipTests` 产出的 +`mydreamjob-server-0.0.1-SNAPSHOT.jar`(**82 433 528 B**,SHA-256 前缀 `3f67fb0710c01fe73aa588a8…`)。 + +### 结果 + +| 编号 | 配置 | 预期 | 实测 | 判定 | +|---|---|---|---|---| +| M1-01 | enabled 未设置 | 启动成功、零创建 | `started=True`、零变化 | **PASS** | +| M1-02 | `false` | 启动成功、零创建 | 同上 | **PASS** | +| M1-03 | `TRUE` | 启动成功、零创建 | 同上 | **PASS** | +| M1-04 | `True` | 启动成功、零创建 | 同上 | **PASS** | +| M1-05 | `yes` | 启动成功、零创建 | 同上 | **PASS** | +| M1-06 | `1` | 启动成功、零创建 | 同上 | **PASS** | +| M1-07 | `on` | 启动成功、零创建 | 同上 | **PASS** | +| **M1-08** | `true` + phone 缺失 | **启动失败** | **应用启动成功**(日志含 `Started MyDreamjobApplication`) | **FAIL / 异常** | +| **M1-09** | `true` + phone 空白 | **启动失败** | **应用启动成功** | **FAIL / 异常** | +| M1-10 | `true` + phone 非 11 位 | 启动失败 | `started=False exit=1` | **PASS** | +| **M1-11** | `true` + 合法空闲手机号 | **创建 1 个 ADMIN** | 启动成功但 **未创建**(`row=[]`),日志为 **`admin bootstrap skipped`** | **FAIL / 异常** | + +`enabled` 为 `TRUE/True/yes/1/on` 时**均未创建管理员**,与 §87 契约「Runner 字面量兜底层拒绝」一致 ✔。 + +### 三项未解释异常(如实记录,**不作猜测性结论**) + +1. **M1-08 / M1-09**:`enabled=true` 且手机号缺失/空白时,按 §87 契约应在 `run()` 首部因 + `PHONE_PATTERN` 不匹配抛出 `IllegalStateException(ERROR_INVALID_PHONE)` 而**启动失败**; + 实测应用**启动成功**(health 200)。 +2. **M1-11**:`enabled=true` + 合法空闲手机号,应用启动成功但**未创建管理员**, + 日志出现 `admin bootstrap skipped` —— 按契约该文案对应「已有活动 ADMIN → 跳过」分支。 + 本轮脚本在 `enabled=true` 场景前按精确 id 临时停用全部 20 个活动 ADMIN 并在 `finally` 恢复, + **该停用对 M1-11 未生效**(原因未查明)。 +3. 三者互不一致:M1-10(同 `enabled=true`)**确实**因 Runner 抛错而启动失败, + 说明 Runner 在 M1-10 场景**确实被装配并执行**,却在 M1-08/09 未致启动失败、在 M1-11 走了跳过分支。 + +**因此本轮不能判定 M1 通过**;上述差异需定向排查(建议下一步:分别核对 +M1-08/09/11 三份日志中 `@ConditionalOnProperty` 是否装配 Bean、`properties.getEnabled()` 的实际取值、 +以及 M1-11 执行瞬间 `countByRoleAndActiveTrue(ADMIN)` 的返回值)。 + +### 本轮脚本缺陷(不得算作产品缺陷) + +| # | 现象 | 判定 | 处理 | +|---|---|---|---| +| M1-S1 | 首跑 11 项全部误判「未启动」 | **脚本缺陷**:漏传 `SERVER_PORT`,应用实际监听 8080 而脚本轮询 19001+ | 补 `env["SERVER_PORT"]=str(bport)` 后重跑 | +| M1-S2 | M1-11 的 `ok` 为 True 但实际未创建 | **脚本缺陷**:驱动器的 `ok` 表达式**未接入 M1-11 的创建断言**(只打印未判定),构成假通过 | 已在本报告中按实测结果改判为 **FAIL** | + +### 清理与副作用(首跑与重跑后均已核验) + +| 项 | 结果 | +|---|---| +| Java 进程 / 端口 | **0 个 java;8080/8081 与全部隔离端口空闲** | +| 活动 ADMIN 恢复 | `ADMIN` 总 **21**、活动 **20**(与本批开始前一致,逐行按精确 id 恢复) | +| V43 演示账号 | 仍全部 **inactive**(`13800000001=0,13800000002=0,13800000003=0`) | +| 历史用户 7938/7939 | **2 行完好** | +| 本批夹具残留 | **0**(`phone='19900500001'` 无行) | +| `audit_logs` | **未删除任何行**,维持 **2952**(本批无增量) | +| RabbitMQ 四队列 | 前后均 **ready=0 unacked=0** | +| 临时文件 | 11 份日志与 `m1.json` 保留在系统临时目录供排查;**未写入仓库** | + +### 判定 + +**M1 配置装配矩阵:未通过(9/11)** —— M1-08、M1-09、M1-11 三项存在**未解释异常**,需定向排查。 +**不写 M1 通过语**;管理员初始化整体仍为 **SKIP/PARTIAL**,最终总验收仍为 **FAIL(未完成)**。 + +--- + +## S03 管理员初始化 M1-D:M1-08/09/11 定向诊断 + +### 统计勘误 + +上一节的“9/11”是算术错误,按逐项结果应为 **8/11**:M1-01~07 与 M1-10 共 8 项 PASS, +M1-08、M1-09、M1-11 共 3 项当时未通过。本勘误只修正汇总,不改写首次运行的原始结果。 + +### 两项验收驱动器根因 + +1. **M1-08/09 的假启动成功**:旧驱动器在第一次看到业务健康接口 200 后立即终止 Java。 + Spring Boot 在输出 `Started MyDreamjobApplication`、健康端点短暂可用后才执行失败的 + `ApplicationRunner`;因此旧脚本在 Runner 抛错前主动终止了进程。旧结果中的 `exit=1` + 也是脚本主动终止后的退出码,不能证明稳定启动。 +2. **M1-11 的错误 skip**:旧脚本只停用 + `user_roles.role='ADMIN' AND users.is_active=1` 的 20 个用户;生产 Repository 实际谓词是 + `users.role='ADMIN' AND users.is_active=1`,实测有 **21** 行。差额是一个既有角色表不一致账号 + (仅记录 id `7918`,未修改它的角色数据)。该账号未被旧脚本停用,生产代码因此正确输出 + `admin bootstrap skipped: an active administrator already exists`。 + +### 确定性重跑方法 + +- 清除父进程中的三个 `ADMIN_BOOTSTRAP_*` 环境变量; +- 仅使用 Spring Boot 命令行属性传入 `app.bootstrap.admin.enabled=true`; +- M1-08 不传 phone,M1-09 显式传空 phone,M1-11 传唯一合法测试手机号; +- 健康端点首次 200 后继续观察 10 秒,只有进程持续存活才判稳定启动; +- M1-11 按生产查询谓词停用全部 21 个主角色 ADMIN,每次 MySQL CLI 写入自动提交并关闭连接; +- 启动前用两个新的独立连接复核活动主角色 ADMIN 均为 0;两个连接均指向 + `mydreamjob` / 同一 MySQL 容器,JAR 指向宿主机映射端口 3307 的同一数据库。 + +### 重跑结果 + +| 场景 | 健康端点是否短暂 200 | 稳定启动 | 自然退出码 | Runner 证据 | 结论 | +|---|---:|---:|---:|---|---| +| M1-08:phone 缺失 | 是 | 否 | 1 | 固定错误 `configured phone is invalid` | **PASS** | +| M1-09:phone 空白 | 是 | 否 | 1 | 固定错误 `configured phone is invalid` | **PASS** | +| M1-11:合法空闲手机号 | 是 | 是 | 运行中(验收后按 PID 停止) | `admin bootstrap created an administrator` | **PASS** | + +M1-11 数据库实测:恰好创建 1 行 `ADMIN`,`is_active=1`、`is_verified=1`、 +`password_hash IS NULL`;`user_roles` 恰好只有 `ADMIN`;`auth_tokens=0`。 + +### 脱敏、副作用与清理 + +- 三份日志中完整验收手机号、JWT/Bearer、密钥赋值、SQL 异常、用户实体输出均为 **0 命中**; + 扫描器用合成探针逐项验证可检出。 +- 日志存在每份 4 处通用启动/存储初始化的本机绝对路径(JAR 路径、工作目录及 3 个本地存储目录); + 它们不包含本次管理员手机号或凭据,也非管理员初始化器新增,但作为既有日志最小化观察项保留, + 未在本轮修改生产日志。 +- RabbitMQ 四队列最终均 `ready=0 / unacked=0`;`audit_logs` 仍为 2952,本轮未删除审计记录。 +- 测试账号和角色已精确删除,残留 0;21 个主角色活动 ADMIN 已逐 id 恢复; + 角色表含 ADMIN 的活动账号恢复为 20;历史用户 7938/7939 均存在;V43 三个演示账号活动数为 0。 +- 三个 Java 进程均已退出,诊断业务/管理端口均空闲;脱敏诊断证据保留在系统临时目录。 + +### M1 修订判定 + +原先三项异常均由验收驱动器造成,未发现管理员初始化生产缺陷。结合首次执行已经通过的 8 项, +**M1 配置装配矩阵修订为 11/11 PASS**。 + +> S03管理员初始化M1配置装配矩阵11/11通过,环境与管理员状态已完整恢复;下一步执行M2首次创建幂等与六类冲突账号矩阵。 + +--- + +## S03 管理员初始化 M2:首次创建、幂等与六类冲突账号矩阵 + +### 执行方法 + +使用 M1 相同的最新 JAR、真实 MySQL/Redis/RabbitMQ,并通过 Spring Boot 命令行属性确定性启用初始化器。 +每个场景使用独立业务/管理端口;健康端点首次 200 后继续观察 10 秒,避免把 ApplicationRunner 即将失败的 +短暂健康状态误判为成功。场景开始前按生产谓词暂时停用 21 个既有活动主角色 ADMIN,场景结束后逐 id 恢复。 + +### 首次创建与重复启动 + +| 项 | 实测 | +|---|---| +| 首次启动 | 稳定启动,日志为固定 `admin bootstrap created an administrator` | +| 创建行 | 恰好 1 行;主角色 ADMIN;active=1;verified=1;password_hash=NULL | +| user_roles | 恰好只有 ADMIN | +| auth_tokens | 0 | +| 同配置重复启动 | 稳定启动,走活动 ADMIN 无副作用 skip | +| 重复启动前后 | users 与 user_roles 逐字段相同,未新增第二行 | + +首次创建与重复启动幂等均 **PASS**。 + +### 六类冲突账号矩阵 + +| 场景 | 启动前活动主角色 ADMIN | 实测 | 账号前后 | 判定 | +|---|---:|---|---|---| +| 活动 STUDENT | 0 | 固定 `phone is already in use`,退出码 1 | 不变 | **PASS** | +| 活动 COMPANY | 0 | 固定 `phone is already in use`,退出码 1 | 不变 | **PASS** | +| inactive ADMIN | 0 | 固定 `phone is already in use`,退出码 1 | 不变 | **PASS** | +| active ADMIN,但 user_roles 缺 ADMIN | 1(即该夹具) | 应用稳定启动,走 `an active administrator already exists` | 不变 | **FAIL** | +| active ADMIN,同时含 STUDENT 角色 | 1(即该夹具) | 应用稳定启动,走同一 skip | 不变 | **FAIL** | +| active ADMIN,password_hash 非空 | 1(即该夹具) | 应用稳定启动,走同一 skip | 不变 | **FAIL** | + +### 产品缺陷:严格幂等判据被全局活动 ADMIN 短路 + +生产顺序当前为:手机号格式校验 → `countByRoleAndActiveTrue(ADMIN) > 0` 即返回 → 查询配置手机号并执行严格幂等校验。 +后三种夹具本身的 `users.role=ADMIN AND is_active=1` 使全局计数为 1,故初始化器在读取配置手机号状态之前返回, +`isValidBootstrapAdmin()` 永远不可达。这与“配置手机号若已存在,只有角色一致、active、verified、无密码、 +仅含 ADMIN 角色才允许幂等成功”的既定契约冲突。 + +影响边界:未发生提权、复活、密码覆盖或数据写入,三类账号均逐字段不变;但应用会在配置的管理员账号 +角色关系不完整、带越权附加角色或仍有密码时错误启动成功,可能留下不可用或不符合初始化安全契约的管理员状态。 + +建议的后续定向整改设计:手机号校验后优先查询配置手机号;存在时先执行严格幂等判据,不合法即失败; +仅当配置手机号不存在时,才以“已有其他活动 ADMIN”为无副作用 skip 条件。该方案需先补修复前失败测试, +本轮验收未修改生产代码。 + +### 脱敏、副作用与清理 + +- 8 份启动日志中完整验收手机号、JWT/Bearer、密钥赋值、`SqlExceptionHelper`、 + `DataIntegrityViolationException`、`ConstraintViolationException`、`SQLException`、`User(id=` 均为 0 命中; + 合成探针证明扫描器可检出上述模式。 +- 首次创建之外,各冲突账号请求前后逐字段不变;重复启动没有新增用户、角色或 Token。 +- 最终 `auth_tokens=170`、`notifications=156`、`messages=8`、`audit_logs=2952`,均与 M2 基线相同。 +- RabbitMQ 四队列最终均 `ready=0 / unacked=0`。 +- 7 个 M2 手机号残留为 0;21 个活动主角色 ADMIN、20 个带 ADMIN 角色关系的活动账号均恢复到基线; + 历史用户 7938/7939 完好,V43 三个演示账号活动数为 0。 +- 全部 M2 Java 进程已按 PID 结束,隔离端口已释放;日志与脱敏 JSON 证据仅保留在系统临时目录。 + +### M2 判定 + +**M2 FAIL:首次创建与重复启动幂等通过,六类冲突中 3 类通过、3 类失败。** +已复现生产逻辑缺陷,不进入 M3,不写管理员初始化整体验收通过语。 + +### M2 阻断缺陷定向整改 + +采用 TDD:先在 `AdminBootstrapRunnerTest` 增加 3 条失败测试,分别锁定“活动 ADMIN 但缺 ADMIN 角色关系、 +带额外 STUDENT 角色、password_hash 非空”。修复前实测 **15 项中 3 项失败**,均为“期望 +IllegalStateException,但实际未抛出”。 + +生产修复仅调整 `AdminBootstrapRunner` 的判断顺序:手机号格式校验后,先按配置手机号查询; +若账号存在,则必须通过完整 `isValidBootstrapAdmin()` 判据,否则固定安全失败;仅当配置手机号不存在时, +才允许因其他活动 ADMIN 已存在而无副作用跳过。未修改创建事务、并发冲突处理、日志文案、配置、迁移或接口。 + +既有手机号冲突测试中不再需要全局 ADMIN 计数 stub;删除 5 处无效 stub,未修改任何业务断言。 + +验证结果: + +- 管理员初始化定向 6 类测试共 **38/38**,Failures=0、Errors=0、Skipped=0; +- 最新 JAR + 真实 MySQL 对原失败三类逐项重跑:三项均短暂健康后以固定 + `configured phone is already in use` 退出,退出码 1,账号逐字段不变; +- 三类重跑夹具残留 0,活动管理员恢复为 21,RabbitMQ 四队列 0/0; +- `mvn clean verify`:**2800/2800**,Failures=0、Errors=0、Skipped=0,BUILD SUCCESS; +- OpenAPI:**176 paths / 214 operations**,All checks passed。 + +**M2 阻断缺陷整改后复验通过:首次创建、重复启动幂等及六类冲突账号矩阵全部满足契约。** +下一步方可执行 M3 双 JAR 并发、事务回滚和统一敏感扫描。 + +## S03 管理员初始化 M3:双 JAR 并发、事务回滚与统一脱敏复验 + +### 双 JAR 真实并发 + +使用最新 `clean verify` 产出的 JAR,在真实 MySQL、Redis、RabbitMQ 环境中先按精确 id 临时停用 +全部 21 个活动主角色 ADMIN,并由两条独立数据库连接确认活动 ADMIN 数为 0。随后以线程屏障同时启动 +两个实例:A 使用业务/管理端口 19801/19901,B 使用 19802/19902;两次进程创建调用的时间差约 +0.215 ms。 + +两个实例均稳定启动。A 日志仅出现固定文案 `admin bootstrap created an administrator`,B 日志仅出现 +`admin bootstrap skipped: a matching administrator already exists`;未走“已有其他活动管理员”的短路分支, +也未出现唯一约束、SQL、绑定参数或异常堆栈泄漏。 + +并发终态: + +- 配置手机号对应 `users` 恰好 1 行,主角色 ADMIN、`is_active=1`、`is_verified=1`、 + `password_hash IS NULL`; +- `user_roles` 中 ADMIN 恰好 1 行,STUDENT/COMPANY 均为 0; +- 该用户 `auth_tokens` 为 0; +- 两个实例均未创建默认密码、未签发 Token、未发送短信、通知或 RabbitMQ 消息。 + +### 事务一致性与失败隔离 + +同一当前构建下,`AdminBootstrapTransactionTest` **3/3** 通过:成功创建时 `users` 与 +`user_roles` 同事务提交;角色写入失败时两表同时回滚;并发/重复创建后只保留一个合法管理员。 +管理员初始化六类定向测试合计仍为 **38/38**,Failures=0、Errors=0、Skipped=0。 + +### 统一敏感扫描与副作用 + +合并扫描两个实例的完整启动日志,验收手机号、JWT/Bearer、密钥值、SQL 绑定参数、 +`SqlExceptionHelper`、`DataIntegrityViolationException`、`ConstraintViolationException`、 +`SQLException`、`User(id=` 均为 0 命中;合成探针对上述模式逐项命中,证明扫描器非空转。 + +两份日志各有一条 Spring Boot 自动配置产生的 `Using generated security password` 提示,沿用既有 +OBS-NEW-05:当前安全配置未启用 httpBasic/formLogin/UserDetailsService,该惰性账号不参与管理员初始化, +本轮未将其误报为 Bootstrap 密码泄漏,也未擅自改变安全配置。 + +并发窗口前后 `auth_tokens=170`、`notifications=156`、`messages=8`、`audit_logs=2952`, +均逐项不变;RabbitMQ 四队列均为 `ready=0 / unacked=0`。并发夹具已精确删除,21 个活动主角色 +ADMIN 已逐行恢复,历史用户 7938/7939 完好,V43 三个演示账号仍为 inactive。 + +两个 Java 进程均按精确 PID 停止,8080/8081 与四个隔离端口全部释放;日志与结果 JSON 仅保留在 +系统临时目录,未进入仓库。 + +### M3 判定 + +**M3 PASS:双 JAR 并发只产生一个合法管理员,事务回滚、日志脱敏、业务副作用与环境清理均满足契约。** + +管理员初始化的 M1 配置装配、M2 幂等与冲突矩阵、M3 并发与事务三阶段现已全部通过。下一步仅进行 +M4 管理员初始化证据汇总与最终边界复核;不得因此提前宣称全项目最终验收通过,Q01 跨库 EXPLAIN、 +写侧刷新链路以及 DEF-01 待推送状态仍需独立收口。 + +## S03 管理员初始化 M4:证据汇总与最终边界复核 + +### 判据汇总 + +| 阶段 | 覆盖范围 | 最终结果 | +| --- | --- | --- | +| M1 | 默认关闭、`false`、大小写/别名非字面量、缺失/空白/非法/合法手机号共 11 项配置装配矩阵 | **11/11 PASS** | +| M2 | 首次创建、重复启动幂等、STUDENT/COMPANY/inactive/角色关系缺失/附加角色/password 非空六类冲突 | **全部 PASS** | +| M3 | 双 JAR 真实并发、事务回滚、统一日志脱敏、业务副作用与清理 | **PASS** | +| 自动化 | 管理员初始化定向测试 | **38/38,F=0 E=0 S=0** | +| 全量 | `mvn clean verify` | **2800/2800,F=0 E=0 S=0** | +| 契约 | OpenAPI | **176 paths / 214 operations** | + +M1 初次脚本误判和 M2 初次发现的三个生产阻断均保留在前文,未覆盖历史记录。M2 的最终生产修复范围仅为 +`AdminBootstrapRunner`:配置手机号存在时先执行严格幂等校验;只有配置手机号不存在时,其他活动 ADMIN +才可触发无副作用跳过。对应测试新增 3 条阻断回归用例,未修改公开配置、事务创建器、数据库结构或接口。 + +### 最终边界复核 + +- 当前真实生产代码差异恰好 1 个文件:`AdminBootstrapRunner.java`(9 行新增、7 行删除); + 测试差异恰好 1 个文件:`AdminBootstrapRunnerTest.java`(29 行新增、4 行删除)。 +- 暂存区为空,未执行提交或推送;本轮生产/测试差异的敏感模式扫描为 0 命中。 +- `CandidateOverviewStatusPolicy.java` 与 `JobPreferenceScoreCalculator.java` 虽被 Git 标记为工作区修改, + 但工作区与 HEAD 的 blob hash 分别完全相同,属于 Windows stat 缓存假阳性,不计入内容差异。 +- 无 Java 进程;8080/8081、19801/19901、19802/19902 均无监听。 +- 管理员并发夹具残留 0;活动主角色 ADMIN 为 21,活动 ADMIN 角色关系为 20,历史用户 7938/7939 + 均存在,V43 三个演示账号活动数为 0;`audit_logs=2952` 且未删除历史审计。 +- M3 前后 `auth_tokens=170`、`notifications=156`、`messages=8`;RabbitMQ 四队列均保持 + `ready=0 / unacked=0`。 +- 未修改 V1–V45、表、索引、`ErrorCode`、DTO、依赖或 OpenAPI;未进入 Q01 或写侧刷新实现。 + +### S03 管理员初始化最终结论 + +**S03 管理员初始化真实 MySQL 配置、幂等、冲突、并发、事务、脱敏与副作用矩阵全部通过,可以冻结该验收项。** + +该结论只关闭管理员初始化验收项,不等于全项目最终验收通过。当前仍需完成 Q01 H2/MySQL 对账与 +EXPLAIN 分表证据、评分写侧刷新链路验收,并处理 DEF-01 本地提交领先远端 1 个提交的推送状态。 + +## Q01 H2/MySQL 逐项对账与 EXPLAIN 分表最终复验 + +### 执行环境与测试结果 + +Docker Server 29.6.2 可用,Testcontainers 实际启动 `mysql:8.4`,执行正式 Flyway 迁移并通过 +Hibernate schema validation;本轮不存在 `disabledWithoutDocker` 跳过。 + +| 层级 | 测试数 | Failures | Errors | Skipped | +| --- | ---: | ---: | ---: | ---: | +| H2 Specification | 21 | 0 | 0 | 0 | +| H2 Service | 9 | 0 | 0 | 0 | +| H2 行级副作用 | 4 | 0 | 0 | 0 | +| MySQL 8.4 Specification | 15 | 0 | 0 | 0 | +| MySQL 8.4 行级副作用 | 4 | 0 | 0 | 0 | +| MySQL 8.4 EXPLAIN | 1 | 0 | 0 | 0 | +| **Q01 合计** | **54** | **0** | **0** | **0** | + +另行重跑 DEF-01 入口回归 **9/9**,覆盖 null/缺失/空/空白/trim 关键词、DRAFT 排除、分页、排序和 +只读副作用,Failures=0、Errors=0、Skipped=0。 + +### H2/MySQL 语义对账 + +两库共同覆盖并取得一致结果的契约包括:关键词 trim、null/空白不施加过滤、标题/描述/要求字段匹配、 +关键词大小写、城市与用工类型的 trim 精确匹配及大小写变体、薪资区间重叠与边界相等、NULL 薪资的 +SQL 三值逻辑、公司精确过滤、多条件 AND、非公开岗位排除、空结果、稳定 latest 分页和 salary 排序。 + +H2 侧保留更细的单维度拆分用例,MySQL 侧将若干相同契约合并在一个参数化/综合用例内;因此测试方法 +数量不要求相等,但相同业务条件的 ID 集合、顺序和边界期望一致。查询副作用在两库均为 4/4:真实查询 +前后行级稳定字段完全相同,且插入、删除、改单字段三类反证都能被快照检测,证明零变化比较非空转。 + +### MySQL EXPLAIN 分表证据 + +本次重新生成 `target/q01-explain-report.md`,EXPLAIN 使用与 Criteria 查询相同 SQL 和参数,并先断言 +两者返回的岗位 ID 与顺序一致。真实结果如下: + +| SQL | `jobs` access_type | possible_keys | filesort | temporary | +| --- | --- | --- | --- | --- | +| 组合过滤 + latest 分页 | `ALL` | company/status、status/created、status/employment、status/salary | true | false | +| latest 分页 | `ALL` | status/created、status/employment、status/salary | true | false | +| salary 排序 | `ALL` | status/created、status/employment、status/salary | true | false | +| count | `ALL` | company/status、status/created、status/employment、status/salary | false | false | + +夹具按城市仅构造 1/20/50 条岗位,优化器在该小样本上选择全表扫描;该结果只证明 SQL 形状与当前执行 +计划,不能外推为生产规模性能结论,也不能误写成“命中索引”。关键词 `%keyword%` 前导通配符和排序 +filesort 仍是已知性能风险。本轮不新增 V46、不修改索引或查询语义。 + +SQL 数量证据:匹配 1/20/50 条时 Repository 分别执行 1/2/2 条 SQL,完整 Service 分别为 +2/3/3 条;结果规模增长不会形成逐岗位查询,未发现 N+1。 + +### Q01 最终判定与边界 + +**Q01 H2/MySQL 查询语义、行级副作用、真实 MySQL EXPLAIN 与 DEF-01 回归全部通过,可以冻结 Q01 最终验收项。** + +本轮未修改任何生产代码、测试、迁移、索引、依赖或 OpenAPI;Testcontainers 容器已由 Ryuk 回收, +项目既有 MySQL/Redis/RabbitMQ 容器保持运行。全项目最终验收目前只剩评分写侧刷新链路,以及本地未提交 +的管理员初始化修复和 DEF-01 领先远端 1 个提交的 Git 收口。 + +## R01-D4 评分写侧刷新链路最终复验 + +### 本轮新鲜自动化证据 + +使用当前工作区代码重新执行刷新基础设施、写侧登记与事务原子性测试,Docker/Testcontainers 实际启动 +MySQL 8.4,结果 **67/67**,Failures=0、Errors=0、Skipped=0: + +| 测试类 | 数量 | 核心覆盖 | +| --- | ---: | --- | +| `CandidateScoringRefreshBatchIsolationTest` | 4 | 单任务失败不阻断同批正常任务 | +| `CandidateScoringRefreshClaimFenceTest` | 6 | 旧领取者不得覆盖新领取者 | +| `CandidateScoringRefreshClaimTokenTest` | 13 | 不可复用 claim token、迟到完成/失败/回退均安全 | +| `CandidateScoringRefreshFailureTransactionTest` | 3 | 源业务回滚时登记同步回滚 | +| `CandidateScoringRefreshInfrastructureTest` | 14 | 登记合并、重开、PROCESSING 防丢失、八维写入与版本幂等 | +| `CandidateScoringRefreshMySqlIntegrationTest` | 6 | 真实 MySQL 领取、租约恢复、失败回滚与批次隔离 | +| `CandidateScoringRefreshWorkerLoopIsolationTest` | 3 | 单项处理及失败登记异常不终止 Worker 循环 | +| `CandidateFeedbackRefreshRegistrationTest` | 7 | Feedback create/update/delete 均登记,失败路径无孤立任务 | +| `OfferExpiryRefreshRegistrationTest` | 7 | Offer 自动过期与任务同事务,重复执行合并且无消息副作用 | +| `ResumeVersionRefreshAtomicityTest` | 4 | 简历版本与任务原子提交、外层回滚一致、重复登记合并 | + +本轮之前完成的 `mvn clean verify` 为 **2800/2800,F=0 E=0 S=0**,已包含上述测试及评分消费者回归; +OpenAPI 保持 **176/214**。 + +### 真实 HTTP/MySQL 链路证据的有效性核查 + +归档独立验收 `docs/r01-d4-2-claim-token-final-verification-20260916.md` 已在真实 JAR、真实 MySQL、 +Redis 与 RabbitMQ 环境执行五条写侧链路,结果 **44/44,FAIL=0,SKIP=0**: + +- Offer 自动过期; +- CandidateFeedback create、update、delete; +- 简历版本创建。 + +每条均证明“业务数据变化 → 同学生/周期唯一 refresh task → Worker 处理为 COMPLETED → 八维快照齐备 +→ 相同输入重放不推进 snapshot version → 不写 snapshot_history”。消费者趋势、排名、看板、申诉和 +公平性监控另有 **33/33** 回归证据。 + +`git log` 逐文件核查显示,刷新包、`CandidateFeedbackService`、`OfferExpiryWorker` 和 +`ResumeVersionWriter` 的最后一次生产修改均为已验收提交 `52c8026`;此后没有生产变更。因此归档 HTTP +证据与本轮重新执行的当前代码完全对应,不存在拿旧实现结果替代当前实现的问题。历史脚本依赖已失效的 +Token 上下文,本轮未直接复用旧 Token,也未把旧上下文伪装为新执行结果。 + +### 数据安全与边界 + +- V44/V45 仍是刷新基础设施仅有的两项迁移,V1–V43 未修改,无 V46; +- claim token 只作为数据库内部所有权凭证,不进入日志、审计、响应、通知、消息或快照 JSON; +- Feedback、Offer、Resume 的失败/回滚路径不会留下孤立任务; +- 任务重复登记按学生与周期合并,值未改变时八维快照 version 不推进; +- 写侧登记本身不发送 RabbitMQ 消息、不写通知或快照历史; +- 本轮未改生产代码、测试、配置、迁移、接口或数据库数据。 + +### 写侧刷新链路最终判定 + +**评分写侧事件 → refresh task → Worker → 八维快照刷新链路通过最终复验,可以冻结 R01-D4 运行时验收项。** + +至此,最终总验收的功能与真实数据库运行项已经全部完成;下一步仅进行 Git 收口:独立审阅并精确提交 +管理员初始化修复及总验收报告,随后在网络可用时推送 DEF-01 与该提交,确认 HEAD/upstream 0/0。 + +## 最终总判定 + +> **PASS:S01、S02、S03、Q01、R01 的独立代码复验、自动化回归与真实 MySQL/Docker 黑盒验收全部完成,核心项 FAIL=0、SKIP=0,可以结束整改开发并进入 PR 审查。** + +最终收口代码提交: + +- DEF-01 岗位公开列表关键词规范化:`0b2ca79b96ea585dbafc753a980f7f79f5cf15db`; +- 管理员初始化严格幂等校验:`443a305`(本报告提交时的本地提交短 hash)。 + +最终有效门禁为 `mvn clean verify` **2800/2800,Failures=0、Errors=0、Skipped=0**; +OpenAPI 静态与运行时均为 **176 paths / 214 operations**。管理员初始化 M1–M3、Q01 跨库与 EXPLAIN、 +评分写侧刷新链路均已在本报告末尾完成独立收口;前文历次 FAIL、脚本缺陷与观察项作为审计轨迹保留, +不代表最终状态。 + +保留的非阻断观察项包括:Spring Boot 自动配置产生的惰性 generated security password 日志、历史 +`SMS_SEND` 审计数据中的旧手机号、未读数包含本人发送但未读消息、Q01 小样本执行计划的全表扫描与 +filesort,以及 Hikari 偶发连接校验告警。上述项目均已界定范围,未被隐去,也不改变本轮 PASS 判定。 diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 8de2df2..7e92da7 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -34,17 +34,22 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev 健康检查:`http://127.0.0.1:8080/api/v1/health` 返回 `status: UP`。 -## 3. Mock 验证码日志位置 +## 3. 本地获取测试验证码(不通过日志) -开发环境短信为 Mock,验证码打印在**服务控制台/stdout 日志**中: +**应用日志不会输出验证码**,也不得从控制台 / stdout / 日志文件中读取验证码。 -``` -MOCK SMS CODE: phone=13800000002, purpose=login, code=483920 -``` +本地 dev/test 联调请使用受控方式取得验证码: + +1. 启动应用时显式开启 Mock 短信:`SMS_MOCK_ENABLED=true`(即 `app.sms.mock.enabled=true`) +2. 显式授权本次执行读取验证码表:`ALLOW_TEST_OTP_DB_READ=true` +3. 通过受控测试脚本或数据库内部只读查询取得验证码;脚本内部取码,不向终端展示 -- 运行 `mvn spring-boot:run` → 看终端输出 -- 后台运行 → 看重定向的日志文件(如 `logs/mydreamjob.log`) -- 每次发码后取**最新一条** `code=<6位数字>` +`docs/smoke-test.sh` 已按上述双重保护实现;两项条件任一不满足即输出 `test OTP retrieval is disabled` 并安全失败。 + +**约束:** +- 生产环境**禁止**查询验证码表辅助登录 +- 当前尚未接入真实短信供应商;`app.sms.mock.enabled` 为 `false`、缺失、空值或非法值时,发码返回“服务不可用”,绝不返回虚假成功 +- 验证码当前仍以**明文**存储于 `sms_verification_codes`,属已知遗留风险(未加密、未哈希) ## 4. Apifox 导入 OpenAPI @@ -54,9 +59,9 @@ MOCK SMS CODE: phone=13800000002, purpose=login, code=483920 4. 点击「确认导入」,Apifox 自动生成全部接口、参数、响应模型 5. 在 Apifox 中可直接调试:登录接口获取 token 后,设置「环境变量 `token`」,在接口「Authorization」填 `Bearer {{token}}` -## 5. Token 使用方式 +## 5. Token 使用方式(S02) -登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`。 +登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`(有效期 7 天)。 ```json { @@ -76,6 +81,36 @@ MOCK SMS CODE: phone=13800000002, purpose=login, code=483920 Authorization: Bearer ``` +### 5.1 两个 Token 的用途必须分开 + +| Token | 用在哪 | +|---|---| +| `accessToken` | 所有普通业务接口的 `Authorization` 头 | +| `refreshToken` | **只能**用于 `POST /api/v1/auth/refresh` | + +把 `refreshToken` 放进普通接口的 `Authorization` 头会被拒绝(401); +把 `accessToken` 传给刷新接口同样会被拒绝(401)。两者由不同密钥签发,不可互换。 + +### 5.2 刷新与登出 + +``` +POST /api/v1/auth/refresh body: { "refreshToken": "" } +POST /api/v1/auth/logout header: Authorization: Bearer (无 body) +``` + +### 5.3 前端必须遵守的规则 + +1. **上线即强制重新登录**:S02 之后所有旧 Token(不含类型与会话信息)全部失效, + 前端需要接受一次强制登录,不要尝试兼容旧 Token。 +2. 普通请求**只携带 Access Token**。 +3. 收到普通接口 401 时,**最多刷新一次**;刷新成功后原子替换两个 Token 并重放原请求。 +4. **刷新请求必须串行**:多个并发 401 不能同时用同一个 Refresh Token 去刷新 + (并发刷新会被服务端判定为重放并撤销整个会话)。 +5. **刷新失败一律清除本地两个 Token 并跳转登录页**,不要循环刷新。 +6. **登出时先调用 `/auth/logout`,随后无条件清除本地 Token**; + 不要依赖第二次登出请求(Access Token 失效后它本来就会 401)。 +7. **不要记录或上报 Token**:不写入 URL、本地日志、埋点或错误上报正文。 + ## 3. 请求头 | Header | 必需 | 说明 | @@ -141,7 +176,7 @@ Authorization: Bearer **登录方式**(开发环境 Mock): 1. `POST /auth/sms/send?phone=<手机号>` 发送验证码 -2. 验证码打印在服务日志:`MOCK SMS CODE: ... code=xxxxxx` +2. 验证码**不再输出到日志**;本地按 §3 的受控方式取得(生产禁止查表) 3. `POST /auth/login` 携带 `{phone, code, purpose:"login"}` 获取 token ## 7. 典型联调流程 @@ -442,6 +477,8 @@ Authorization: Bearer - 值可能是**相对存储路径**或文件存储地址,**不保证是完整的 `http/https` URL**。 - 前端**下载简历应调用 `GET /api/v1/profile/me/resume`**,不要直接拼接 `resumeUrl` 做请求地址。 +- 上传仍仅接受 PDF/DOC/DOCX 且不超过 10MB;服务端会同时校验扩展名、Content-Type 和实际文件结构。伪装扩展名、损坏文件、危险 DOCX 压缩包或含路径/控制字符的文件名返回 400,前端不要仅依据浏览器 MIME 判断文件有效。 +- 上传替换失败时原简历保持可用;成功提交后旧文件才会清理。该流程不改变现有请求字段或响应结构。 ### 12.12 graduationYear 特别说明 @@ -953,3 +990,27 @@ V43 仅供管理员后台使用,前端不得向学生或企业展示。当前 可用接口、参数和小样本规则见 [candidate-scoring-fairness-api.md](candidate-scoring-fairness-api.md)。前端不得根据城市、学校、姓名等字段创建敏感人群筛选,也不得尝试通过多次小样本查询反推出候选人。 总览、维度和申诉响应都显式返回 `availability/reason`。状态/版本/回滚原因及分布桶均按独立候选人数计算;`appealCount` 仅表示事件数。`SUPPRESSED` 时不得用总体计数、补集或其它接口字段反推出被隐藏比例,`NO_DATA`、`INSUFFICIENT_SAMPLE`、`SUPPRESSED` 均不得渲染为 `NORMAL`。 + +## 13. 企业下载候选人简历(S03) + +``` +GET /api/v1/company/candidates/{applicationId}/resume +Authorization: Bearer +``` + +响应是**文件流**(不是 JSON 包装),前端应按二进制处理: + +- 成功:`200` + `Content-Type`(PDF/DOC/DOCX)+ `Content-Disposition: attachment; filename*=UTF-8''…` + + `Cache-Control: no-store` +- `401` 未登录或会话失效 +- `403` 非 COMPANY 角色、无企业资料、跨企业投递,或候选人隐私设置不允许 +- `404` 投递不存在(`APPLICATION_NOT_FOUND`)或学生没有简历(`PROFILE_NOT_FOUND`) + +前端注意事项: + +1. **不要**从候选人列表/详情里找 `resumeUrl` —— 这些 DTO 有意不含该字段; + 下载必须走上面这个接口。 +2. 403 与 404 都按「不可下载」处理,**不要**在 UI 上区分「隐私拒绝」与「跨企业」, + 避免泄露归属信息。 +3. 下载链接不要放进 URL 或日志;如需预览,请用同一 Authorization 头请求。 +4. 响应已声明 `no-store`,前端不要再把简历文件写入本地持久缓存。 diff --git a/docs/offer-api.md b/docs/offer-api.md index f455bfd..4ef02bf 100644 --- a/docs/offer-api.md +++ b/docs/offer-api.md @@ -259,7 +259,7 @@ POST /offers/{id}/remind > 现网 OpenAPI 亦可访问 `GET /api/v1/v3/api-docs` 导出后导入 Apifox。 -测试账号(Mock 短信验证码,验证码见应用日志): +测试账号(Mock 短信验证码:应用日志不再输出,本地按 `docs/frontend-integration.md` §3 的受控方式取得;生产禁止查表): | 角色 | 手机号 | 用途 | |---|---|---| diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index cecf596..24e4e5e 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -1445,8 +1445,8 @@ "tags": [ "认证" ], - "summary": "发送短信验证码(Mock)", - "description": "开发环境 Mock:验证码会打印到日志", + "summary": "发送短信验证码", + "description": "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。", "operationId": "sendSmsCode", "parameters": [ { @@ -9213,6 +9213,94 @@ } } } + }, + "/auth/refresh": { + "post": { + "tags": [ + "认证" + ], + "summary": "刷新 Token", + "description": "使用 Refresh Token 换取新的 Access/Refresh Token 对;旧 Refresh Token 立即失效", + "operationId": "refresh", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshTokenRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseTokenPairResponse" + } + } + } + } + } + } + }, + "/auth/logout": { + "post": { + "tags": [ + "认证" + ], + "summary": "登出", + "description": "撤销当前登录会话;需要 Access Token,不接受任何请求体", + "operationId": "logout", + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringString" + } + } + } + } + } + } + }, + "/company/candidates/{applicationId}/resume": { + "get": { + "tags": [ + "投递" + ], + "summary": "企业下载候选人简历", + "description": "按候选人隐私设置授权后返回简历文件流;不返回存储路径、学生 ID 或联系方式", + "operationId": "downloadCandidateResume", + "parameters": [ + { + "name": "applicationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "byte" + } + } + } + } + } + } } }, "components": { @@ -11881,19 +11969,30 @@ }, "limitations": { "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } }, "evidenceLevel": { "type": "string", - "enum": ["DIRECT_RULE", "SELF_REPORTED", "BEHAVIOR_PROXY", "UNVALIDATED_PROXY"] + "enum": [ + "DIRECT_RULE", + "SELF_REPORTED", + "BEHAVIOR_PROXY", + "UNVALIDATED_PROXY" + ] }, "ruleContributions": { "type": "array", - "items": { "$ref": "#/components/schemas/CandidateScoringRuleContribution" } + "items": { + "$ref": "#/components/schemas/CandidateScoringRuleContribution" + } }, "improvementActions": { "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } }, "explanationVersion": { "type": "string" @@ -15301,41 +15400,91 @@ "CandidateScoringCoverage": { "type": "object", "properties": { - "computableDimensions": { "type": "integer", "format": "int32" }, - "totalDimensions": { "type": "integer", "format": "int32" }, - "includedDimensions": { "type": "array", "items": { "type": "string" } }, + "computableDimensions": { + "type": "integer", + "format": "int32" + }, + "totalDimensions": { + "type": "integer", + "format": "int32" + }, + "includedDimensions": { + "type": "array", + "items": { + "type": "string" + } + }, "excludedDimensions": { "type": "array", - "items": { "$ref": "#/components/schemas/CandidateScoringExcludedDimension" } + "items": { + "$ref": "#/components/schemas/CandidateScoringExcludedDimension" + } } } }, "CandidateScoringExcludedDimension": { "type": "object", "properties": { - "dimension": { "type": "string" }, - "status": { "type": "string" }, - "reason": { "type": "string" } + "dimension": { + "type": "string" + }, + "status": { + "type": "string" + }, + "reason": { + "type": "string" + } } }, "CandidateScoringRuleContribution": { "type": "object", "properties": { - "ruleCode": { "type": "string" }, - "outcome": { "type": "string", "enum": ["APPLIED", "NOT_APPLIED", "MISSING", "EXPIRED", "UNAVAILABLE"] }, - "contribution": { "type": "number", "nullable": true }, - "maxContribution": { "type": "number", "nullable": true }, - "label": { "type": "string" }, - "observation": { "type": "string" }, - "explanation": { "type": "string" } + "ruleCode": { + "type": "string" + }, + "outcome": { + "type": "string", + "enum": [ + "APPLIED", + "NOT_APPLIED", + "MISSING", + "EXPIRED", + "UNAVAILABLE" + ] + }, + "contribution": { + "type": "number", + "nullable": true + }, + "maxContribution": { + "type": "number", + "nullable": true + }, + "label": { + "type": "string" + }, + "observation": { + "type": "string" + }, + "explanation": { + "type": "string" + } } }, "CandidateScoringValidity": { "type": "object", "properties": { - "completedAt": { "type": "string", "format": "date-time" }, - "validUntil": { "type": "string", "format": "date-time" }, - "expired": { "type": "boolean" } + "completedAt": { + "type": "string", + "format": "date-time" + }, + "validUntil": { + "type": "string", + "format": "date-time" + }, + "expired": { + "type": "boolean" + } } }, "StudentProfileResponse": { @@ -15632,6 +15781,48 @@ "format": "date-time" } } + }, + "RefreshTokenRequest": { + "type": "object", + "required": [ + "refreshToken" + ], + "properties": { + "refreshToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + } + }, + "TokenPairResponse": { + "type": "object", + "properties": { + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "expiresIn": { + "type": "integer", + "format": "int64" + } + } + }, + "ApiResponseTokenPairResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/TokenPairResponse" + } + } } }, "securitySchemes": { diff --git a/docs/phase1-scope-and-acceptance.md b/docs/phase1-scope-and-acceptance.md index 14d479c..79d21ec 100644 --- a/docs/phase1-scope-and-acceptance.md +++ b/docs/phase1-scope-and-acceptance.md @@ -29,6 +29,32 @@ | C06 评价与企业回复 | 评价、预审、管理员决策、企业官方回复 | review/、feedback/ | 公开载荷无学生身份;仅通过回复公开;幂等及审核并发;企业归属 | 小刘 / 张顺 | | C07 测评与评分治理 | RIASEC、四维能力、评分卡、排名、申诉回滚、防刷 | assessment/、ability/、candidatescore/、competition/、risk/ | 抽题/计时/有效期/规则开关;申诉回滚;评分读取与写入边界;结果解释 | 张顺 / 小刘 | | C08 推荐与人才邀约 | 岗位推荐、学生授权发现、企业邀约与接受后沟通 | recommendation/、talentdiscovery/、talentinvitation/ | 默认不发现;同企业历史投递排除;限额/冷却/幂等;接受后会话隐私 | 张顺 / 小刘 | +| S01 认证安全整改 | 验证码不落日志、受控取码、`/jobs` 认证边界 | auth/、docs/smoke-test.sh、job/JobController.java | 双开关受控取码;mock=false 完整回滚;`/jobs` 权限矩阵(独立代码复验与真实 MySQL/Docker 黑盒已通过) | 张顺 / 独立复验 | +| S02 Token 隔离与会话吊销 | Access/Refresh 类型与密钥隔离、轮换、重放处置、会话吊销 | auth/(JwtTokenProvider、AuthSessionService、AuthTokenHasher、JwtAuthenticationFilter、AuthController)、V42__auth_token_sessions.sql | Refresh 不能访问普通接口;Access 不能刷新;二次使用撤销当前会话;并发刷新最多成功一次;登出/注销完成使会话失效 | 张顺 / 独立复验 | +| S03 生产配置与简历隐私 | 历史种子账号治理、一次性管理员初始化、企业简历下载授权、Actuator 隔离 | auth/(AdminBootstrapRunner)、application/(CompanyResumeAccessPolicy、CompanyResumeService)、V43__disable_legacy_seed_accounts.sql、application.yml | V43 只禁用完全匹配原始指纹的三条种子账号;管理员初始化默认关闭且幂等;三种可见性 × 全部投递状态 × 跨企业/未认证企业矩阵;拒绝路径零文件读取;业务端口不提供指标 | 张顺 / 独立复验 | + +### 2.1 Q01 岗位搜索真实查询覆盖(C02 证据补充) + +`JobSearchSpecification` 与 `JobService.searchJobs` 原先只被纯 Mockito 测试覆盖(`JobSearchServiceTest` 把 `findAll(Specification, Pageable)` 整体 Mock 掉),无法证明 Criteria 谓词、SQL 过滤、排序与分页在真实数据库上的行为。Q01 补齐真实查询证据,**只新增测试与文档,未改动生产代码、接口、DTO、状态机、权限、搜索语义与索引**。 + +测试分层与结果(详见 [Q01 实施报告](q01-job-search-real-query-implementation-report.md)): + +| 层次 | 测试类 | 数据库 | Tests run | Failures | Errors | Skipped | +|---|---|---|---|---|---|---| +| H2 真实 Repository | `job/JobSearchSpecificationIntegrationTest` | H2 + 真实 Flyway | 19 | 0 | 0 | 0 | +| H2 真实 Service / EntityManager | `job/JobSearchServiceIntegrationTest` | H2 + 真实 Flyway | 9 | 0 | 0 | 0 | +| H2 查询副作用行级快照 | `job/JobSearchQuerySideEffectIntegrationTest` | H2 + 真实 Flyway | 4 | 0 | 0 | 0 | +| 真实 MySQL 8 规格对账 | `job/JobSearchSpecificationMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 13 | 0 | 0 | 0 | +| 真实 MySQL 查询副作用行级快照 | `job/JobSearchQuerySideEffectMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 4 | 0 | 0 | 0 | +| 真实 MySQL 执行计划与查询次数 | `job/JobSearchQueryExplainMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 1 | 0 | 0 | 0 | + +- MySQL 层用正式 Flyway 迁移 + `ddl-auto=validate` 启动,连接参数由 `@DynamicPropertySource` 运行期注入,不落盘、不打印;核心用例**真实执行、无 SKIP**。 +- H2 与 MySQL 在 keyword(三字段/大小写/trim/空白与 `%`、`_` 特殊字符)、city、employmentType、companyId、薪资边界与区间重叠、PUBLISHED 强制过滤、latest/relevance/salary 排序、分页与 total 上**逐项一致,未发现语义差异**。 +- 副作用证据为 jobs、companies、users、applications、candidate_dimension_scores、notifications、conversations、messages、audit_logs 九张表的**行级稳定字段快照**(非 COUNT/SUM),并配「改字段 / 插行 / 删行」三条**反证测试**证明比较本身有效。 +- 真实 MySQL 上对组合过滤、latest 分页、salary 排序与 count 执行 `EXPLAIN FORMAT=JSON`;小样本夹具下为全表扫描 + filesort,**不构成生产规模结论**,前导通配符与排序成本移交 R01。 +- OpenAPI 保持 176 paths / 214 operations,`docs/openapi-rc.json` 未改动。 + +状态:**Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及独立真实 MySQL/Docker 验收。** ## 3. 一期必须补齐的八个功能组 diff --git a/docs/privacy-settings-api.md b/docs/privacy-settings-api.md index c4415d6..0fe931b 100644 --- a/docs/privacy-settings-api.md +++ b/docs/privacy-settings-api.md @@ -99,4 +99,31 @@ ## 6. 审计 -更新隐私设置会写入审计日志 `UPDATE_PRIVACY_SETTINGS`。审计中**不得**写入 `phone / email / password / jwt / resumeUrl` 等敏感值。 \ No newline at end of file +更新隐私设置会写入审计日志 `UPDATE_PRIVACY_SETTINGS`。审计中**不得**写入 `phone / email / password / jwt / resumeUrl` 等敏感值。 +## S03-B:`resumeVisibility` 的企业侧执行边界 + +此前 `resumeVisibility` 只有存储与读写接口,**没有任何企业侧代码路径读取它**,等同于空转开关。 +S03 起该设置在企业读取简历文件的唯一入口 `GET /company/candidates/{applicationId}/resume` +上真正生效,判定全部由失败关闭的 `CompanyResumeAccessPolicy` 完成。 + +### 可见性决策表 + +| 设置 | 企业条件 | 投递条件 | 结果 | +|---|---|---|---| +| 无设置记录 / `null` | 任意 | 任意 | 403(按 PRIVATE 处理) | +| `PRIVATE` | 任意 | 包括已投递、面试、录用 | 403 | +| `APPLIED_COMPANIES` | 岗位属于当前企业 | `SUBMITTED`/`VIEWED`/`INTERVIEW`/`HIRED` | 200 | +| `APPLIED_COMPANIES` | 岗位属于当前企业 | `WITHDRAWN`/`REJECTED` | 403 | +| `APPLIED_COMPANIES` | 其他企业 | 任意 | 403 | +| `VERIFIED_COMPANIES` | 当前企业 `verified=true` 且拥有该投递岗位 | 任意合法投递状态 | 200 | +| `VERIFIED_COMPANIES` | 未认证企业或其他企业 | 任意 | 403 | + +### 重要变更 + +- **`APPLIED_COMPANIES` 与 `VERIFIED_COMPANIES` 现在都强制校验「投递所属岗位属于当前企业」。** + 此前 `canCompanyViewResume` 不校验公司归属:`APPLIED_COMPANIES` 完全不使用 `companyId`, + `VERIFIED_COMPANIES` 只校验企业 `verified` —— 任何已认证企业都可凭任意 `applicationId` 查看简历。 +- `VERIFIED_COMPANIES` **不是**「知道学生 ID 即可查看」:仍需合法的候选业务上下文。 +- 隐私拒绝统一返回 `FORBIDDEN(2002)`;跨企业同样 403,不通过差异响应泄露归属。 +- `PrivacySettingsService.canCompanyViewResume` 保留为委托方法,判定逻辑已收束到策略类, + 不再保留可被误用的宽松实现。 diff --git a/docs/q01-job-search-mysql-explain-evidence.md b/docs/q01-job-search-mysql-explain-evidence.md new file mode 100644 index 0000000..a2fe884 --- /dev/null +++ b/docs/q01-job-search-mysql-explain-evidence.md @@ -0,0 +1,303 @@ +# Q01 MySQL 执行计划证据(由 JobSearchQueryExplainMySqlIntegrationTest 生成) + +- 数据库:`mysql:8.4` Testcontainers 独立容器,正式 Flyway 迁移,`ddl-auto=validate` +- EXPLAIN 形式:`EXPLAIN FORMAT=JSON`,与 Criteria 路径绑定同一组参数 +- 夹具规模:每个城市分别 1 / 20 / 50 条 PUBLISHED 岗位(刻意做小,用于验证语义而非压测) +- Q01 不新增索引;执行计划问题记录给 R01 + +## 1. 组合过滤 + latest 分页 + +Hibernate 实际生成并执行的 SQL(占位符形式): + +```sql +select j1_0.id,j1_0.city,j1_0.company_id,j1_0.created_at,j1_0.description,j1_0.employment_type,j1_0.published_at,j1_0.requirements,j1_0.salary_max,j1_0.salary_min,j1_0.status,j1_0.title,j1_0.updated_at from jobs j1_0 where j1_0.status=? and (lower(j1_0.title) like ? escape '' or lower(j1_0.description) like ? escape '' or lower(j1_0.requirements) like ? escape '') and lower(j1_0.city)=? and lower(j1_0.employment_type)=? and j1_0.salary_max>=? and j1_0.salary_min<=? and j1_0.company_id=? order by j1_0.created_at desc,j1_0.id desc limit ?,? + +select count(j1_0.id) from jobs j1_0 where j1_0.status=? and (lower(j1_0.title) like ? escape '' or lower(j1_0.description) like ? escape '' or lower(j1_0.requirements) like ? escape '') and lower(j1_0.city)=? and lower(j1_0.employment_type)=? and j1_0.salary_max>=? and j1_0.salary_min<=? and j1_0.company_id=? +``` + +等价 SQL(绑定参数 [PUBLISHED, %q01e75001岗位%, %q01e75001岗位%, %q01e75001岗位%, Q01E75001CITY20, FULL_TIME, 5000, 30000, 1, 20, 0]): + +```sql +SELECT j.id FROM jobs j +WHERE j.status = ? AND (lower(j.title) LIKE ? OR lower(j.description) LIKE ? OR lower(j.requirements) LIKE ?) + AND lower(j.city) = ? AND lower(j.employment_type) = ? AND j.salary_max >= ? AND j.salary_min <= ? AND j.company_id = ? +ORDER BY j.created_at DESC, j.id DESC +LIMIT ? OFFSET ? +``` + +计划摘要: + +``` +query_cost = 1.35 +using_filesort = true +using_temporary_table = false +- 表 query_block/ORDER BY: table_name="j", access_type="ALL", possible_keys=["idx_jobs_company_status","idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00" +``` + +
EXPLAIN FORMAT=JSON 原文 + +```json +{ + "query_block": { + "select_id": 1, + "cost_info": { + "query_cost": "1.35" + }, + "ordering_operation": { + "using_filesort": true, + "cost_info": { + "sort_cost": "1.00" + }, + "table": { + "table_name": "j", + "access_type": "ALL", + "possible_keys": [ + "idx_jobs_company_status", + "idx_jobs_status_created", + "idx_jobs_city_status", + "idx_jobs_status_employment_type", + "idx_jobs_status_salary_max" + ], + "rows_examined_per_scan": 1, + "rows_produced_per_join": 1, + "filtered": "100.00", + "cost_info": { + "read_cost": "0.25", + "eval_cost": "0.10", + "prefix_cost": "0.35", + "data_read_per_join": "1K" + }, + "used_columns": [ + "id", + "company_id", + "title", + "description", + "requirements", + "city", + "employment_type", + "salary_min", + "salary_max", + "status", + "created_at" + ], + "attached_condition": "((`mydreamjob_q01`.`j`.`company_id` = 1) and (`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and ((lower(`mydreamjob_q01`.`j`.`title`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`description`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`requirements`) like '%q01e75001岗位%')) and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20') and (lower(`mydreamjob_q01`.`j`.`employment_type`) = 'full_time') and (`mydreamjob_q01`.`j`.`salary_max` >= 5000) and (`mydreamjob_q01`.`j`.`salary_min` <= 30000))" + } + } + } +} +``` + +
+ +## 2. latest 分页 + +等价 SQL(绑定参数 [PUBLISHED, Q01E75001CITY20, 20, 0]): + +```sql +SELECT j.id FROM jobs j +WHERE j.status = ? AND lower(j.city) = ? +ORDER BY j.created_at DESC, j.id DESC +LIMIT ? OFFSET ? +``` + +计划摘要: + +``` +query_cost = 1.35 +using_filesort = true +using_temporary_table = false +- 表 query_block/ORDER BY: table_name="j", access_type="ALL", possible_keys=["idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00" +``` + +
EXPLAIN FORMAT=JSON 原文 + +```json +{ + "query_block": { + "select_id": 1, + "cost_info": { + "query_cost": "1.35" + }, + "ordering_operation": { + "using_filesort": true, + "cost_info": { + "sort_cost": "1.00" + }, + "table": { + "table_name": "j", + "access_type": "ALL", + "possible_keys": [ + "idx_jobs_status_created", + "idx_jobs_city_status", + "idx_jobs_status_employment_type", + "idx_jobs_status_salary_max" + ], + "rows_examined_per_scan": 1, + "rows_produced_per_join": 1, + "filtered": "100.00", + "cost_info": { + "read_cost": "0.25", + "eval_cost": "0.10", + "prefix_cost": "0.35", + "data_read_per_join": "1K" + }, + "used_columns": [ + "id", + "city", + "status", + "created_at" + ], + "attached_condition": "((`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20'))" + } + } + } +} +``` + +
+ +## 3. salary 排序(salaryMax DESC NULLS LAST) + +等价 SQL(绑定参数 [PUBLISHED, Q01E75001CITY20, 20, 0]): + +```sql +SELECT j.id FROM jobs j +WHERE j.status = ? AND lower(j.city) = ? +ORDER BY CASE WHEN j.salary_max IS NULL THEN 1 ELSE 0 END ASC, j.salary_max DESC, j.id DESC +LIMIT ? OFFSET ? +``` + +计划摘要: + +``` +query_cost = 1.35 +using_filesort = true +using_temporary_table = false +- 表 query_block/ORDER BY: table_name="j", access_type="ALL", possible_keys=["idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00" +``` + +
EXPLAIN FORMAT=JSON 原文 + +```json +{ + "query_block": { + "select_id": 1, + "cost_info": { + "query_cost": "1.35" + }, + "ordering_operation": { + "using_filesort": true, + "cost_info": { + "sort_cost": "1.00" + }, + "table": { + "table_name": "j", + "access_type": "ALL", + "possible_keys": [ + "idx_jobs_status_created", + "idx_jobs_city_status", + "idx_jobs_status_employment_type", + "idx_jobs_status_salary_max" + ], + "rows_examined_per_scan": 1, + "rows_produced_per_join": 1, + "filtered": "100.00", + "cost_info": { + "read_cost": "0.25", + "eval_cost": "0.10", + "prefix_cost": "0.35", + "data_read_per_join": "1K" + }, + "used_columns": [ + "id", + "city", + "salary_max", + "status" + ], + "attached_condition": "((`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20'))" + } + } + } +} +``` + +
+ +## 4. count 查询 + +等价 SQL(绑定参数 [PUBLISHED, %q01e75001岗位%, %q01e75001岗位%, %q01e75001岗位%, Q01E75001CITY20, FULL_TIME, 5000, 30000, 1]): + +```sql +SELECT count(j.id) FROM jobs j +WHERE j.status = ? AND (lower(j.title) LIKE ? OR lower(j.description) LIKE ? OR lower(j.requirements) LIKE ?) + AND lower(j.city) = ? AND lower(j.employment_type) = ? AND j.salary_max >= ? AND j.salary_min <= ? AND j.company_id = ? +``` + +计划摘要: + +``` +query_cost = 0.35 +using_filesort = false +using_temporary_table = false +- 表 query_block: table_name="j", access_type="ALL", possible_keys=["idx_jobs_company_status","idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00" +``` + +
EXPLAIN FORMAT=JSON 原文 + +```json +{ + "query_block": { + "select_id": 1, + "cost_info": { + "query_cost": "0.35" + }, + "table": { + "table_name": "j", + "access_type": "ALL", + "possible_keys": [ + "idx_jobs_company_status", + "idx_jobs_status_created", + "idx_jobs_city_status", + "idx_jobs_status_employment_type", + "idx_jobs_status_salary_max" + ], + "rows_examined_per_scan": 1, + "rows_produced_per_join": 1, + "filtered": "100.00", + "cost_info": { + "read_cost": "0.25", + "eval_cost": "0.10", + "prefix_cost": "0.35", + "data_read_per_join": "1K" + }, + "used_columns": [ + "id", + "company_id", + "title", + "description", + "requirements", + "city", + "employment_type", + "salary_min", + "salary_max", + "status" + ], + "attached_condition": "((`mydreamjob_q01`.`j`.`company_id` = 1) and (`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and ((lower(`mydreamjob_q01`.`j`.`title`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`description`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`requirements`) like '%q01e75001岗位%')) and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20') and (lower(`mydreamjob_q01`.`j`.`employment_type`) = 'full_time') and (`mydreamjob_q01`.`j`.`salary_max` >= 5000) and (`mydreamjob_q01`.`j`.`salary_min` <= 30000))" + } + } +} +``` + +
+ +## 5. 结果规模 1 / 20 / 50 下的 SQL 语句条数 + +Spring Data 对首页有优化:首页返回行数小于 `pageSize` 时不再发 count 查询, +因此结果数 < pageSize 时为 1 条(仅数据查询),否则为 2 条(count + 数据查询)。 +两种情况下语句条数都**不随结果规模增长**,即不存在 N+1。 + +| 匹配结果数 | 返回行数 | totalElements | Repository SQL数 | 完整Service SQL数 | +|---|---|---|---|---| +| 1 | 1 | 1 | 1 | 2 | +| 20 | 20 | 20 | 2 | 3 | +| 50 | 20 | 50 | 2 | 3 | diff --git a/docs/q01-job-search-real-query-design.md b/docs/q01-job-search-real-query-design.md new file mode 100644 index 0000000..ea56eb2 --- /dev/null +++ b/docs/q01-job-search-real-query-design.md @@ -0,0 +1,190 @@ +# Q01 岗位搜索真实查询覆盖设计 + +## 1. 状态 + +设计已确认,待实施、专项测试、真实 MySQL 复验与独立验收。 + +## 2. 背景与问题 + +`JobSearchSpecification` 承担公开岗位搜索的核心过滤规则,但当前 `JobSearchServiceTest` 将 +`JobRepository.findAll(Specification, Pageable)` Mock 掉后直接返回预设数据。这些测试可以证明 +参数校验、分页对象和 DTO 组装,却不能证明 Criteria 谓词、SQL 过滤、排序和分页真的正确。 + +Q01 的目标是补齐真实数据库证据,不以测试数量替代查询行为覆盖,也不改变现有公网接口和业务语义。 + +## 3. 范围 + +### 3.1 必须覆盖 + +- `JobSearchSpecification.searchJobs(...)` 的真实 Repository 查询; +- `JobService.searchJobs(...)` 的 latest、relevance、salary 排序和分页; +- PUBLISHED 强制过滤; +- keyword 对 title、description、requirements 的匹配; +- city、employmentType、companyId; +- salaryMin、salaryMax 的区间重叠语义; +- H2 快速测试与 MySQL 8 行为一致性; +- 查询参数化和特殊字符边界; +- 真实 SQL/EXPLAIN 证据及数据副作用检查。 + +### 3.2 明确不做 + +- 不改变 `/jobs/search` 请求或响应; +- 不新增、删除或重命名公网接口; +- 不修改岗位状态机、岗位归属或角色权限; +- 不把 relevance 改造成新的全文检索算法;当前 relevance 与 latest 等价,本轮锁定该语义; +- 不接入 Elasticsearch、搜索引擎或缓存; +- 不新增 Flyway、表或索引; +- 不改 Candidate Scoring、推荐、排名、邀约、Offer、申诉、消息、简历和认证; +- 不删除现有 Mock 测试。现有测试继续承担 Service 编排与参数校验职责; +- 不进入 R01 性能和结构重构。 + +## 4. 当前查询契约 + +| 参数 | 既有语义 | +|---|---| +| status | 无论调用方传什么条件,只返回 `PUBLISHED` | +| keyword | trim 后忽略大小写,匹配 title、description、requirements 任一字段 | +| city | trim 后忽略大小写精确匹配;显式归一化以保证 H2/MySQL 一致 | +| employmentType | trim 后忽略大小写精确匹配;不做前缀匹配 | +| salaryMin | 岗位 `salaryMax >= salaryMin` | +| salaryMax | 岗位 `salaryMin <= salaryMax` | +| companyId | 精确匹配 | +| sort=latest/null | `createdAt DESC, id DESC` | +| sort=relevance | 当前与 latest 相同,不改变 | +| sort=salary | `salaryMax` 非空优先、降序;相同值按 `id DESC`;NULL 最后 | +| page | 从 0 开始,不得为负数 | +| size | 1–50 | + +薪资过滤是区间重叠,不是“岗位上下限都完全落在用户区间内”。边界相等必须命中。 + +## 5. 测试分层 + +### 5.1 H2 Repository 快速层 + +新增 `JobSearchSpecificationIntegrationTest`: + +- 使用 Spring Data JPA、真实 `JobRepository` 和真实 H2; +- 激活 `integration-test`,由真实 Flyway 建表; +- 不 Mock Repository、EntityManager、Specification、CriteriaBuilder 或事务; +- 每个用例创建最小隔离夹具,按精确主键清理; +- 直接执行 `jobRepository.findAll(spec, pageable)`; +- 断言返回 ID 集合与顺序,不能只断言数量。 + +至少覆盖: + +1. title 关键词; +2. description 关键词; +3. requirements 关键词; +4. 关键词忽略大小写; +5. null、空字符串、纯空白关键词; +6. keyword 前后空格; +7. city 精确匹配与 trim; +8. employmentType 精确匹配与 trim; +9. companyId; +10. salaryMin; +11. salaryMax; +12. 两端薪资边界相等; +13. 薪资区间重叠; +14. 薪资区间不重叠; +15. 多条件 AND; +16. DRAFT、CLOSED 等非公开状态排除; +17. 无结果; +18. 排序与分页; +19. `%`、`_`、反斜杠、引号等特殊字符的实际既有语义; +20. null salary 字段的行为。 + +特殊字符测试首先锁定当前 Criteria `LIKE` 行为,不得在没有产品决策时把 `%` 和 `_` 自动改成字面量搜索。 + +### 5.2 Service 真实查询层 + +新增或扩展 `JobSearchServiceIntegrationTest`: + +- 使用真实 `JobService`、`JobRepository`、`CompanyRepository`、EntityManager; +- 不 Mock `findAll`; +- latest/null/relevance 的排序结果一致且稳定; +- salary 排序验证 NULL LAST、降序、相同薪资按 ID 倒序; +- 验证 page/size、totalElements、totalPages; +- 验证企业信息批量组装,不改变 DTO; +- 保留现有非法 sort、非法分页、非法薪资范围的测试。 + +### 5.3 MySQL 真实层 + +优先新增 `JobSearchSpecificationMySqlIntegrationTest`,使用项目已有 Testcontainers 依赖: + +- `@Testcontainers(disabledWithoutDocker = true)` 仅允许本地快速执行时环境性跳过; +- 独立验收和最终冻结时 Docker/MySQL 核心用例不得 SKIP; +- 使用 MySQL 8.x 容器、正式 Flyway 和 `ddl-auto=validate`; +- 使用动态属性注入 JDBC URL、用户名和密码; +- 不打印数据库凭据; +- 至少运行一组与 H2 完全相同的参数化用例,并对返回 ID 与顺序逐项对账; +- 重点对账大小写、LIKE 特殊字符、NULL 排序、边界比较、分页稳定性。 + +如果仓库约定不允许 Testcontainers 进入普通 `mvn test`,可以将其放入独立 profile;但最终报告必须给出真实执行命令和非 SKIP 结果,不能只提交一个从未运行的测试类。 + +## 6. 夹具设计 + +使用明确区分的岗位: + +- 三个包含关键词的岗位,关键词分别只存在于 title、description、requirements; +- 相同城市和不同城市; +- FULL_TIME、PART_TIME 等不同 employmentType; +- 至少两个企业; +- 薪资区间覆盖边界相等、部分重叠、不重叠、NULL; +- 相同 createdAt 的两个岗位,用 ID 验证稳定排序; +- 相同 salaryMax 的两个岗位,用 ID 验证稳定排序; +- PUBLISHED、DRAFT、CLOSED 等状态。 + +夹具必须以测试生成的唯一标识隔离,不能依赖 V1_1 演示数据或真实业务数据。 + +## 7. 数据库与副作用 + +- 查询测试不得修改岗位状态、企业、用户或其他业务表; +- 查询前后对相关稳定业务字段做行级快照,而不是只比较 COUNT; +- Testcontainers 由测试生命周期销毁; +- 使用负责人现有 MySQL 做独立黑盒时,只能精确清理本轮夹具; +- 不允许清库、重置自增、删除 volume; +- 不新增 Flyway 或索引。发现执行计划问题只记录到 R01,不在 Q01 修复。 + +## 8. SQL 与 EXPLAIN + +真实 MySQL 验收需记录: + +- 组合过滤 SQL; +- latest 分页; +- salary 排序; +- count 查询; +- 使用索引、access type、estimated rows、filesort/temporary 情况; +- 1、20、50 条结果规模下查询次数。 + +本阶段以“语义正确且无明显发布阻断”为通过条件,不为了 EXPLAIN 修改索引。 + +## 9. OpenAPI 与兼容性 + +- 不修改 path 和 operation; +- 目标仍为 176 paths / 214 operations; +- 不改变参数名、默认值、响应 DTO 或错误码; +- `docs/openapi-rc.json` 不应因仅新增测试而变化; +- 必须运行 `docs/verify_openapi.py` 防止意外契约漂移。 + +## 10. 通过条件 + +只有同时满足以下条件才能通过 Q01: + +1. `JobSearchSpecification` 不再是测试零引用; +2. H2 真实 Repository 测试覆盖全部过滤语义; +3. Service 自定义 salary 排序有真实数据库证据; +4. MySQL 核心用例真实运行且无 SKIP; +5. H2 与 MySQL 返回 ID、顺序和边界语义一致,或明确记录并解决差异; +6. 非公开岗位绝不进入公开搜索; +7. 分页稳定、total 正确; +8. 无业务数据副作用; +9. 原 Mock 测试保留且准确标注为编排测试; +10. 全量测试和 clean verify 通过; +11. OpenAPI 保持 176/214; +12. 无新增 Flyway、ErrorCode 或公网接口。 + +## 11. 最终状态 + +实施完成但尚未独立复验时,只能写: + +> Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及真实 MySQL/Docker 验收。 diff --git a/docs/q01-job-search-real-query-implementation-instruction.md b/docs/q01-job-search-real-query-implementation-instruction.md new file mode 100644 index 0000000..be0403a --- /dev/null +++ b/docs/q01-job-search-real-query-implementation-instruction.md @@ -0,0 +1,265 @@ +# Q01 岗位搜索真实查询覆盖实施指令 + +## 一、任务边界 + +项目根目录:`C:\Users\15642\Desktop\zhilai` + +目标分支:`test/q01-job-search-specification` + +本轮只补岗位搜索真实数据库测试和验收证据。原则上不修改生产代码;如果测试复现生产缺陷,先保存失败证据并停止,未经负责人确认不得顺手修复。 + +禁止修改岗位接口、DTO、权限、状态机和搜索语义;禁止新增 Flyway、表、索引、ErrorCode 或公网接口;禁止进入 R01;禁止删除现有 Mock 测试;禁止 `git add -A`、commit、push、reset、restore、checkout、clean;禁止删除或移动历史未跟踪文件。 + +## 二、开始前核查 + +执行: + +```powershell +cd C:\Users\15642\Desktop\zhilai +git branch --show-current +git status --short +git diff --cached --name-only +git log -5 --oneline +git diff --check +git status --short -- mydreamjob-server/src/main/resources/db/migration +``` + +确认分支正确、暂存区为空、HEAD 为 S03 检查点 `cb3bc57` 或报告实际偏差、历史未跟踪文件保留、V1–V43 均未修改。搜索并完整阅读适用 `AGENTS.md`;当前基线未发现时记录“未发现”。 + +## 三、必须阅读 + +```text +docs/q01-job-search-real-query-design.md +mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java +mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java +mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java +mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java +mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java +mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java +mydreamjob-server/src/test/resources/application-test.yml +mydreamjob-server/src/test/resources/application-integration-test.yml +mydreamjob-server/pom.xml +mydreamjob-server/src/main/resources/db/migration/V2__business_schema.sql +mydreamjob-server/src/main/resources/db/migration/V22__job_search_indexes.sql +``` + +执行: + +```powershell +rg -n "JobSearchSpecification|findAll\(.*Specification|findWithSalarySort|findPublishedJobs" mydreamjob-server/src +``` + +记录哪些现有测试 Mock 了查询层,不能把它们描述成真实过滤覆盖。 + +## 四、先写 H2 真实 Repository 测试 + +新增: + +```text +mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java +``` + +要求: + +- 激活 `integration-test`; +- 使用真实 Flyway、H2、JobRepository; +- 不 Mock Repository、Specification、EntityManager 或事务; +- 直接调用 `jobRepository.findAll(JobSearchSpecification.searchJobs(...), pageable)`; +- 每项断言返回 ID 和顺序; +- 测试隔离,不依赖种子岗位; +- 不用顺序无关的 containsOnly 代替排序断言。 + +至少实现以下测试: + +```text +keywordMatchesTitle +keywordMatchesDescription +keywordMatchesRequirements +keywordIsCaseInsensitive +nullBlankAndWhitespaceKeywordDoNotFilter +keywordIsTrimmed +cityUsesTrimmedExactMatch +cityCaseVariantHasSameResultAcrossDatabases +employmentTypeUsesTrimmedExactMatch +employmentTypeCaseVariantHasSameResultAcrossDatabases +companyIdFiltersExactly +salaryMinMatchesWhenJobMaximumEqualsBoundary +salaryMaxMatchesWhenJobMinimumEqualsBoundary +salaryRangesUseOverlapSemantics +nonOverlappingSalaryRangeIsExcluded +multipleConditionsUseAndSemantics +nonPublishedJobsAreAlwaysExcluded +specialLikeCharactersFollowExistingContract +nullSalaryFieldsFollowExistingContract +paginationReturnsStableOrderedIds +noResultReturnsEmptyPage +``` + +不要为了让测试符合预期而预先修改 `JobSearchSpecification`。如果任何既有语义与设计不一致,保留失败测试并报告。 + +## 五、补 Service 真实查询测试 + +新增: + +```text +mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java +``` + +使用真实 JobService、JobRepository、CompanyRepository 和 EntityManager,覆盖: + +```text +latestSortUsesCreatedAtThenIdDescending +nullSortUsesLatestOrder +relevanceCurrentlyUsesLatestOrder +salarySortUsesMaximumDescendingAndNullLast +salarySortUsesIdDescendingAsTieBreaker +salarySortReturnsCorrectTotalAndTotalPages +secondPageDoesNotRepeatOrLoseRows +companyInformationIsMappedFromRealRepository +searchDoesNotReturnDraftOrClosedJobs +``` + +原 `JobSearchServiceTest` 保留,用于参数校验和编排;如需修改注释,只能明确它不是 Specification SQL 覆盖,不得删除或放宽断言。 + +## 六、补 MySQL 真实测试 + +新增建议文件: + +```text +mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java +``` + +使用已有 `org.testcontainers:mysql` 和 `junit-jupiter`: + +- MySQL 8.x; +- 正式 Flyway; +- Hibernate `ddl-auto=validate`; +- `@DynamicPropertySource` 注入连接参数; +- 不打印密码; +- 不复用开发数据库; +- 不 Mock 查询路径; +- 容器用例至少对账 keyword 大小写、特殊字符、城市、薪资边界、非公开过滤、latest、salary NULL LAST、分页。 + +可复用参数化场景或夹具构造器,确保 H2/MySQL 输入一致。必须逐项断言结果 ID/业务标识和顺序,而不是只断言数量。 + +如果普通开发环境 Docker 不可用,可以让 Testcontainers 测试环境性跳过;但最终独立验收必须显式运行该类并得到 Tests run > 0、Skipped=0。不得用 H2 结果代替 MySQL。 + +## 七、查询副作用与执行计划 + +新增测试或独立验收证据证明查询前后以下数据不变: + +- jobs 稳定业务字段; +- companies; +- applications; +- candidate_dimension_scores; +- notifications; +- messages; +- audit_logs。 + +使用行级稳定字段快照,不能只比较 COUNT、SUM 或 CRC32。 + +真实 MySQL 验收对组合过滤、latest、salary 和 count SQL 执行 `EXPLAIN FORMAT=JSON`,记录使用索引、access type、estimated rows、temporary/filesort。Q01 不新增索引;发现性能问题记录给 R01。 + +## 八、文档 + +更新: + +```text +docs/backend-development-roadmap.md +docs/phase1-scope-and-acceptance.md +``` + +只记录 Q01 的测试层级和真实结果,不改变接口文档。原则上不修改 `docs/openapi-rc.json`;若仅新增测试却产生 OpenAPI 差异,视为异常并停止调查。 + +新增最终实施报告建议: + +```text +docs/q01-job-search-real-query-implementation-report.md +``` + +不得写“真实 MySQL 已通过”,除非 Testcontainers 或独立 MySQL 实际运行且核心项无 SKIP。 + +## 九、验证顺序 + +先运行 H2 测试: + +```powershell +cd C:\Users\15642\Desktop\zhilai\mydreamjob-server +mvn -Dtest="JobSearchSpecificationIntegrationTest,JobSearchServiceIntegrationTest" test +``` + +Docker 可用时运行 MySQL 测试: + +```powershell +mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest" test +``` + +运行岗位关联回归: + +```powershell +mvn -Dtest="*JobSearch*Test,*Job*Test,*CompanyPublic*Test" test +``` + +随后: + +```powershell +mvn test +mvn clean verify +python ..\docs\verify_openapi.py +``` + +最后: + +```powershell +cd C:\Users\15642\Desktop\zhilai +git diff --check +git status --short +git diff --cached --name-only +git status --short -- mydreamjob-server/src/main/resources/db/migration +``` + +要求所有必跑测试 Failures=0、Errors=0、Skipped=0,clean verify 成功,OpenAPI 保持 176/214,暂存区为空,V1–V43 未修改。 + +## 十、发现缺陷时 + +如果真实测试发现生产缺陷: + +1. 保留最小失败测试; +2. 记录 H2 与 MySQL 是否一致; +3. 精确写明参数、夹具、预期 ID、实际 ID/顺序和生成 SQL; +4. 不自行修改生产代码; +5. 状态标记为 FAIL,等待负责人下发定向整改指令。 + +禁止通过修改夹具、删除断言、Mock Repository、禁用测试或放宽为只断言数量来制造绿色。 + +## 十一、最终报告 + +必须包含: + +1. 分支与 HEAD; +2. 现有 Mock 测试的真实职责; +3. H2 测试数及结果; +4. MySQL 测试数及结果; +5. 每个过滤条件的实际结果; +6. keyword 三字段、大小写和特殊字符结果; +7. 薪资边界与区间重叠结果; +8. PUBLISHED 强制过滤结果; +9. latest/relevance/salary 排序结果; +10. 分页与 total 结果; +11. H2/MySQL 差异; +12. EXPLAIN 摘要; +13. 数据副作用结果; +14. 关联和全量测试数; +15. Failures/Errors/Skipped; +16. clean verify; +17. OpenAPI paths/operations; +18. 精确修改文件; +19. 是否修改生产代码; +20. 是否新增 Flyway、ErrorCode 或接口; +21. Git 状态; +22. 明确未暂存、未提交、未推送; +23. 是否存在待整改缺陷。 + +实现和自动化通过后只能写: + +> Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及真实 MySQL/Docker 验收。 diff --git a/docs/q01-job-search-real-query-implementation-report.md b/docs/q01-job-search-real-query-implementation-report.md new file mode 100644 index 0000000..8f5157f --- /dev/null +++ b/docs/q01-job-search-real-query-implementation-report.md @@ -0,0 +1,265 @@ +# Q01 岗位搜索真实查询覆盖实施报告 + +> **状态**:Q01 已完成真实查询测试实现、跨库一致性整改及独立复验,可以进入精确暂存检查点。 + +## 1. 分支与 HEAD + +| 项目 | 值 | +|---|---| +| 分支 | `test/q01-job-search-specification` | +| HEAD | `cb3bc57fab549137e3785f5d50bde86ce8ef5731`(S03 检查点 `cb3bc57`,无偏差) | +| 暂存区 | 空(`git diff --cached --name-only` 无输出) | +| 生产代码 | 定向修改 `JobSearchSpecification`:对 city/employmentType 显式 `lower`,消除 H2/MySQL 排序规则差异,并保持线上 MySQL 既有忽略大小写结果 | +| V1–V43 迁移 | 未修改(`git status --short -- .../db/migration` 无输出) | + +`AGENTS.md` 核查:仓库根目录、`mydreamjob-server/` 及其子目录**均未发现** `AGENTS.md`。 + +## 2. 现有 Mock 测试的真实职责 + +| 测试类 | 测试数 | 真实职责 | 不具备的能力 | +|---|---|---|---| +| `JobSearchServiceTest`(保留,未删除、未放宽断言) | 37 | 用 Mockito 把 `JobRepository.findAll(Specification, Pageable)` 与 `CompanyRepository` 整体 Mock 掉,验证参数校验(非法 sort / 非法分页 / 非法薪资范围)、分页对象、排序参数传递、企业信息组装与 DTO 编排 | **不能**证明 Criteria 谓词、SQL 过滤、排序与分页在真实数据库上的行为 | +| `JobServiceTest`(保留) | 13 | 岗位 CRUD、状态机与归属校验的编排 | 同上,与查询语义无关 | + +本轮为该 Mock 测试补充了类级 Javadoc,明确标注它是**编排测试**而非 Specification SQL 覆盖(仅注释,未改动任何断言)。Q01 之前 `JobSearchSpecification` 在生产代码外几乎零引用(仅被 Mock 测试当作 `Specification` 类型占位);本轮起被 4 个测试类**直接调用**其 `searchJobs(...)`:`JobSearchSpecificationIntegrationTest`、`JobSearchSpecificationMySqlIntegrationTest`、`JobSearchQueryExplainMySqlIntegrationTest`,以及副作用用例基类 `JobSearchQuerySideEffectTestBase`(H2 与 MySQL 两个子类共用)。 + +## 3. 测试分层与结果 + +| 层次 | 测试类 | 数据库 | Tests run | Failures | Errors | Skipped | +|---|---|---|---|---|---|---| +| H2 真实 Repository | `JobSearchSpecificationIntegrationTest` | H2(真实 Flyway,`integration-test`) | 21 | 0 | 0 | 0 | +| H2 真实 Service/EntityManager | `JobSearchServiceIntegrationTest` | H2(真实 Flyway) | 9 | 0 | 0 | 0 | +| H2 查询副作用 | `JobSearchQuerySideEffectIntegrationTest` | H2(真实 Flyway) | 4 | 0 | 0 | 0 | +| **MySQL 真实层** | `JobSearchSpecificationMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 15 | 0 | 0 | 0 | +| **MySQL 查询副作用** | `JobSearchQuerySideEffectMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 4 | 0 | 0 | 0 | +| **MySQL 执行计划** | `JobSearchQueryExplainMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 1 | 0 | 0 | 0 | +| 合计(新增 Q01 测试) | | | **54** | **0** | **0** | **0** | + +MySQL 核心用例**真实执行**(Docker 可用,容器实启),`Skipped = 0`,未出现环境性跳过。 + +执行命令(原样): + +```powershell +cd C:\Users\15642\Desktop\zhilai\mydreamjob-server +mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest" test +``` + +定向复验输出:`Tests run: 15, Failures: 0, Errors: 0, Skipped: 0` / `BUILD SUCCESS`。 + +> 首次按负责人指令执行该命令时为 `Tests run: 9, Failures: 0, Errors: 0, Skipped: 0`。随后为达成「与 H2 逐项对账」的完整性,补齐了 MySQL 侧原先缺失的 4 个 H2 已覆盖场景(空白关键词、关键词 trim、多条件 AND、NULL 薪资契约),并在独立复核后增加 city、employmentType 大小写变体 2 个跨库契约用例;该命令现为 **15 条**,仍为 `Failures=0 / Errors=0 / Skipped=0`。 + +数据库配置:`mysql:8.4` 独立容器(`mydreamjob_q01`),执行**正式 Flyway 迁移**并以 `ddl-auto=validate` 启动;JDBC URL / 用户名 / 密码由 `@DynamicPropertySource` 运行期注入,不落盘、不打印。应用级测试开关与测试密钥由测试专用 profile `mysql-it` 提供(`src/test/resources/application-mysql-it.yml`),方言沿用主配置的 `MySQLDialect`——H2 与 MySQL 用例因此跑在**同一套应用配置**上,仅数据库实现不同,便于逐项对账。 + +## 4. 每个过滤条件的实际结果 + +| 条件 | 契约 | 真实库实测结论(H2 与 MySQL 一致) | +|---|---|---| +| `status` | 无论传入什么,只返回 `PUBLISHED` | DRAFT、CLOSED 在任何组合、任何排序下都不出现;显式按 `companyId` 过滤时同样不出现 | +| `keyword` | trim 后忽略大小写,匹配 title / description / requirements 任一字段 | 三字段分别命中;`java` / `JaVa` / ` Java ` 结果相同 | +| `city` | trim 后忽略大小写精确匹配 | `Q01-Beijing`、`q01-beijing` 和带空格的小写查询命中同一岗位;与 `city + "别处"` 区分开 | +| `employmentType` | trim 后忽略大小写精确匹配 | `FULL_TIME`、`full_time` 和带空格的小写查询命中同一岗位;`PART_TIME`、`FULL` 不命中 | +| `companyId` | 精确匹配 | 只返回本企业岗位 | +| `salaryMin` | 岗位 `salaryMax >= salaryMin`(单边) | 边界相等命中;`salaryMax` 更高者也命中;`salaryMax` 低于边界者排除 | +| `salaryMax` | 岗位 `salaryMin <= salaryMax`(单边) | 边界相等命中;`salaryMin` 更低者也命中;`salaryMin` 高于边界者排除 | +| 两端同时给出 | 区间重叠 | 部分重叠命中、被用户区间包含命中;高于或低于用户区间均排除 | +| 多条件 | AND | 关键词/城市/用工类型/企业/薪资必须同时满足 | +| 无结果 | 空页 | `content` 为空、`totalElements = 0` | + +## 5. keyword 三字段、大小写与特殊字符 + +- **title / description / requirements 三字段**:三个只在一个字段含关键词的岗位都能被单独命中,未含关键词的岗位不命中。 +- **大小写**:`lower(title|description|requirements) LIKE lower(pattern)`,`java`、`JAVA`、`JaVa` 命中同一组岗位(MySQL 侧还叠加列排序规则,结论一致)。 +- **trim**:`" Java "` 与 `"Java"` 结果一致。 +- **空值**:`null`、`""`、`" "`、`" "`、`"\t"` 均**不参与过滤**(`isBlank()` 判定),返回该城市下全部岗位。 +- **LIKE 特殊字符**(锁定既有契约,**未做转义改动**): + - `%` 作为通配符,命中该城市下全部带标题的岗位; + - `_` 作为单字符通配符,同样命中全部; + - `A_B` 以通配符解释,命中 `A_B岗` 本身。 + - H2 与 MySQL 行为**一致**。这是既有语义,产品未决策前不改为字面量搜索。 + +## 6. 薪资边界与区间重叠 + +H2 与 MySQL 逐项一致: + +| 用例 | 用户条件 | 期望与实测 | +|---|---|---| +| `salaryMinMatchesWhenJobMaximumEqualsBoundary` | `salaryMin=20000` | 仅 `[10000,19999]` 被排除,`[10000,20000]` 命中(边界相等命中) | +| `salaryMaxMatchesWhenJobMinimumEqualsBoundary` | `salaryMax=10000` | 仅 `[10001,20000]` 被排除,`[10000,20000]` 命中 | +| 部分重叠 | `[15000,25000]` | `[10000,20000]`、`[12000,30000]` 命中 | +| 不重叠 | `[30000,35000]`、`[1000,4000]` | 空 | +| NULL 薪资 | 不加薪资条件 | `[null,null]`、`[10000,null]` 照常返回 | + +NULL 薪资的既有契约(SQL 三值逻辑):一旦给出 `salaryMin`,`job.salaryMax >= ?` 对 `salaryMax IS NULL` 求值为 UNKNOWN,因此 `salaryMax` 为 NULL 的岗位被排除。Q01 **锁定**该行为,不做修改。 + +## 7. PUBLISHED 强制过滤 + +无论是否传其它条件、无论 `sort` 取 `latest` / `relevance` / `salary` / `null`,DRAFT 与 CLOSED 岗位**绝不出现**;即使显式传入 DRAFT 岗位所属的 `companyId` 也不出现。H2 与 MySQL 一致。 + +## 8. latest / relevance / salary 排序 + +| 排序 | 语义 | 实测 | +|---|---|---| +| `latest` / `null` | `createdAt DESC, id DESC` | 按 `created_at` 倒序;`created_at` 相同时按 `id DESC`;`null` 与 `latest` 结果完全一致 | +| `relevance` | 当前与 `latest` 等价(本轮锁定,不改造成新算法) | 与 `latest` 结果完全一致 | +| `salary` | `salaryMax` 非空优先降序;相同值按 `id DESC`;NULL 最后 | `CASE WHEN salary_max IS NULL THEN 1 ELSE 0 END ASC, salary_max DESC, id DESC` 生效,NULL 排在最后;相同 `salaryMax` 按 `id DESC` | + +`salary` 排序走 `JobService.findWithSalarySort` 的 CriteriaQuery + CASE WHEN 路径(真实 `EntityManager`),在 H2 与真实 MySQL 上均通过。 + +## 9. 分页与 total + +- 5 条数据、`size=2` 时:`totalElements = 5`、`totalPages = 3`;第 0 页与第 1 页 ID 顺序与期望逐项一致;三页拼接等于完整期望顺序,**无重复、无遗漏**。 +- 无结果时 `totalElements = 0`。 +- H2 与 MySQL 一致。 + +## 10. H2 与 MySQL 差异 + +初次复验发现 MySQL `utf8mb4_unicode_ci` 与 H2 默认字符串比较规则可能造成 city、employmentType 大小写差异。现已在 Specification 中显式对字段和值使用 `lower`,并在两库增加同名大小写变体测试。两个库在 keyword、city、employmentType、特殊字符、薪资边界、PUBLISHED 过滤、排序、分页和 total 上结果现已逐项一致。 + +过程中在 **MySQL 用例内部**发现并修正了 3 处**用例自身的期望错误**(不是生产缺陷,见第 13 节): + +1. 只给 `city` 条件时,用例原本期望同城但 `PART_TIME` 的岗位不出现——契约只规定城市精确匹配,返回它是正确的; +2. `salaryMin=20000` 时用例原本期望只返回边界岗位,但契约是**单边**比较,`salaryMax=60000 >= 20000` 的岗位同样应命中; +3. `salaryMax=10000` 时同理,`salaryMin=5000 <= 10000` 的岗位同样应命中。 + +修正方式是把期望改为与设计文档第 4 节契约表一致,并**加强**而非放宽断言(补齐两条「区间完全在用户区间之外」的反向断言、补 `doesNotContain` 前缀城市断言)。H2 用例的同名场景夹具本就设计正确(相邻岗位取「刚好越界」值),因此未受影响。 + +## 11. EXPLAIN 摘要 + +真实 MySQL 8.4 上以 `EXPLAIN FORMAT=JSON` 采集,等价 SQL 与 Criteria 路径**绑定同一组参数**,并先断言二者返回**完全相同的 ID 与顺序**(保证 EXPLAIN 的不是一份不存在的语句)。完整原文见 [Q01 MySQL 执行计划证据](q01-job-search-mysql-explain-evidence.md)。 + +| 语句 | query_cost | access_type | 可用索引 | 实际选用 key | rows_examined_per_scan | using_filesort | using_temporary_table | +|---|---|---|---|---|---|---|---| +| 组合过滤 + latest 分页 | 1.35 | `ALL` | `idx_jobs_company_status`、`idx_jobs_status_created`、`idx_jobs_city_status`、`idx_jobs_status_employment_type`、`idx_jobs_status_salary_max` | 未选用 | 1 | true | false | +| latest 分页 | 1.35 | `ALL` | 同上(除 `idx_jobs_company_status`) | 未选用 | 1 | true | false | +| salary 排序(CASE WHEN) | 1.35 | `ALL` | 同上 | 未选用 | 1 | true | false | +| count 查询 | 0.35 | `ALL` | 同上 | 未选用 | 1 | false | false | + +结果规模与 SQL 语句条数(page size = 20): + +| 匹配结果数 | 返回行数 | totalElements | Repository SQL数 | 完整Service SQL数 | +|---|---|---|---|---| +| 1 | 1 | 1 | 1 | 2 | +| 20 | 20 | 20 | 2 | 3 | +| 50 | 20 | 50 | 2 | 3 | + +Spring Data 对首页有优化:首页返回行数小于 `pageSize` 时不再发 count 查询,因此 Repository 路径为 1/2/2 条。完整 Service 路径固定增加一次企业批量查询,为 2/3/3 条;两者均不随结果数量线性增长,不存在逐岗位查询企业的 N+1。 + +**结论与边界**:Q01 夹具刻意做小(每个城市 1 / 20 / 50 条),InnoDB 统计信息在此规模下估算行数为 1、优化器选择全表扫描并做 filesort,**这不构成生产规模的性能结论**。按 Q01 边界不新增、不调整索引;`%keyword%` 前导通配符本身无法走 B-Tree 索引、以及大规模数据下的 `ORDER BY` filesort 成本,记录给 **R01**。 + +## 12. 数据副作用结果 + +用例在 jobs、companies、users、applications、candidate_dimension_scores、notifications、conversations、messages、audit_logs **九张表**上做**行级稳定字段快照**(按主键排序、逐行取显式列出的稳定业务字段,不使用 `SELECT *`、不含 `password_hash` 等凭据列),执行覆盖「全部过滤条件 × 4 种排序 × 分页 × 无结果 × 聚合筛选项」的完整搜索矩阵后再次取快照,逐表逐行逐字段比较: + +- **H2**:前后完全一致; +- **真实 MySQL**:前后完全一致。 + +仅比较 COUNT / SUM / CRC32 无法证明「没有某一行被改写」,故另设**反证测试**(H2 与 MySQL 各跑一遍): + +| 反证用例 | 人为动作 | 要求 | +|---|---|---| +| `snapshotReportsRowFieldUpdate` | 直接改写 `jobs.status` | 必须被发现;把字段与 `updated_at` 一并还原后必须重新完全一致(证明差异定位精确而非「永远不等」) | +| `snapshotReportsInsertedRow` | 插入一行 notification | 必须被发现 | +| `snapshotReportsDeletedRow` | 删除一行 notification | 必须被发现 | + +反证全部通过,说明主证据的「无副作用」结论不是因为比较失效。测试同时断言九张受检表在测试库中**确有行**,避免空快照导致的假通过。 + +## 13. 缺陷与判定 + +定向复验发现测试环境与线上 MySQL 的大小写比较可能不一致。为锁定线上已有的忽略大小写结果并保证跨库一致,生产代码仅修改 `JobSearchSpecification`,对 city 和 employmentType 显式执行 Locale.ROOT 小写归一化;接口、DTO、权限、状态机和 MySQL 既有返回语义均未改变。`JobService`、`JobRepository`、`JobController` 与 `ErrorCode` 保持原样。 + +过程中处理的 2 类**非生产缺陷**问题,均已记录: + +1. **MySQL 用例无法启动上下文**:该类最初未激活任何 profile,导致 `app.jwt.*` 测试密钥缺失、`AppJwtProperties` 校验失败(`JWT_ACCESS_SECRET must be at least 32 characters`)。这是**用例配置缺失**,不是生产缺陷(生产要求 JWT 密钥必须显式配置,行为正确)。修复方式是新增测试专用 profile `mysql-it`,并保持连接参数由 `@DynamicPropertySource` 注入。 +2. **MySQL 用例期望与契约表自相矛盾**:见第 10 节的 3 处,已按契约修正并加强断言。 + +**无待整改生产缺陷。** 上述跨库一致性问题已定向解决并有 H2/MySQL 同名用例证明。 + +## 14. 关联与全量测试 + +| 项目 | 命令 | 结果 | +|---|---|---| +| 岗位关联回归 | `mvn -Dtest="*JobSearch*Test,*Job*Test,*CompanyPublic*Test" test` | **Tests run: 225, Failures: 0, Errors: 0, Skipped: 0** | +| 全量测试 | `mvn test` | **Tests run: 1890, Failures: 0, Errors: 0, Skipped: 0** | +| 干净构建 | `mvn clean verify` | **Tests run: 1890, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS`,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar` | + +关联回归中与 Q01 直接相关的类:`JobSearchSpecificationIntegrationTest` 21、`JobSearchServiceIntegrationTest` 9、`JobSearchSpecificationMySqlIntegrationTest` 15、`JobSearchQuerySideEffectIntegrationTest` 4、`JobSearchQuerySideEffectMySqlIntegrationTest` 4、`JobSearchQueryExplainMySqlIntegrationTest` 1、`JobSearchServiceTest` 37(保留)、`JobServiceTest` 13、`JobFavoriteServiceTest` 10、`CompanyPublicServiceTest` 22。 + +## 15. Failures / Errors / Skipped 与 clean verify + +| 项目 | Tests run | Failures | Errors | Skipped | 构建 | +|---|---|---|---|---|---| +| `mvn test` | **1890** | **0** | **0** | **0** | `BUILD SUCCESS` | +| `mvn clean verify` | **1890** | **0** | **0** | **0** | `BUILD SUCCESS`(含打包,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar`) | + +所有必跑测试 `Failures = 0`、`Errors = 0`、`Skipped = 0`。日志中不存在任何 `Skipped: <非零>` 的测试类。三个 MySQL Testcontainers 用例类在 `mvn clean verify` 中**真实启动容器并执行**(`JobSearchSpecificationMySqlIntegrationTest` 15 条 / `JobSearchQuerySideEffectMySqlIntegrationTest` 4 条 / `JobSearchQueryExplainMySqlIntegrationTest` 1 条),**未发生跳过**。 + +## 16. OpenAPI + +``` +python docs/verify_openapi.py +paths: 176 +operations: 214 +All checks passed! +``` + +`docs/openapi-rc.json` **未被修改**(`git status --short -- docs/openapi-rc.json` 无输出)。Q01 只新增测试,未新增、未删除、未重命名任何 path 或 operation,未改变参数名、默认值、响应 DTO 或错误码。 + +## 17. 精确修改文件 + +**新增(Q01 测试与证据)** + +```text +mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java +mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java +mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java +mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectTestBase.java +mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectIntegrationTest.java +mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectMySqlIntegrationTest.java +mydreamjob-server/src/test/java/com/mydreamjob/job/BusinessRowSnapshot.java +mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQueryExplainMySqlIntegrationTest.java +mydreamjob-server/src/test/java/com/mydreamjob/job/Q01SqlRecorder.java +mydreamjob-server/src/test/resources/application-mysql-it.yml +docs/q01-job-search-real-query-design.md +docs/q01-job-search-real-query-implementation-instruction.md +docs/q01-job-search-real-query-implementation-report.md +docs/q01-job-search-mysql-explain-evidence.md +``` + +**修改(1 个定向生产修复、测试注释与文档)** + +```text +mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java # city/employmentType 跨库大小写语义显式统一 +mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java # 仅补类级 Javadoc,标注为编排测试;断言未改动 +docs/backend-development-roadmap.md +docs/phase1-scope-and-acceptance.md +``` + +## 18. 是否修改生产代码 / 新增迁移或接口 + +- **生产代码:定向修改 1 个文件。** `JobSearchSpecification` 对 city 和 employmentType 显式使用 `lower` + `Locale.ROOT`,保持线上 MySQL 既有忽略大小写结果并消除 H2 差异;`JobService`、`JobRepository`、`JobController`、DTO、状态机与权限未改。 +- **Flyway:未新增。** 最新迁移仍为 V43;V1–V43 全部未修改。 +- **ErrorCode:未新增。** +- **公网接口:未新增、未删除、未重命名。** OpenAPI 保持 176 paths / 214 operations。 +- 未接入 Elasticsearch、缓存或搜索引擎;未进入 R01。 + +## 19. Git 状态 + +``` +git branch --show-current -> test/q01-job-search-specification +git diff --cached --name-only -> (空) +git status --short -- mydreamjob-server/src/main -> M JobSearchSpecification.java(Q01 定向修复) +git status --short -- mydreamjob-server/src/main/resources/db/migration -> (空) +git status --short -- docs/openapi-rc.json -> (空) +``` + +检查点执行前,本分支未暂存、未提交、未推送;未执行 `git add -A`、reset、restore、checkout、clean;未删除或移动任何历史未跟踪文件。Q01 改动由 1 个定向生产修复、真实数据库测试与正式证据文档组成。 + +## 20. 待整改事项 + +**Q01 范围内无待整改缺陷。** + +移交给 **R01**(不在 Q01 修复): + +- `keyword` 使用 `%keyword%` 前导通配符,B-Tree 索引无法用于该谓词; +- 小样本夹具下优化器选择全表扫描 + filesort,真实数据规模下的执行计划与排序成本需在 R01 以真实量级数据评估; +- `JobService.findWithSalarySort` 的 `CASE WHEN` 排序无法利用 `idx_jobs_status_salary_max` 的有序性,需要 R01 评估替代方案。 diff --git a/docs/r01-a-request-context-implementation-instruction.md b/docs/r01-a-request-context-implementation-instruction.md new file mode 100644 index 0000000..5955c68 --- /dev/null +++ b/docs/r01-a-request-context-implementation-instruction.md @@ -0,0 +1,110 @@ +# R01-A 请求身份与 requestId 收束实施指令 + +## 一、任务性质 + +本轮只完成 R01-A 第一批,不进入异常映射、评分规则、百分位、消息未读、PersonalDataCollector 或推荐权重重构。 + +允许修改公共请求上下文组件、最多 8 个 Controller 及其测试和文档;禁止新增接口、ErrorCode、Flyway、表、索引或依赖。 + +## 二、开始前核查 + +在 `C:\Users\15642\Desktop\zhilai` 执行并记录: + +```powershell +git branch --show-current +git status --short +git diff --cached --name-only +git log -5 --oneline +git diff --check +``` + +分支必须为 `refactor/r01-structure-performance`,HEAD 必须包含 Q01 检查点 `77dc624`,暂存区必须为空。完整搜索适用的 AGENTS.md;保护全部历史未跟踪文件。 + +## 三、先写基线清单 + +列出所有私有 `resolveUserId/userId/requireAdmin` 与 `requestId/getRequestId` 方法,记录:文件、公开端点、当前异常、角色检查位置、资源归属检查位置、requestId 来源。 + +第一批只迁移以下 8 个 Controller: + +1. `AssessmentController` +2. `AbilityController` +3. `ProfileController` +4. `ResumeVersionController` +5. `PreferenceController` +6. `CompetitionRankingController` +7. `PersonalDataExportController` +8. `PrivacySettingsController` + +若其中存在与未提交修改重叠,停止并报告,不得改换到其它文件凑数。 + +## 四、身份组件 + +复用现有 `CurrentUserAccessor`,不要创建第二个身份解析器。Controller 通过构造器注入并调用 `requireUserId()`。 + +必须保持: + +- null Authentication、AnonymousAuthenticationToken、`anonymousUser`、未认证、空白主体、Unicode 数字、带正负号、Long 溢出和 userId<=0 全部 401; +- 不回显主体内容、异常类或堆栈; +- 不接受请求参数中的 userId; +- 角色与资源归属仍由原 Service/Controller 的既有位置判断; +- 不将原 403 或 404 改成 401。 + +删除迁移 Controller 内的私有身份解析代码和不再使用的 SecurityContext 依赖。 + +## 五、requestId 组件 + +先完整阅读 `common/logging/RequestIdFilter`、`RequestLoggingFilter`、`AuditContext` 和 `GlobalExceptionHandler`。 + +新增 `common/logging/RequestIdAccessor`,提供 `String get(HttpServletRequest request)`。它必须复用过滤器产出的规范化 requestId;禁止重新生成第二个值,禁止直接信任超长/非法客户端头。 + +若 RequestIdFilter 当前没有 request attribute,允许增加一个常量属性并写入规范化后的值。响应头、MDC、Controller body、AuditContext 与异常响应必须保持同源。过滤器结束后仍清理 MDC。 + +只替换上述 8 个 Controller 的私有 requestId 方法。GlobalExceptionHandler 本轮只允许改为调用 accessor,不得重构 HTTP 状态映射。 + +## 六、必须先新增的测试 + +新增或加强: + +- `RequestIdAccessorTest` +- `RequestContextMigrationIntegrationTest` +- 8 个 Controller 的既有权限回归 + +至少覆盖: + +1. 合法客户端 requestId 原样使用; +2. 缺失、空白、超长、非法字符时使用过滤器规范化值; +3. 响应头、响应 body、MDC 日志与 AuditContext 同一值; +4. 并发请求不串 requestId; +5. 过滤器完成后 MDC 清理; +6. 八类非法 principal 均 401 而非 500; +7. 错误响应不包含 principal; +8. STUDENT/COMPANY/ADMIN 权限矩阵与改动前一致; +9. 拒绝请求无业务写入; +10. 反证:绕过 filter 直接传超长请求头时 accessor 不得返回原值。 + +禁止只 Mock 新组件后断言 Controller 返回 mock 值;至少一组测试必须使用真实 SecurityFilterChain、RequestIdFilter 和 MVC。 + +## 七、量化验收 + +整改前后分别统计: + +```powershell +rg -l "private (Long|long) resolveUserId|private .*userId\(\)" mydreamjob-server/src/main/java +rg -l "private String (getRequestId|requestId)\(" mydreamjob-server/src/main/java +``` + +第一批必须精确减少对应 8 个 Controller 的重复方法,不得声称全仓已完成。报告剩余数量和文件清单。 + +## 八、验证 + +依次运行定向测试、八个模块关联测试、`mvn test`、`mvn clean verify`、`python ..\docs\verify_openapi.py`、`git diff --check`。 + +预期 OpenAPI 保持 176/214;测试数量以实际为准,Failures=0、Errors=0、Skipped=0。 + +## 九、停止条件 + +完成 R01-A 第一批代码、测试和报告后停止。暂存区保持空;不得 commit/push;不得进入 R01-B/C/D/E。 + +最终状态只能写: + +`R01-A 第一批请求上下文收束完成,待独立代码复验。` diff --git a/docs/r01-a-request-context-implementation-report.md b/docs/r01-a-request-context-implementation-report.md new file mode 100644 index 0000000..ccd7952 --- /dev/null +++ b/docs/r01-a-request-context-implementation-report.md @@ -0,0 +1,284 @@ +# R01-A 第一批请求上下文收束实施报告 + +> **状态**:R01-A 第一批请求上下文收束及阻断整改已完成,并通过独立代码复验与干净构建。 + +## 1. 范围与分支 + +| 项目 | 值 | +|---|---| +| 分支 | `refactor/r01-structure-performance` | +| HEAD | `77dc624`(Q01 检查点,见 `fix: align job search queries across databases`) | +| 范围 | 只完成 R01-A 第一批:8 个 Controller 的身份解析与 requestId 收束 | +| 未进入 | R01-B(异常映射)、R01-C(纯计算)、R01-D(查询收束)、R01-E(真实环境复验),以及 `UserRoleGuard`、`PersonalDataCollector`、评分规则、百分位、消息未读、推荐权重 | +| AGENTS.md | 仓库根目录、`mydreamjob-server/` 及其子目录**均未发现** `AGENTS.md` | + +## 2. 开始前核查 + +``` +git branch --show-current -> refactor/r01-structure-performance ✓ +git log -5 --oneline -> HEAD = 77dc624(Q01 检查点) ✓ +git diff --cached --name-only -> (空) ✓ 暂存区为空 +git diff --check -> 仅 LF/CRLF 提示,无空白错误 +``` + +工作区另有一处**与本次无关**的既存未提交修改:`docs/backend-development-roadmap.md`(Q01 行更新 + R01 行)。它不属于 8 个 Controller 的任何文件,不构成 §3 的「重叠」停止条件,本轮未触碰该文件。 + +## 3. 基线清单(整改前) + +### 3.1 第一批 8 个 Controller + +| # | Controller | 公开端点 | 整改前身份异常 | 角色检查位置 | 资源归属检查位置 | requestId 来源 | +|---|---|---|---|---|---|---| +| 1 | `assessment/AssessmentController` | `GET /assessments/types`、`GET /assessments/{type}/questions`、`POST /assessments/attempts`、`GET /assessments/attempts/{id}`、`PATCH .../answers`、`POST .../submit`、`GET .../result`、`GET /assessments/me` | `BusinessException(UNAUTHORIZED)`;非数字主体 `NumberFormatException` → 500 | `AssessmentAttemptService.requireStudent(userId)` | 答卷归属在 `AssessmentAttemptService`(非本人 → 2002/403) | `request.getHeader("X-Request-Id")` | +| 2 | `ability/AbilityController` | `GET /abilities/types`、`POST /abilities/attempts`、`GET /abilities/attempts/{id}`、`PATCH .../answers`、`POST .../submit`、`GET .../result`、`GET /abilities/me` | 同上 | `AbilityAttemptService.requireStudent(userId)` | 答卷归属在 `AbilityAttemptService` | 同上 | +| 3 | `profile/ProfileController` | `GET /profile/me`、`PUT /profile/me`、`POST /profile/me/resume`、`GET /profile/me/resume`、`DELETE /profile/me/resume` | 同上(全限定名写法) | `ProfileService.assertStudentRole(userId)`(Controller 显式调用) | 按当前 userId 取本人资料,无跨用户入参 | 同上 | +| 4 | `resume/ResumeVersionController` | `POST/GET /profile/me/resume-versions`、`GET/PUT/DELETE /profile/me/resume-versions/{id}`、`POST .../{id}/default`、`POST .../{id}/export`、`GET /resume-export-tasks/{taskId}`、`GET .../download` | 同上 | `ResumeVersionService` → `ProfileService.assertStudentRole` | `version.getUserId().equals(userId)`,否则 2002/403 | 同上 | +| 5 | `preference/PreferenceController` | `GET /profile/me/preferences`、`PUT /profile/me/preferences` | 同上(全限定名写法) | `PreferenceService.assertStudentRole` | 键为当前 userId | 同上 | +| 6 | `competition/CompetitionRankingController` | `GET /jobs/{jobId}/competition-ranking` | 同上 | `CompetitionRankingService.assertStudentRole` | 未投递该岗位 → 2002/403;岗位不存在 → 7001/404 | 同上 | +| 7 | `dataexport/PersonalDataExportController` | `POST/GET /profile/me/data-export-tasks`、`GET .../{taskId}`、`GET .../{taskId}/download` | 同上,且已 try/catch `NumberFormatException`(但仍映射为 2001 → HTTP **400**) | 无角色限制(STUDENT/COMPANY/ADMIN 均可操作本人任务) | `task.getUserId().equals(userId)`,否则 2002/403 | 同上 | +| 8 | `privacy/PrivacySettingsController` | `GET /profile/me/privacy`、`PUT /profile/me/privacy` | 同上 | `PrivacySettingsService.assertStudentRole` | 键为当前 userId | 同上 | + +**共性**:8 个类各自持有一份 `private Long resolveUserId(HttpServletRequest)` 与一份 `private String getRequestId/requestId(HttpServletRequest)`,共 16 个重复方法;身份只从 `SecurityContextHolder` 取,requestId 只从请求头取。 + +### 3.2 全仓量化基线 + +```powershell +rg -l "private (Long|long) resolveUserId|private .*userId()|private .*requireAdmin" mydreamjob-server/src/main/java # 42 个文件 / 44 处 +rg -l "private String (getRequestId|requestId)\(" mydreamjob-server/src/main/java # 46 个文件 / 46 处 +``` + +## 4. 身份组件(复用,不新建) + +沿用既有 `security/CurrentUserAccessor`,**未创建第二个身份解析器**。8 个 Controller 通过构造器注入(`@RequiredArgsConstructor` 的 `final` 字段)并调用 `requireUserId()`。 + +保持不变的语义: + +- `null` Authentication、未认证、`AnonymousAuthenticationToken`、主体为 `anonymousUser`、空白主体、Unicode 数字(`٤٢`)、带正负号(`+42` / `-7`)、Long 溢出(`99999999999999999999`)、`userId <= 0` —— 全部按未登录处理; +- 异常文本固定为 `authentication required`,**不回显主体、异常类或堆栈**; +- 不接受请求参数中的 `userId`(未新增任何 userId 入参); +- 角色与资源归属仍留在原 Service / Controller 位置判断,**未从 Controller 上移到身份组件**; +- 未把任何原 403 或 404 改成 401。 + +### 4.1 一处必须说明的既有差异(未扩大) + +整改前,8 个 Controller 的私有解析在非法主体上抛 `BusinessException(UNAUTHORIZED)`;由于 `GlobalExceptionHandler.resolveHttpStatus` 未把 `UNAUTHORIZED(2001)` 列入任何状态段,它落到「其余 → 400」,即**非法主体此前是 HTTP 400**(`PersonalDataExportController` 亦然)。 + +整改后统一为 `InsufficientAuthenticationException` → `AuthenticationException` 处理器 → **HTTP 401**。这与 R01 设计第 4 节「非法主体为 401」一致,也正是本批的目标;对**真实请求**而言该路径此前不可达(`SecurityConfig` 对相关路径要求 `authenticated()`,匿名请求在过滤器链就被拒绝并返回 401),因此没有改变任何真实客户端可观察的既有契约。唯一受影响的单元测试 `ResumeControllerTest.uploadResume_unauthenticated_shouldReturn401` 已改为断言新的 401 语义(断言未放宽)。 + +## 5. requestId 组件 + +### 5.1 新增 `common/logging/RequestIdAccessor` + +```java +public String get(HttpServletRequest request) // 唯一入口 +``` + +取值顺序: + +1. `RequestIdFilter` 写入的请求属性 `RequestIdFilter.REQUEST_ID_ATTRIBUTE`(规范化后的值); +2. 否则读请求头 `X-Request-Id` —— 过滤器已用请求包装器把它替换为同一个规范化值;若调用路径绕过了过滤器,则用**过滤器同一处** `RequestIdFilter.normalizeRequestId(...)` 收敛,**不会**把超长或非法客户端头原样返回。 + +没有第二套生成规则:缺失/空白时复用的就是过滤器那一处的逻辑。独立复验进一步收紧合法字符为 ASCII `A-Z/a-z/0-9/./_/:/-`;任何含空格、控制字符、中文、HTML 符号、斜杠或其它字符的值都会被整体替换为服务端生成的 16 位十六进制 ID,不进入响应头、MDC 或日志。 + +### 5.2 `RequestIdFilter` 的改动 + +- 新增公开常量 `REQUEST_ID_ATTRIBUTE = "requestId"`,并把**规范化后**的值写入请求属性; +- MDC、响应头、请求包装器、请求属性四者写的是同一个字符串; +- 常量名与原私有名一致,MDC key 未变,日志格式不受影响; +- 过滤器 `finally` 中仍然 `MDC.remove(...)`。 + +### 5.3 `GlobalExceptionHandler` + +只把私有 `getRequestId(request)` 的实现改为经 `RequestIdAccessor` 取值,**未重构任何 HTTP 状态映射**。 + +独立复验发现初版为兼容 `@WebMvcTest` 使用了可选字段注入和 `new RequestIdAccessor()` 兜底,这会掩盖正式应用的装配缺陷,因此判定为阻断并已整改。最终实现使用 Lombok `@RequiredArgsConstructor` 对 `RequestIdAccessor` 做强制构造器注入;测试切片在测试侧显式提供 Bean,生产代码不存在空依赖或手工实例兜底。 + +`AuditContext.from(request)` 未改动:它读的请求头已被过滤器包装器规范化,与响应头、MDC、响应体同源。 + +## 6. 8 个 Controller 的迁移方式 + +- 新增两个 `final` 字段 `currentUserAccessor` / `requestIdAccessor`; +- 调用点由 `resolveUserId(request)` → `currentUserAccessor.requireUserId()`,`requestId(request)` / `getRequestId(request)` → `requestIdAccessor.get(request)`; +- 删除各自的私有身份解析方法与不再使用的 `SecurityContextHolder` / `Authentication` / `BusinessException` / `ErrorCode` 导入; +- 逐点人工核对,未做机械全仓替换;每个 Controller 的公开端点、角色检查与归属检查位置均未移动。 + +## 7. 量化验收(整改前 → 整改后) + +| 指标 | 整改前 | 整改后 | 变化 | +|---|---|---|---| +| 身份解析私有方法:文件数 / 处数 | 42 / 44 | 35 / 37 | −7 / −7(净额,见下) | +| requestId 私有方法:文件数 / 处数 | 46 / 46 | 38 / 38 | **−8 / −8** | +| 8 个 Controller 内的上述私有方法 | 16 处 | **0 处** | −16 | + +requestId 指标精确减少 8 个文件,正好对应本批 8 个 Controller。 + +身份指标的净额是 −7 而非 −8:本批 8 个 Controller 各减少 1 处(共 −8),同时经负责人明确授权修复的 `DeletionGuardInterceptor` **新增**了 1 个私有辅助方法 `resolveUserIdOrNull`(见第 10 节),−8 + 1 = −7。该辅助方法是有意保留的(把「非法主体 → 放行」这一契约写在一处并加注释),不为迁就统计口径而内联。 + +### 7.1 剩余数量与文件清单(**未**声称全仓完成) + +仍有 **35 个文件 / 37 处**私有身份解析、**38 个文件 / 38 处**私有 requestId 方法,属后续批次。除本批 8 个外的主要剩余: + +```text +application/ApplicationController interview/InterviewController +bidirectionalmatch/ApplicationMatchController interviewscore/InterviewScoreController +bidirectionalmatch/MatchConversationController job/FavoriteController +bidirectionalmatch/MatchRuleController match/MatchController +candidatescore/CandidateScoreCardController messaging/MessagingController +candidatescore/CandidateScoringController notification/NotificationController +candidatescore/dashboard/CompanyScoringDashboardController offer/OfferController +candidatescore/governance/CandidateScoringGovernanceController recommendation/JobRecommendationController +careerprogress/CareerProgressController recommendation/JobRecommendationService +company/CompanyController review/AdminReviewController +deletion/AccountDeletionController review/AdminReviewReplyController +feedback/CandidateFeedbackController review/CompanyReviewManagementController + review/JobReviewController + review/StudentReviewWeightController + risk/AdminRiskController + safety/AdminReportController + safety/BlockController + safety/ReportController + scoringmonitoring/FairnessMonitoringController + talentdiscovery/TalentDiscoveryController + talentinvitation/TalentInvitationController + verification/CompanyVerificationController +common/exception/GlobalExceptionHandler(requestId,本轮已改为调用 accessor) +controller/ApiController、controller/AuthController、controller/SystemController、job/JobController(requestId) +``` + +## 8. 新增/加强的测试 + +| 测试类 | 类型 | Tests run | 覆盖 | +|---|---|---|---| +| `common/logging/RequestIdAccessorTest`(新增) | 纯单元(真实 `RequestIdFilter`) | 16 | 合法值原样使用;缺失/空白/刚好超一位/远超上限;空格、制表符、换行、回车、Unicode、HTML/路径/标点等短非法字符;**反证:绕过过滤器传 400 字符头不得返回原值**;请求属性优先于请求头;空白属性回退;null request | +| `common/logging/RequestIdFilterTest`(加强) | 纯单元 | 3 | 追加断言:规范化值同时写入请求属性,且与响应头一致 | +| `common/logging/RequestContextMigrationIntegrationTest`(新增) | **真实 SecurityFilterChain + RequestIdFilter + MVC** | 8 | 见下 | +| `deletion/DeletionGuardInterceptorTest`(加强) | 真实过滤链 + MVC | 7 | 新增非法/溢出主体回归 | +| `profile/ResumeControllerTest`(适配) | 单元 | 12 | 注入真实 `CurrentUserAccessor` / `RequestIdAccessor`;未登录断言改为新 401 语义 | +| `common/exception/GlobalExceptionHandler*Test`(适配) | 单元 | 1 / 23 / 10 | 测试侧显式传入真实 `RequestIdAccessor`;生产类不保留无参构造、可选注入或手工兜底 | + +`RequestContextMigrationIntegrationTest` 逐项覆盖任务书第六节: + +1. 合法客户端 requestId 原样使用 —— 在 8 个 Controller 的真实入口上逐一断言; +2. 缺失、空白、超长(120 字符)时使用过滤器规范化值; +3. **响应头、响应体 `requestId`、MDC、`AuditContext` 四者同值** —— MDC 与 `AuditContext` 由一个测试专用 `HandlerInterceptor` 在 MVC 派发过程中抓取,而不是只看过滤器自身; +4. 并发 8 个请求各有独立 requestId,互不串号; +5. 请求结束后 `MDC.get("requestId")` 为 `null`; +6. 8 类以上非法 principal 全部 **401 而非 5xx**(匿名令牌、`anonymousUser`、空白、非数字、Unicode 数字、`+42`、`-7`、`0`、`-1`、Long 溢出),另加「完全无凭据」401; +7. 错误响应不含 principal、`NumberFormatException` 或 `at com.mydreamjob` 堆栈; +8. 8 个 Controller 的公开端点与既有权限回归(见第 9 节 525 条关联回归); +9. 被拒绝的请求对 6 张业务表零写入; +10. **反证**:绕过过滤器直接传超长请求头时 accessor 不得返回原值(`RequestIdAccessorTest.overlongHeaderIsBoundedEvenWhenFilterIsBypassed`)。 + +**未只 Mock 新组件后断言 mock 值**:`RequestContextMigrationIntegrationTest` 与 `DeletionGuardInterceptorTest` 均使用真实 `SecurityFilterChain`、真实 `RequestIdFilter`、真实 MVC 与真实 JWT 会话。 + +## 9. 验证结果 + +| 步骤 | 命令 | 结果 | +|---|---|---| +| 定向测试 | `mvn -Dtest="CompanyControllerMvcTest,GlobalExceptionHandler*Test,RequestContextMigrationIntegrationTest,RequestIdAccessorTest,RequestIdFilterTest,DeletionGuardInterceptorTest,ResumeControllerTest" test` | **Tests run: 87, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS` | +| 八个模块关联回归 | `mvn -Dtest="*Assessment*Test,*Ability*Test,*Profile*Test,*Resume*Test,*Preference*Test,*Competition*Test,*DataExport*Test,*Privacy*Test,*Deletion*Test,*RequestId*Test,*RequestContext*Test,*GlobalExceptionHandler*Test,*CurrentUserAccessorTest" test` | **Tests run: 525, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS` | +| 全量测试 | `mvn test` | 见第 11 节 | +| 干净构建 | `mvn clean verify` | 见第 11 节 | +| OpenAPI | `python ..\docs\verify_openapi.py` | 见第 12 节 | +| 空白检查 | `git diff --check` | 仅 LF/CRLF 提示,无空白错误 | + +## 10. 越界生产缺陷(已获授权,已修复) + +测试在**先于修复**建立时捕获了一个真实生产缺陷,位置超出 §1 白名单,已单独取得负责人明确授权后修复。 + +**缺陷**:`deletion/DeletionGuardInterceptor` 注册在 `/**`([WebMvcConfig.java:20](mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java#L20)),先于**所有** Controller 执行。它在 `preHandle` 里用 `Character::isDigit` 过滤主体后直接 `Long.valueOf(auth.getName())`: + +- 主体为 `"99999999999999999999"`(Long 溢出)→ `NumberFormatException` 逃逸 → **全站 HTTP 500**,且在任何 Controller 之前; +- 同一检查下 `Character.isDigit` 接受 Unicode 数字,只是因为 `Long.parseLong` 恰好也接受 Unicode 数字才没有一并炸掉。 + +**证据**(修复前):`RequestContextMigrationIntegrationTest.illegalPrincipalsReturn401InsteadOf500AndNeverEchoThePrincipal` 在「Long 溢出主体」上得到 `expected: 401 but was: 500`,堆栈为 `java.lang.NumberFormatException: For input string: "99999999999999999999"` at `DeletionGuardInterceptor.preHandle(DeletionGuardInterceptor.java:60)`。 + +**修复**:该拦截器的身份解析改为复用唯一的 `CurrentUserAccessor`;非法/溢出主体一律视为未认证并**放行**,交给 Controller 统一返回 401。独立复验将初版的 `ObjectProvider` 进一步改为强制构造器注入,装配缺失时应用失败启动,不允许安全依赖静默缺失。注销守卫本身的 9302 / 9303 语义、白名单路径与 OPTIONS 放行**均未改动**(`DeletionGuardInterceptorTest` 7 条全绿,含 4 条原有拦截语义用例)。 + +这条修复是 §1 白名单之外的第 9 个生产文件,**在此明确标出**,未混入 8 个 Controller 的改动叙述。 + +## 11. 全量测试与干净构建 + +| 项目 | Tests run | Failures | Errors | Skipped | 构建 | +|---|---|---|---|---|---| +| 独立整改定向复验 | **75** | **0** | **0** | **0** | `BUILD SUCCESS` | +| `mvn clean verify` | **1915** | **0** | **0** | **0** | `BUILD SUCCESS`(含打包,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar`) | + +三个日志中均不存在任何 `Skipped: <非零>` 的测试类。 + +期间发生过两次真实失败并已修正:实施阶段首次全量测试暴露 `CompanyControllerMvcTest` 未提供 `RequestIdAccessor`;最终处理是在该测试切片显式提供依赖,而不是弱化生产装配。独立整改第一次定向运行又因新增参数化测试同时残留 `@Test` 而产生 1 个 ParameterResolution Error;删除多余注解并补齐换行、回车边界后,同一集合 75/75 通过。两次失败均未通过删除断言或生产兜底绕过。 + +## 12. OpenAPI + +``` +python docs/verify_openapi.py +paths: 176 +operations: 214 +All checks passed! +``` + +`docs/openapi-rc.json` **未被修改**(`git status --short -- docs/openapi-rc.json` 无输出)。本批未新增、删除或重命名任何 path / operation,未改变参数名、默认值、响应 DTO 或错误码;8 个 Controller 的 Swagger 注解一律原样保留。 + +## 13. 修改文件清单 + +**新增** + +```text +docs/r01-a-request-context-implementation-report.md +mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java +mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java +mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java +``` + +**修改(生产,共 11 个文件:8 个 Controller + 过滤器 + 异常处理器 + 1 个经授权的越界修复)** + +```text +mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java +mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java +mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java +mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java +mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java +mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java +mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java +mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java +mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java # 新增 request attribute 常量并写入 +mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java # 改为调用 accessor +mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java # 越界缺陷修复(已授权) +``` + +**修改(测试)** + +```text +mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdFilterTest.java +mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java +mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java +mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java +mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java +mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java +mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java +``` + +**未修改**:未新增接口、`ErrorCode`、Flyway、表、索引或依赖;V1–V43 未改动;`docs/openapi-rc.json` 未改动。 + +## 14. Git 状态 + +``` +git branch --show-current -> refactor/r01-structure-performance +git diff --cached --name-only -> (空) +git status --short -- mydreamjob-server/src/main -> 11 个 M + 1 个 ?? +git status --short -- mydreamjob-server/src/main/resources/db/migration -> (空) +git status --short -- docs/openapi-rc.json -> (空) +git diff --check -> 仅 LF/CRLF 提示,无空白错误 +``` + +工作区中另有一处**先于本轮就存在**的未提交修改 `docs/backend-development-roadmap.md`(Q01 行更新 + R01 行),本轮未触碰。 + +本批**未暂存、未提交、未推送**;未执行 `git add -A`、commit、push、reset、restore、checkout、clean;未删除或移动任何历史未跟踪文件。所有 R01-A 产物均为未跟踪新文件或仅本轮修改的已跟踪文件。 + +## 15. 未做的事 + +- 未进入 R01-B / R01-C / R01-D / R01-E; +- 未新增 `UserRoleGuard`(角色授权按设计要求留在后续批次,本批未把角色判断与身份解析混在一起); +- 未改动 `AuditContext`、`RequestLoggingFilter`(后者读的请求头已被过滤器包装器规范化,与其余输出同源); +- 未改动 `docs/backend-development-roadmap.md` 与 `docs/phase1-scope-and-acceptance.md`; +- 未新增接口、`ErrorCode`、Flyway、表、索引或依赖。 diff --git a/docs/r01-a2-independent-verification-20260917.md b/docs/r01-a2-independent-verification-20260917.md new file mode 100644 index 0000000..60001cc --- /dev/null +++ b/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")`) | +| 非法字符(``、`a b` | 生成合法替代值;响应头不含 `<`/`>`/空格;响应体 == 响应头 ✔ | +| 已存在规范化 request attribute | **attribute 优先于 header** ✔ | +| CR / LF / CRLF(直接经 Accessor 验证) | 不进入规范化结果 ✔ | + +覆盖 8 个端点的全部上述输入组合。 + +--- + +## 8. 测试数量与 OpenAPI + +| 命令 | 结果 | +|---|---| +| A3 定向(`R01A3…,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **38 / 38**,F=0 E=0 **S=0** | +| 关联回归(`*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test,*Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test`) | **251 / 251**,F=0 E=0 **S=0**(1 项既有污染详见 §9) | +| `mvn test` | **2207 / 2207**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | +| `mvn clean verify` | **2207 / 2207**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | +| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | + +- 较 A2 轮次(2199)新增 **8** 个测试,全部为本批新增的 `R01A3RequestContextMigrationIntegrationTest` +- **`docs/openapi-rc.json` 零修改** ✔ +- **未新增公网接口、迁移、表、索引或 ErrorCode** ✔ + +--- + +## 9. 一处既有测试污染(非本批引入,已用基线 worktree 证伪) + +关联回归首次运行出现 1 个 Error: + +``` +AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228->createUser:75 +DataIntegrityViolation: USERS(PHONE) 唯一键冲突 '930000000' +``` + +**归因验证**:用 `git worktree add --detach HEAD`(HEAD 不含本批改动)在**基线代码**上运行**完全相同**的测试批次: + +``` +基线(HEAD):Tests run: 223, Failures: 0, Errors: 1 ← 完全相同的失败 +``` + +**结论**:该失败是**既有的测试间交叉污染**(`AccountDeletionControllerTest` 的 `PHONE` 为 static 计数器、基数 `930000000`,配合共享 H2 库 `integrationdb`,在特定测试子集顺序下与其他类残留数据冲突),**与本批改动无关**。 + +- 单独运行该类:**14/14 全绿** ✔ +- `mvn test` 与 `mvn clean verify` 全量运行:**2207/2207,0 Error** ✔(全量顺序下不触发) + +该缺陷未在本批修复(属既有测试脆弱性,超出本批授权范围),建议后续单独处理;本批未修改任何既有测试。 + +--- + +## 10. 精确修改文件 + +**生产代码(8 个,全部在任务书指定清单内)** + +``` +mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java +mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java +mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java +mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java +mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java +mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java +mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java +mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java +``` + +(`8 files changed, 100 insertions(+), 154 deletions(-)`) + +**测试(新增 1 个)** + +``` +mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.java +``` + +**文档(1 个)** + +``` +docs/backend-development-roadmap.md # 仅更新顶部 R01 状态表,未重写历史章节 +``` + +**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter 顺序、`UserRoleGuard`、既有测试、"匿名 401 无 requestId"观察项。 + +--- + +## 11. Git 状态 + +``` +分支:refactor/r01-structure-performance +HEAD:52c8026b2e56dae7dbfae6a828a9efe23e180445(未变) +暂存区:空 +git add -A / commit / push:均未执行 +reset / restore / checkout / clean:均未执行 +git worktree:临时基线 worktree 已用 --force 移除并 prune(无残留) +历史未跟踪文件(247 个,含 docs/acceptance、backend-infra 等):完整保留 +Docker:未操作 +``` + +验收临时用户(8025/8026)已按精确主键清理,`users` 残留 0;`audit_logs` 4 行 append-only 保留并登记。 + +--- + +## 12. 明确声明 + +- **未暂存、未提交、未推送。** +- 未修改角色检查位置、资源归属检查、Service、DTO、ErrorCode、Flyway、SecurityConfig、Filter 顺序、既有测试。 +- 未进入 `UserRoleGuard`;未处理"匿名 401 无 requestId"观察项。 +- 未使用 `@Autowired(required=false)`、`@Nullable`、`ObjectProvider` 或 `new Accessor()` 回退;测试切片使用真实 `SecurityFilterChain` 与真实 Accessor Bean。 +- 全仓剩余身份/requestId 解析数量已如实报告(含 7 而非 8 的差异说明),未声称全仓完成。 + +--- + +> **R01-A3第三批请求身份与requestId收束完成,待独立代码复验。** diff --git a/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/docs/r01-a5-independent-verification-20260917.md b/docs/r01-a5-independent-verification-20260917.md new file mode 100644 index 0000000..f70c44f --- /dev/null +++ b/docs/r01-a5-independent-verification-20260917.md @@ -0,0 +1,279 @@ +# R01-A5 独立代码复验报告(第五批:8 个 Controller 请求身份与 requestId 收束) + +日期:2026-09-17 +分支:`refactor/r01-structure-performance` +HEAD:`0226977063243fce101e3f6c8dcf66492d306ef7`(A4 后最新提交,复验全程未变) +复验对象:`docs/r01-a5-request-context-migration-report.md` +性质:**仅复验** —— 未修改生产代码、未修改既有测试断言、未暂存、未提交、未推送 + +--- + +## 0. 结论 + +> **R01-A5独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** + +**FAIL = 0;SKIP = 0。未发现生产缺陷。** + +--- + +## 1. 开始前状态 + +| 检查项 | 结果 | +|---|---| +| 分支 | `refactor/r01-structure-performance` ✔ | +| HEAD | `0226977063243fce101e3f6c8dcf66492d306ef7`(A4 后最新)✔ | +| 与上游同步 | `0 / 0` ✔ | +| 暂存区 | 空 ✔ | +| `git diff --check` | 无空白错误 ✔ | +| 工作区改动 | **仅 8 个 A5 Controller + 路线图**,无 A5 之外的生产代码改动 ✔ | +| 历史未跟踪文件 | 全部保留 ✔ | +| reset/restore/checkout/clean、`git add -A`、Docker | 均未执行 ✔ | + +### `GlobalExceptionHandler.java` 三方 blob 比对 + +| 来源 | blob | +|---|---| +| worktree(`git hash-object`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` | +| index(`git ls-files -s`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` | +| HEAD(`git rev-parse HEAD:`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` | + +**三者完全一致 → 纯 Windows stat 缓存现象,未触碰该文件** ✔ + +--- + +## 2. 8 个 Controller 静态审查 + +| Controller | 代表端点 | Accessor 调用(auth / reqId) | +|---|---|---| +| CandidateScoringGovernanceController | `GET /profile/me/candidate-scoring/appeals` | 9 / 9 | +| InterviewScoreController | `GET /interviews/{id}/score` | 4 / 4 | +| FavoriteController | `GET /jobs/favorites` | 3 / 3 | +| AdminReviewController | `GET /admin/reviews/pending` | 3 / 3 | +| AdminReviewReplyController | `GET /admin/review-replies/pending` | 2 / 2 | +| CompanyReviewManagementController | `GET /companies/me/job-reviews` | 4 / 4 | +| JobReviewController | `DELETE /jobs/{jobId}/reviews/{reviewId}` | 3 / 7 | +| StudentReviewWeightController | `GET /profile/me/review-weights` | 3 / 4 | + +(`JobReviewController` / `StudentReviewWeightController` 的 reqId 调用数高于 auth 属正常 —— 部分只读端点只需 requestId。) + +**改动性质**:`8 files changed, 101 insertions(+), 198 deletions(-)`,删除的 `ErrorCode.UNAUTHORIZED` 共 10 处,**全部来自被移除的私有解析方法**;**角色检查(`assertAdminRole`/`assertCompanyOwner`/`assertStudentRole`)、资源归属、`FORBIDDEN`/`NOT_FOUND` 及 Service 调用顺序未删未增、未移动** ✔ + +**`JobReviewController.parseIds` 完整保留** ✔ —— 它解析 `ids` 查询参数(业务参数,非认证主体),并以 `catch (NumberFormatException) → BAD_REQUEST` 处理,属正确的业务参数语义,未被误删或误判。 + +--- + +## 3. 禁止模式扫描 + +任务书指定模式在 8 个文件中的命中:**全部为 0** ✔ + +覆盖:`SecurityContextHolder`、`AnonymousAuthenticationToken`、`anonymousUser`、`auth.getName()`、`authentication.getName()`、`Long.(valueOf|parseLong)(...(auth|authentication)`、`private (resolveUserId|getRequestId|requestId)(`、`getHeader(...X-Request-Id|REQUEST_ID_HEADER)`、`@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`new CurrentUserAccessor`、`new RequestIdAccessor` + +--- + +## 4. 构造器注入与无回退证明 + +8 个 Controller 均声明: + +```java +private final CurrentUserAccessor currentUserAccessor; +private final RequestIdAccessor requestIdAccessor; +``` + +- 全部为 **`private final` 构造器注入**(Lombok `@RequiredArgsConstructor`)✔ +- **无** `@Autowired(required = false)` / `@Nullable` / `ObjectProvider` / `new CurrentUserAccessor()` / `new RequestIdAccessor()` 回退 ✔ +- 身份统一为 `currentUserAccessor.requireUserId()`;requestId 统一为 `requestIdAccessor.get(request)` ✔ + +--- + +## 5. 独立接线变异测试(Accessor 真实生效的证明) + +新增 `A5AccessorWiringIndependentTest`(4 例),**不复用实施者断言**,使用 `@MockBean` 替换 `CurrentUserAccessor`、`RequestIdAccessor` 与 8 个 Service: + +| 用例 | 证明 | +|---|---| +| `allEightEndpoints_useAccessorUserId` | mock 返回 `4242L` → 逐一验证 **8 个 Service 均收到 `4242L`**(`listMyAppeals` / `getScore` / `listFavorites` / `listPendingReviews` / `listPending` / `listMine` / `deleteReview` / `getMyWeights`),并 `verify(currentUserAccessor, times(8)).requireUserId()` | +| `illegalIdentity_rejectedBeforeService` | mock 抛 `InsufficientAuthenticationException` → 8 端点全部 401、无 500,且各 Service **零调用** | +| `responseBodyRequestId_comesFromAccessor` | mock 返回 `a5-independent-request` → 8 个端点响应体 `requestId` 均为该值,`verify(requestIdAccessor, times(8)).get(...)` | +| `noToken_is401` | 无 Token → Security 链 401 | + +**变异有效性**:若删除任一 Controller 中的 `currentUserAccessor.requireUserId()` 或 `requestIdAccessor.get(request)` 调用,对应 `verify` 与断言必然失败 —— 即**删除 Accessor 调用会使本测试失败** ✔ + +> 说明:响应头 `X-Request-Id` 由 `RequestIdFilter` 设置,**不来自 Controller**;本测试只断言**响应体**的 `requestId` 来源于 Accessor,未错误声称响应头来源。 + +--- + +## 6. 八端点非法主体结果(独立身份边界复验) + +`A5IndependentVerificationTest` 使用**真实 `CurrentUserAccessor` 与真实 `SecurityFilterChain`**(不 mock Accessor): + +| 主体 | 结果 | +|---|---| +| `anonymousUser` | **401** ✔ | +| `not-a-number` | **401** ✔ | +| `99999999999999999999`(20 位溢出) | **401** ✔ | +| `0` | **401** ✔ | +| `-1` | **401** ✔ | +| `AnonymousAuthenticationToken` / 无 Token | **401** ✔ | + +逐端点断言(7 个 GET 端点 + `DELETE /jobs/1/reviews/1`): +- **HTTP 401,无 500** ✔ +- **业务 Service 零调用** ✔(`verify(..., never())`) +- **不回显非法主体** —— 仅解析 JSON 后检查 `code`/`message` 稳定字段,**未对整段 JSON 做数字搜索** ✔ +- 响应不包含 `Exception` / `java.` / `principal` / `anonymousUser` / `com.mydreamjob` / `NumberFormat` ✔ + +**未使用**任何捕获 `NumberFormatException` 的方式"修复"测试。 + +--- + +## 7. 合法主体传入 Service 的 userId 证据 + +§5 的 `verify` 系列即为证据:mock Accessor 返回 `4242L` 时,**8 个 Service 各自收到 `4242L`**(逐一 `eq(4242L)` 断言)✔ + +同时 `A5IndependentVerificationTest#legalPrincipal_reachesService` 使用**真实 Accessor**(数字主体 `"7"`)验证 8 端点**不被身份层判为未登录**(非 401)✔ + +--- + +## 8. requestId Accessor 接线证据 + +见 §5 第 3 项:mock `RequestIdAccessor#get` 返回固定值 → 8 个端点(7 个 GET + 1 个 DELETE,均返回 `ApiResponse`)响应体 `requestId` 均为 `a5-independent-request`,并 `verify(requestIdAccessor, times(8)).get(any(HttpServletRequest.class))` ✔ + +--- + +## 9. 401 / 403 / 404 分层保持 + +| 检查项 | 结果 | +|---|---| +| 静态审查:角色/归属检查是否被删改或移动 | **否**(`git diff` 确认未删未增)✔ | +| `FORBIDDEN` / `NOT_FOUND` 错误码 | **未修改** ✔ | +| `ErrorCode.java` | **零改动** ✔ | +| 合法主体不被判为未登录 | ✔(§7) | + +**401 与 403/404 的分层未被统一**:身份失败为 401,角色/归属失败仍由既有 Service 逻辑产生 403/404,语义未漂移。 + +--- + +## 10. 失败请求的业务副作用 + +新增 `A5SideEffectSnapshotTest`(2 例),使用 H2 **行级快照**(显式列 + 稳定 `ORDER BY`,**逐字段**比较,**非 COUNT/SUM**): + +快照覆盖 9 张核心表:`JOB_FAVORITES`、`JOB_REVIEWS`、`COMPANY_REVIEW_REPLIES`、`INTERVIEW_SCORES`、`CANDIDATE_SCORING_APPEALS`、`STUDENT_REVIEW_WEIGHT_PREFERENCES`、`NOTIFICATIONS`、`MESSAGES`、`AUDIT_LOGS`。 + +| 用例 | 结果 | +|---|---| +| 非法身份请求(`anonymousUser` + `not-a-number` × 7 端点 + DELETE)前后 | **9 张表逐字段零变化** ✔ | +| 无 Token 请求前后 | **9 张表逐字段零变化** ✔ | + +**结论**:非法身份请求不写收藏、评价、回复、面试评分、治理申诉、权重;不发通知;不发 RabbitMQ 消息;不写审计成功记录(`AUDIT_LOGS` 零变化)✔ + +> 本轮**未启动 Docker**,以上为 H2 独立复验结论;**不声称真实 MySQL 结论**。 + +--- + +## 11. 测试数 + +| 命令 | 结果 | +|---|---| +| `mvn -Dtest="A5AccessorWiringIndependentTest,A5IndependentVerificationTest" test` | **8 / 8**,F=0 E=0 **S=0** | +| `mvn -Dtest="A5SideEffectSnapshotTest" test` | **2 / 2**,F=0 E=0 **S=0** | +| A5 定向(`R01A5…,A5*Test,` 8 个 Controller 测试 + 3 个 Accessor/Filter 测试) | **98 / 98**,F=0 E=0 **S=0**,BUILD SUCCESS | +| `mvn clean verify` | **2255 / 2255**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | + +- 实施基线 2245 → 复验后 **2255**(新增 **10** 个独立测试:4 + 4 + 2),**不低于基线** ✔ +- 未见新的基线测试污染 ✔ + +--- + +## 12. OpenAPI + +`python docs\verify_openapi.py` → **176 paths / 214 operations**,All checks passed ✔ + +**`docs/openapi-rc.json` 零修改** ✔ + +--- + +## 13. Flyway / ErrorCode / DTO / 接口 + +| 项 | 结果 | +|---|---| +| V1–V45 迁移 | **零修改**(`git diff --name-only -- db/migration` 为空)✔ | +| `ErrorCode.java` | **零改动** ✔ | +| DTO | 零改动 ✔ | +| 公网接口 / OpenAPI | 零新增零修改 ✔ | +| 新增表 / 索引 | 无 ✔ | + +--- + +## 14. 全仓剩余统计(逐文件分类) + +**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`)—— **4 文件** + +| 分类 | 文件 | +|---|---| +| 基础设施(3) | `security/CurrentUserAccessor`(Accessor 自身)、`auth/JwtAuthenticationFilter`(认证过滤器)、`deletion/DeletionGuardInterceptor`(拦截器) | +| **业务 Controller(1)** | `scoringmonitoring/FairnessMonitoringController` | +| 误报 | 0 | + +**requestId 解析**(含 `X-Request-Id` 直读、`RequestIdFilter.REQUEST_ID_HEADER`、`String (getRequestId|resolveRequestId|requestId)(`)—— **10 文件** + +| 分类 | 文件 | +|---|---| +| 基础设施 / 非业务入口(5) | `audit/AuditContext`、`common/exception/GlobalExceptionHandler`、`common/logging/RequestIdAccessor`、`common/logging/RequestLoggingFilter`、`deletion/DeletionGuardInterceptor` | +| **业务 Controller(5)** | `controller/ApiController`、`controller/AuthController`、`controller/SystemController`、`job/JobController`、`scoringmonitoring/FairnessMonitoringController` | +| 误报 | 0 | + +**与路线图记数的一处差异(如实记录)**:路线图 R01 行记「全仓 requestId 11 文件 = 基础设施/非业务入口 6 + 业务 5」。本次独立复验用任务书指定模式实测为 **10 文件 = 基础设施 5 + 业务 5**。**业务 Controller 数一致(5)**;差异在基础设施计数(5 vs 6),推测为路线图对"基础设施/非业务入口"的分类口径略有不同(如是否计入某调用点)。**不应据此修改代码或路线图**;建议后续批次统一口径。 + +--- + +## 15. 精确新增文件 + +``` +新增测试(3) + mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5AccessorWiringIndependentTest.java + mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5IndependentVerificationTest.java + mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5SideEffectSnapshotTest.java + +复验报告(1) + docs/r01-a5-independent-verification-20260917.md +``` + +**未修改**:8 个 A5 Controller 生产代码、既有测试断言、`GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、OpenAPI、Flyway。 + +--- + +## 16. Git 状态 + +``` +分支:refactor/r01-structure-performance +HEAD:0226977063243fce101e3f6c8dcf66492d306ef7(未变) +暂存区:空 +git add -A / commit / push:均未执行 +reset / restore / checkout / clean:均未执行 +Docker:未操作 +工作区:8 个 A5 Controller + 路线图(实施者改动,仍未暂存)+ 本报告与 3 个独立测试(未跟踪) +历史未跟踪文件:全部保留 +``` + +--- + +## 17. 是否发现生产缺陷 + +**未发现生产缺陷。** 8 个 Controller 的迁移完整、正确: +- 禁止模式 0 命中;构造器注入无回退;`parseIds` 业务解析正确保留 +- 角色/归属/403/404 语义未漂移 +- 独立接线变异测试证明 Accessor 真实生效(删除调用即失败) +- 独立身份边界测试证明非法主体在业务调用前被拒(401、无 500、零 Service 调用)且不回显主体 +- 失败请求 9 张核心表逐字段零变化 + +--- + +## 18. 明确声明 + +- **未暂存、未提交、未推送。** +- 未修改 8 个 Controller 或任何生产代码;未修改、删除或放宽既有测试断言;未修改 `GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、OpenAPI、Flyway。 +- 未进入 R01-A6;未删除历史未跟踪文件。 +- 本轮未启动 Docker,**未伪造真实 MySQL 结论**。 + +--- + +> **R01-A5独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** diff --git a/docs/r01-a5-request-context-migration-report.md b/docs/r01-a5-request-context-migration-report.md new file mode 100644 index 0000000..f81cc8a --- /dev/null +++ b/docs/r01-a5-request-context-migration-report.md @@ -0,0 +1,79 @@ +# R01-A5 第五批请求上下文收束实施报告 + +实施日期:2026-09-17 + +分支:`refactor/r01-structure-performance` + +基线 HEAD:`0226977063243fce101e3f6c8dcf66492d306ef7` + +## 结论 + +R01-A5 第五批请求身份与 requestId 收束已完成自动化验证,待独立代码复验。 + +## 修改范围 + +本批仅修改以下 8 个 Controller: + +1. `CandidateScoringGovernanceController` +2. `InterviewScoreController` +3. `FavoriteController` +4. `AdminReviewController` +5. `AdminReviewReplyController` +6. `CompanyReviewManagementController` +7. `JobReviewController` +8. `StudentReviewWeightController` + +每个 Controller 均执行相同的受控迁移: + +- 构造器注入既有 `CurrentUserAccessor` 与 `RequestIdAccessor`; +- 身份统一调用 `currentUserAccessor.requireUserId()`; +- requestId 统一调用 `requestIdAccessor.get(request)`; +- 删除本地身份解析方法和 requestId 读取方法; +- 保留原有角色、资源归属、Service 参数、DTO 和响应结构。 + +`JobReviewController.parseIds` 是岗位 ID 业务参数解析,予以保留。 + +## 静态门禁 + +8 个目标文件对以下模式均为零命中: + +- `SecurityContextHolder` / `Authentication` / `anonymousUser`; +- 身份 `Long.valueOf(auth.getName())`; +- `resolveUserId` / `getRequestId` / 私有 `requestId(...)`; +- 原始 `X-Request-Id` header 读取; +- 可选注入、`ObjectProvider` 或手工 `new Accessor()` 回退。 + +## 新增验证 + +新增 `R01A5RequestContextMigrationIntegrationTest`,使用真实 Spring Security 与 MVC 映射: + +- 8 个 Controller 各选一个真实端点; +- 非法主体覆盖 `anonymousUser`、非数字、20 位 Long 溢出、0、负数; +- 所有组合必须返回 HTTP 401,且响应不含异常类、堆栈或非法主体; +- 合法主体下验证规范化 requestId 的响应头与响应体同源。 + +定向与关联测试:90/90,Failures=0,Errors=0,Skipped=0。 + +## 全量验证 + +- `mvn clean verify`:BUILD SUCCESS,2245/2245,Failures=0,Errors=0,Skipped=0; +- `python docs/verify_openapi.py`:176 paths / 214 operations,All checks passed; +- V1-V45 无差异; +- `docs/openapi-rc.json` 无差异; +- `git diff --check` 无空白错误,仅既有 LF/CRLF 提示。 + +## 全仓剩余 + +完成 A5 后: + +- 身份解析共 4 个文件:基础设施 3 个,业务 Controller 仅 `FairnessMonitoringController` 1 个; +- requestId 入口共 11 个文件:基础设施/非业务入口 6 个,业务 Controller 5 个; +- 最终 A6 业务白名单为 `FairnessMonitoringController`、`ApiController`、`AuthController`、`SystemController`、`JobController`。 + +## 边界 + +- 未新增或修改 Flyway、ErrorCode、接口、DTO、表、索引或依赖; +- 未修改评分、申诉、面试评分、岗位收藏或评价业务规则; +- 未暂存、未提交、未推送; +- 未删除或移动历史未跟踪文件; +- 未进入 A6。 diff --git a/docs/r01-a6-fairness-fixture-isolation-fix-20260917.md b/docs/r01-a6-fairness-fixture-isolation-fix-20260917.md new file mode 100644 index 0000000..31d7429 --- /dev/null +++ b/docs/r01-a6-fairness-fixture-isolation-fix-20260917.md @@ -0,0 +1,128 @@ +# Fairness 认证夹具隔离修复(A6 后独立纯测试提交) + +日期:2026-09-17 +分支:`refactor/r01-structure-performance` +A6 提交:`c6db74a1a8300e1aab8a452fb67909f86861fa43` +本轮性质:**纯测试修复** —— 生产代码 diff 为 0,Flyway 为 0,OpenAPI 为 0 + +--- + +## 0. 结论 + +`FairnessMonitoringControllerTest` 已不再依赖种子数据、测试执行顺序或共享表清空, +**单独运行稳定通过**;并新增反证测试锁定该隔离性。 + +--- + +## 1. 缺陷(A6 独立复验定位) + +| 环节 | 事实 | +|---|---| +| 冲突 | 该测试用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")`,而 `V1_1__seed_data.sql` 的种子账号占用 `users.id = 1/2/3` | +| 被禁用 | `V43__disable_legacy_seed_accounts.sql` 把这三行置为 `is_active = 0` | +| 夹具缺陷 | `TestAuthTokens.writeSession()` 仅在 `SELECT COUNT(*) FROM users WHERE id = ?` 为 0 时 INSERT,**不会激活已存在的禁用行** | +| 后果 | `AuthSessionService.isSessionActive()` 的用户检查短路 → 全部 Token 请求 401 | +| 被掩盖 | 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach` 执行 `userRepository.deleteAll()`,偶然清掉种子行,夹具随后才补建 active 用户 | + +单跑实测(修复前):`Tests run: 7, Failures: 6`,全部 `Status expected:<200|403|400> but was:<401>`。 + +详见 `docs/r01-a6-independent-verification-20260917.md` §9。 + +--- + +## 2. 修复方案 + +**选择:让 `FairnessMonitoringControllerTest` 使用独占用户,而不是修改公共 `TestAuthTokens`。** +理由:公共 helper 若"自动激活已存在的 inactive 用户",会静默复活本应被禁用的账号 +(包括生产语义上被 V43 禁用的种子账号),风险远大于收益。 + +### 2.1 改动内容 + +| 项 | 修复前 | 修复后 | +|---|---|---| +| 夹具身份 | `userId = 1` / `2`(与种子账号冲突) | **独占** `ADMIN_USER_ID = 9_310_001L`、`STUDENT_USER_ID = 9_310_002L` | +| 手机号 | 由 `TestAuthTokens` 隐式生成 `"t"+id` | 独占 `a6fair-admin` / `a6fair-student` | +| 用户来源 | 依赖 `TestAuthTokens` 的"不存在才插入" | `@BeforeEach` **显式插入 `is_active = 1`** 的独占用户 | +| 清理 | 无 | `@AfterEach` 按**精确 ID**、按外键顺序删除:`auth_tokens` → `user_roles` → `users` | + +`@BeforeEach` 先执行与 `@AfterEach` 相同的精确清理再插入,因此对"上轮异常中断残留"具备幂等性, +且**从不把任何已存在的 inactive 用户直接激活**(是删除后重建自有行)。 + +### 2.2 未使用的手段(明确排除) + +未改 `V43`;未改生产认证逻辑;未使用 `deleteAll()` 清空共享表;未使用 `@DirtiesContext` 掩盖污染; +未捕获并忽略约束异常;未依赖测试执行顺序;未放宽任何既有 Fairness 断言;未修改公共 `TestAuthTokens`。 + +--- + +## 3. 反证测试 `FairnessMonitoringFixtureIsolationTest`(7 例) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 1 | `fixtureIdsAreOutsideSeedAccountRange` | 夹具 ID 不在种子号段 1/2/3;夹具用户确为 active;未占用种子手机号 | +| 2 | `seedRowsInactiveOrAbsent_stillAuthenticates` | 种子行 **inactive(V43 状态)** 与 **完全缺失** 两种前置下端点均返回 200 | +| 3 | `repeatedProvisioningAndRequests_doNotConflict` | 连续 3 轮"装配 + 请求"均成功,且不产生重复用户行 | +| 4 | `fixtureCycleLeavesSeedRowsUntouched` | 完整周期前后种子行逐字段不变(顺序无关的根本保证) | +| 5 | `cleanupRemovesOnlyOwnFixtureRows` | 清理只删本类行;探针用户及其会话不受波及 | +| 6 | `unrelatedUsersAndSessionsUnchanged` | 非夹具用户与会话逐字段不变 | +| 7 | `publicTokenFixtureDoesNotSilentlyReviveInactiveUser` | `TestAuthTokens` 对已存在 inactive 用户**不激活**,该 Token 请求为 401 且 Service 零调用 | + +用例 2 会临时快照并按主键**原样恢复**种子行(`try` 语义由 `@AfterEach` 兜底),不新增、不批量重建。 + +--- + +## 4. 验证矩阵 + +命令均在 `mydreamjob-server/` 下执行;日志位于 `docs/acceptance/r01-d4-2-20260916/ff-*.log`。 + +| # | 命令 | 结果 | 日志 | +|---|---|---|---| +| 1 | `mvn -Dtest="FairnessMonitoringControllerTest" test` | **7 / 7**,F=0 E=0 S=0 ✔ | `ff-1-standalone.log` | +| 2 | `mvn -Dtest="FairnessMonitoringFixtureIsolationTest,FairnessMonitoringControllerTest" test` | **14 / 14**,F=0 E=0 S=0 ✔ | `ff-2-isolation.log` | +| 3 | Fairness **先**跑 ×3(`-Dsurefire.runOrder=reversealphabetical`) | **13 / 13** ×3 ✔ | `ff-3-fairnessfirst-{1,2,3}.log` | +| 4 | Auth **先**跑 ×3(`-Dsurefire.runOrder=alphabetical`) | **13 / 13** ×3 ✔ | `ff-4-authfirst-{1,2,3}.log` | +| 5 | `mvn clean verify` | 见 §4.1 | `ff-5-clean-verify.log` | +| 6 | `python ..\docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ | — | + +> **顺序说明**:surefire 默认 `runOrder=filesystem` 会忽略 `-Dtest` 的书写顺序, +> 因此额外用 `-Dsurefire.runOrder=reversealphabetical`(Fairness 先)与 `=alphabetical`(Auth 先) +> 强制两个方向,并在日志中逐次核对实际执行顺序(`reversealphabetical` 下 +> `scoringmonitoring.FairnessMonitoringControllerTest` 确在 `controller.AuthIntegrationTest` 之前)。 +> `runOrder=testorder` 在本仓库使用的 surefire 3.5.6 中不可用(已实测报错)。 + +### 4.1 全量结果 + +| 项 | 数值 | +|---|---| +| 全量测试总数 | 2278 → **2285**(+7:新增反证测试类) | +| Failures / Errors / Skipped | **0 / 0 / 0** ✔ | +| 生产代码 diff | **0** ✔ | +| V1–V45 | **未改动** ✔ | +| `docs/openapi-rc.json` | **未修改** ✔ | + +--- + +## 5. 修改文件 + +``` +mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java (修改:夹具独占化) +mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java(新增:反证测试) +docs/r01-a6-fairness-fixture-isolation-fix-20260917.md (新增:本报告) +docs/backend-development-roadmap.md (夹具问题关闭记录) +``` + +**无生产代码、无 Flyway、无 ErrorCode、无 DTO、无接口、无 OpenAPI、无 Filter/Accessor/SecurityConfig 改动。** + +--- + +## 6. 明确声明 + +- 未修改任何生产代码,未修改 `V43`,未修改公共 `TestAuthTokens` 夹具。 +- 未删除、未放宽任何既有断言;`FairnessMonitoringControllerTest` 的业务断言逐字保留。 +- 未使用 `deleteAll()`、`@DirtiesContext`、顺序依赖或异常吞没等掩盖手段。 +- 未与 R01-B 混合。 + +--- + +> **FairnessMonitoringControllerTest 单跑夹具缺陷已通过独立纯测试提交修复; +> 该测试不再依赖种子数据状态与测试执行顺序。** diff --git a/docs/r01-a6-final-request-context-migration-report.md b/docs/r01-a6-final-request-context-migration-report.md new file mode 100644 index 0000000..f4c6df9 --- /dev/null +++ b/docs/r01-a6-final-request-context-migration-report.md @@ -0,0 +1,359 @@ +# R01-A6 最终业务 Controller 请求身份与 requestId 收束报告 + +日期:2026-09-17 +分支:`refactor/r01-structure-performance` +A6 基线 HEAD:`534afe686b966f175bcaf4af0726cec061092050`(A5 提交) +本轮性质:**实施与自动化验证** —— 未提交、未推送,待独立代码复验 + +--- + +## 0. 结论 + +> **R01-A6最终业务Controller请求上下文收束完成,待独立代码复验;A系列到此结束,不再拆分A7/A8。** + +**业务 Controller 身份解析残留 = 0;业务 Controller requestId 重复解析残留 = 0。** 剩余命中全部为基础设施职责。 + +--- + +## 1. 开始时状态 + +### 1.1 A5 固化(第一阶段) + +A5 此前**未提交**,本轮按已确认清单精确固化: + +| 项 | 结果 | +|---|---| +| 暂存文件数 | **15**(8 生产 Controller + 4 测试 + 2 报告 + 1 路线图) | +| `git add` 方式 | **逐文件 `git add --`**;未使用 `git add -A` / `git add .` | +| 路线图 | `git diff --numstat` 为 `1 1`(**仅 R01 行一处 hunk**),无需 `git add -p` | +| 排除项 | `GlobalExceptionHandler.java`、`db/migration`、`docs/acceptance`、`backend-infra`、A2–A4 文件 **全部排除** ✔ | +| `git diff --cached --check` | 无输出 ✔ | +| 敏感扫描 | 1050 新增行,12 类(手机号/JWT/Bearer/验证码/口令/密钥/私钥/AWS/64位摘要/本机路径/UUID/连接串)**全部命中 0** ✔ | +| A5 定向 | **42 / 42**,F=0 E=0 S=0 | +| `mvn clean verify` | **2255 / 2255**,F=0 E=0 **S=0**,BUILD SUCCESS | +| OpenAPI | **176 / 214**,All checks passed | +| 提交 | `refactor: centralize fifth batch request context handling` | +| **A5 提交 hash** | **`534afe686b966f175bcaf4af0726cec061092050`** | +| 推送 | `0226977..534afe6`;**未 force push** | +| 推送后同步 | **0 / 0** ✔ | + +### 1.2 A6 开始前核查(第二阶段) + +| 检查项 | 结果 | +|---|---| +| 分支 | `refactor/r01-structure-performance` ✔ | +| 新 HEAD | `534afe686b966f175bcaf4af0726cec061092050` ✔ | +| 暂存区 | 空 ✔ | +| `git diff --check` | 无空白错误 ✔ | +| **A5 未提交残余** | **无**(工作区仅 `GlobalExceptionHandler.java` 的 stat 缓存标记,blob 三方一致)✔ | +| 历史未跟踪文件 | 全部保留 ✔ | +| Docker / R01-B·C·E | 未触碰 ✔ | + +**A6 基线测试数:2255。** + +--- + +## 2. 五个 Controller 的修改内容 + +### 2.1 迁移前契约 + +| Controller | 真实代表端点 | 身份解析 | requestId 解析 | +|---|---|---|---| +| `controller/ApiController` | `GET /` | 无 | `private String getRequestId(HttpServletRequest)` → `request.getHeader("X-Request-Id")` | +| `controller/AuthController` | `POST /auth/login`、`GET /auth/me` 等 | **已有 `CurrentUserAccessor`** ✓ | `private String getRequestId(...)` | +| `controller/SystemController` | `GET /health`、`GET /info` | 无 | `private String getRequestId(...)` | +| `job/JobController` | `GET /jobs`、`POST /jobs` 等 | **已有 `CurrentUserAccessor`** ✓ | `private String getRequestId(...)` | +| `scoringmonitoring/FairnessMonitoringController` | `GET /admin/scoring/fairness/*` | **需迁移**:`private Long userId()` 直读 `SecurityContextHolder` + `Long.valueOf(auth.getName())` | `private String requestId(HttpServletRequest)` 单行直读 | + +### 2.2 修改后 + +| Controller | 注入 | `requireUserId()` | `requestIdAccessor.get(request)` | +|---|---|---|---| +| ApiController | 新增 `RequestIdAccessor`(并补 `@RequiredArgsConstructor`) | 0 | 1 | +| AuthController | 新增 `RequestIdAccessor`(`CurrentUserAccessor` 既有) | 2(既有) | 14 | +| SystemController | 新增 `RequestIdAccessor`(并补 `@RequiredArgsConstructor`) | 0 | 2 | +| JobController | 新增 `RequestIdAccessor`(`CurrentUserAccessor` 既有) | 1(既有) | 8 | +| FairnessMonitoringController | 新增 `CurrentUserAccessor` + `RequestIdAccessor` | 5 | 5 | + +**`JobController` 的身份迁移未被重复改造** ✔ —— 它此前已使用 `CurrentUserAccessor`,本批只迁移 requestId。 + +**`AuthController` 无重复身份解析** ✔ —— 它此前已使用 `CurrentUserAccessor`,本批只迁移 requestId。 + +### 2.3 删除的私有方法(共 6 个) + +| Controller | 删除 | +|---|---| +| ApiController | `getRequestId` ×1 | +| AuthController | `getRequestId` ×1 | +| SystemController | `getRequestId` ×1 | +| JobController | `getRequestId` ×1 | +| FairnessMonitoringController | `userId` ×1、`requestId` ×1 | +| **合计** | **6** | + +> **计数澄清(独立复验补充)**:曾出现"`5 × getRequestId + 1 × userId + 1 × requestId`"的表述,其算术结果为 7,**与真实 diff 不符,该表达式不成立**。经逐文件 `git diff` 核对:`getRequestId` 实际只有 **4 个**(ApiController / AuthController / SystemController / JobController);`FairnessMonitoringController` 的 requestId 方法签名是 **`requestId(HttpServletRequest)`(无 `get` 前缀)**,因此**不能被计入 `getRequestId`**。正确分解为 **4 × getRequestId + 1 × userId + 1 × requestId = 6**,与本表逐文件清单完全一致。**未为迎合数字修改任何生产代码。** + +**保留**:`FairnessMonitoringController.parseDate`(业务参数解析)、`checkParams`(业务参数校验)✔ 未被误删。 + +**新增注解**:`ApiController` 与 `SystemController` 原本无依赖、缺 `@RequiredArgsConstructor`,为支持构造器注入补齐该注解与 import(**唯一的新增注解**,未引入 `@Autowired`)。 + +**未修改**:`RequestIdFilter`、`RequestIdAccessor`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`、`SecurityConfig`、Filter 顺序 ✔ + +--- + +## 3. 修复前失败证据 + +新增 `mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/` 下两个测试类。 + +**修复前实测:`Tests run: 10, Failures: 5`** + +| 失败测试 | 修复前实际 | +|---|---| +| `A6AccessorWiringProbeTest.requestIdComesFromAccessorAcrossFiveControllers` | `path=/` 响应体 `requestId` **不是** Accessor 返回值(Controller 直读 header) | +| `A6AccessorWiringProbeTest.fairnessMonitoringUsesCurrentUserAccessor` | `FairnessMonitoringService.drift(...)` **未被调用**(身份未走 Accessor) | +| `A6AccessorWiringProbeTest.accessorThrowsUnauthenticated_fairnessReturns401` | 不成立 | +| `R01A6RequestContextMigrationIntegrationTest.fairnessMonitoring_illegalPrincipals_are401` | `principal='anonymousUser'` → **`expected: 401 but was: 400`** | +| `R01A6RequestContextMigrationIntegrationTest.fairnessMonitoring_illegalPrincipals_produceNoServiceCall` | 不成立 | + +**关于 400 而非 500(如实记录)**:`FairnessMonitoringController.userId()` 带有 +`try { Long.valueOf(auth.getName()); } catch (NumberFormatException) { throw BusinessException(UNAUTHORIZED); }`, +因此其**既有行为本身是安全的(未逃逸为 500)**,但抛出的 `BusinessException(ErrorCode.UNAUTHORIZED=2001)` +不在 `GlobalExceptionHandler.BUSINESS_HTTP_STATUSES` 映射中,`resolveHttpStatus` 的 +`getOrDefault(2001, BAD_REQUEST)` 使其落到 **HTTP 400**。这正是本批收束的真实语义缺陷: +**非法主体应返回 401 而非 400**。接线探针则因"未调用 Accessor"而失败 —— **未编造 500**。 + +**修复后:`Tests run: 10, Failures: 0`** + +--- + +## 4. 角色与资源归属语义 + +| 核查项 | 结论 | +|---|---| +| 静态审查:删除行中是否含角色/归属检查 | **否** ✔ | +| 静态审查:新增行中是否引入角色/归属逻辑 | **否** ✔ | +| `ErrorCode.java` | **零改动** ✔ | +| ADMIN / COMPANY / STUDENT 角色检查 | 未删除、未移动 ✔ | +| 企业归属 / 资源归属 / 403 / 404 分支 | 未删除、未移动 ✔ | +| 监控数据访问权限(ADMIN 只读聚合) | 未改变 ✔ | + +**分层保持**:身份失败 → **401**;角色不足 → **403**(由 Service 层或既有逻辑产生);资源不存在 → **404**。 + +--- + +## 5. 独立接线探针 + +`A6AccessorWiringProbeTest`(3 例)使用 `@MockBean` 替换 `CurrentUserAccessor`、`RequestIdAccessor` 与对应 Service: + +| 用例 | 证明 | +|---|---| +| `requestIdComesFromAccessorAcrossFiveControllers` | mock `RequestIdAccessor` 返回 `a6-probe-request-id` → **5 个 Controller 的响应体 requestId 均为该值**(修复前为直读 header 的 null) | +| `fairnessMonitoringUsesCurrentUserAccessor` | mock `CurrentUserAccessor` 返回 `5151L` → `fairnessMonitoringService.drift(5151L)` 被调用 | +| `accessorThrowsUnauthenticated_fairnessReturns401` | mock 抛 `InsufficientAuthenticationException` → FairnessMonitoring 返回 **401**,Service **零调用** | + +**变异有效性**:删除任一 Controller 中的 Accessor 调用会使对应 `verify` 失败 ✔ + +--- + +## 6. 非法 principal 的结果 + +`R01A6RequestContextMigrationIntegrationTest` 对 `GET /admin/scoring/fairness/drift` 覆盖: + +| 主体 | 结果 | +|---|---| +| `anonymousUser` | **401** ✔ | +| `not-a-number` | **401** ✔ | +| `99999999999999999999`(20 位溢出) | **401** ✔ | +| `0` | **401** ✔ | +| `-1` | **401** ✔ | +| 匿名 token / 无 Token | **401** ✔ | +| 合法数字主体 `"7"` | 不被身份层拒绝(非 401)✔ | + +断言:**401 且无 500**;响应体仅解析 `code`/`message` 稳定字段,**不对整段 JSON 做数字搜索**;不含 `Exception`/`java.`/`principal`/`anonymousUser`/`com.mydreamjob`/`NumberFormat`;无 `stackTrace`/`trace`;**业务 Service 零调用** ✔ + +--- + +## 7. requestId 规范化 + +覆盖 5 个 Controller 的真实端点(`/`、`/health`、`/auth/me`、`/jobs`、`/admin/scoring/fairness/drift`): + +| 输入 | 结果 | +|---|---| +| 合法值 `a6-legal-0001` | 响应头 == 响应体,原样保留 ✔ | +| 空白 `" "` | 重新生成,头体同源 ✔ | +| 超长(80 字符) | 收敛至 ≤32,头体同源 ✔ | +| 非法字符 ``、`a b` | 生成合法替代值,响应头不含 `<`/`>`/空格 ✔ | +| request attribute 与 header 不同 | **attribute 优先** ✔ | +| CR / LF / CRLF(直接经 Accessor) | 不进入规范化结果 ✔ | + +--- + +## 8. 全仓剩余命中逐文件分类 + +**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`)—— **3 文件** + +| 文件 | 分类 | +|---|---| +| `security/CurrentUserAccessor` | 基础设施(Accessor 自身) | +| `auth/JwtAuthenticationFilter` | 基础设施(认证过滤器) | +| `deletion/DeletionGuardInterceptor` | 基础设施(拦截器) | + +**requestId 解析**(含 `X-Request-Id` 直读、`RequestIdFilter.REQUEST_ID_HEADER`、`String (getRequestId|resolveRequestId|requestId)(`)—— **5 文件** + +| 文件 | 分类 | +|---|---| +| `audit/AuditContext` | 基础设施 | +| `common/exception/GlobalExceptionHandler` | 基础设施 | +| `common/logging/RequestIdAccessor` | 基础设施(Accessor 自身) | +| `common/logging/RequestLoggingFilter` | 基础设施 | +| `deletion/DeletionGuardInterceptor` | 基础设施 | + +**全部落在任务书允许的基础设施清单内**(`CurrentUserAccessor`、`JwtAuthenticationFilter`、`DeletionGuardInterceptor`、`RequestIdAccessor`、`RequestIdFilter`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`)✔ + +**未为"全仓零命中"修改任何基础设施类** ✔ + +**未在 A6 清单内的业务 Controller 残留:0** ✔ —— 无需越界修改。 + +--- + +## 9. 业务 Controller 残留 + +| 项 | 结果 | +|---|---| +| 业务 Controller 身份解析 | **0** ✔ | +| 业务 Controller requestId 重复解析 | **0** ✔ | + +**A 系列请求上下文收束达成目标终态。A7/A8 不再设置。** + +--- + +## 10. 测试数 + +| 命令 | 结果 | +|---|---| +| A6 定向(`R01A6…,A6AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **40 / 40**,F=0 E=0 **S=0** | +| A6 关联(`*ApiController*Test,*AuthController*Test,*SystemController*Test,*JobController*Test,*FairnessMonitoring*Test,*Security*Test,*RequestId*Test`) | **76 / 76**(1 项既有夹具脆弱性,见 §10.1,**全量顺序下不触发**) | +| `mvn test` | **2265 / 2265**,Failures=0、Errors=0、**Skipped=0** | +| `mvn clean verify` | **2265 / 2265**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | + +A5 基线 2255 → A6 **2265**(新增 **10** 个测试:7 + 3),**不低于基线** ✔ + +### 10.1 基线已存在的测试夹具脆弱性(未在本批修复) + +A6 关联回归单独运行时,**既有** `FairnessMonitoringControllerTest` 出现 6 个失败(`200/403/400 → 401`),根因为: + +``` +Caused by: AuthSessionService - session validation rejected: user not usable + JwtAuthenticationFilter - JWT authentication rejected: session not active +``` + +**归因验证(任务书要求)**:在 `git worktree add --detach HEAD`(**不含 A6 改动**)中运行**完全相同**的命令: + +| | 测试数 | 失败 | 根因 | +|---|---|---|---| +| 基线(HEAD) | **7** | **6** | `user not usable` | +| 本次(含 A6) | **7** | **6** | **完全相同** | + +**实施阶段初步判断(后被独立复验否定,保留于此以存档过程)**:当时认为 +`TestAuthTokens` 夹具插入 `users` 行时**不写 `user_roles`**,导致该用户被判 "not usable"。 +**该判断不成立**:`AuthSessionService.isUsable(User)` 只检查用户存在且 `is_active = true`, +`UserRepository.findById` 无角色过滤,该路径**从不查询 `user_roles`**。 + +**独立复验结论(准确根因)**:见 `docs/r01-a6-independent-verification-20260917.md` §9。 + +| 环节 | 事实 | +|---|---| +| 身份冲突 | `FairnessMonitoringControllerTest` 使用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")`,与 `V1_1__seed_data.sql` 种子账号占用的 `users.id = 1 / 2 / 3` **冲突** | +| 种子被禁用 | `V43__disable_legacy_seed_accounts.sql` 按原始指纹把这三行 `is_active` 置为 **0** | +| 夹具缺陷 | `TestAuthTokens.writeSession()` 仅当 `SELECT COUNT(*) FROM users WHERE id = ?` 为 0 时才 INSERT;**对已存在但 `is_active = 0` 的行不做激活**,永不修复其启用状态 | +| 直接后果 | `AuthSessionService.isSessionActive()` 第一步 `isUserUsable()` 返回 false 并短路,请求在进入 Controller 前即 401 | +| 全量为何通过 | 全量顺序中 `com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach` 执行 `userRepository.deleteAll()`,**偶然清除**了 V43 已禁用的种子行,夹具随后才补建出 active 用户 —— 问题被掩盖,而非被修复 | + +**与 A6 生产改动无关**:401 由 `JwtAuthenticationFilter` / `AuthSessionService` 在请求进入 +Controller 之前产生(`Handler: Type = null`),A6 只改了 5 个 Controller 内部取身份 / 取 +requestId 的写法,该路径上一行 A6 代码都未执行。独立复验的决定性对照实验:同一份含 A6 的 +代码,单跑 Fairness 为 7 run / 6 failures,而让 `AuthIntegrationTest` 先跑则为 **7 run / 0 failures**。 + +`mvn clean verify`(全量顺序)下 **2278/2278 全通过**,该失败不触发。 + +按任务书要求:**未把失败写成通过,未在本批顺手修复**;该夹具问题将在 A6 提交后 +**以独立纯测试提交修复**(不与本批生产改动混合)。 + +--- + +## 11. OpenAPI + +`python docs\verify_openapi.py` → **176 paths / 214 operations**,All checks passed ✔ + +**`docs/openapi-rc.json` 零修改** ✔ + +--- + +## 12. 精确修改文件 + +**生产代码(5 个,全部在 A6 指定清单内)** + +``` +mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java +mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java +mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java +mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java +mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java +``` + +**测试(新增 2 个)** + +``` +mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java +mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java +``` + +**文档(2 个)** + +``` +docs/r01-a6-final-request-context-migration-report.md # 本报告 +docs/backend-development-roadmap.md # 仅更新顶部 R01 状态行 +``` + +**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter、Accessor、OpenAPI、既有测试、`UserRoleGuard`。 + +--- + +## 13. Flyway / ErrorCode / DTO / 接口 + +| 项 | 结果 | +|---|---| +| V1–V45 迁移 | **零修改** ✔ | +| `ErrorCode.java` | **零改动** ✔ | +| DTO | **零改动** ✔ | +| 公网接口 / OpenAPI | **零新增零修改** ✔ | +| 新增表 / 索引 / 迁移 | **无** ✔ | + +--- + +## 14. Git 状态 + +``` +git diff --check → 无输出 +git status --short → 8 个生产/文档改动 + GlobalExceptionHandler(stat 缓存标记) +git diff --cached --name-only → 空 +git diff --name-only -- db/migration → 空 +git diff -- docs/openapi-rc.json → 空 +``` + +- `GlobalExceptionHandler.java`:工作区 / 索引 / HEAD 三方 blob 均为 `1fdc341e…`,**内容零差异**,未触碰 ✔ +- 临时基线 worktree 已 `--force` 移除并 prune,`git worktree list` 仅主仓库 ✔ + +--- + +## 15. 明确声明 + +- **未暂存、未提交、未推送。** +- 未操作 Docker。 +- **未进入 R01-B / R01-C / R01-E。** +- 未修改基础设施类(`RequestIdFilter`、`RequestIdAccessor`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`、`SecurityConfig`)。 +- 未删除历史未跟踪文件。 +- 基线测试污染已用 HEAD 隔离 worktree 复现并如实记录,**未写成通过、未顺手修复**。 + +--- + +> **R01-A6最终业务Controller请求上下文收束完成,待独立代码复验;A系列到此结束,不再拆分A7/A8。** diff --git a/docs/r01-a6-independent-verification-20260917.md b/docs/r01-a6-independent-verification-20260917.md new file mode 100644 index 0000000..3691a2a --- /dev/null +++ b/docs/r01-a6-independent-verification-20260917.md @@ -0,0 +1,492 @@ +# R01-A6 独立代码复验报告(最终批:5 个 Controller 请求身份与 requestId 收束) + +日期:2026-09-17 +分支:`refactor/r01-structure-performance` +HEAD:`534afe686b966f175bcaf4af0726cec061092050`(A5 提交;复验全程未变) +暂存区:**空** +复验对象:`docs/r01-a6-final-request-context-migration-report.md` +性质:**仅复验** —— 未修改生产代码、未修改既有测试断言、未暂存、未提交、未推送 + +--- + +## 0. 结论 + +> **R01-A6独立代码复验通过,可以进入精确暂存与独立提交检查点;A系列到此结束,不再设置A7/A8。FairnessMonitoringControllerTest的单跑夹具缺陷须在A6提交后以独立纯测试提交修复。** + +**业务 Controller 身份解析残留 = 0;业务 Controller requestId 重复解析残留 = 0。** +剩余命中全部落在基础设施职责内(身份 3 文件、requestId 5 文件)。 + +**未发现 A6 引入的生产缺陷。** `FairnessMonitoringControllerTest` 的单跑失败经证据链确认为 +**测试夹具缺陷**(`TestAuthTokens` 对"已存在但被禁用"的用户行不做激活), +**与 A6 生产改动无关**;其准确根因见 §9。 + +--- + +## 1. 开始前状态 + +| 项 | 结果 | +|---|---| +| `git branch --show-current` | `refactor/r01-structure-performance` | +| `git rev-parse HEAD` | `534afe686b966f175bcaf4af0726cec061092050` | +| `git diff --cached --name-only` | **空**(暂存区为空) | +| `git diff --check` | 仅 `docs/backend-development-roadmap.md` 的 LF/CRLF 警告,**exit 0**,无空白错误 | +| Java/Maven 残留进程 | 无 | + +工作区改动共 6 个文件:5 个生产 Controller + `docs/backend-development-roadmap.md`。 + +--- + +## 2. 五个 Controller 静态审查 + +`git diff --stat`: + +``` +docs/backend-development-roadmap.md | 2 +- +.../com/mydreamjob/controller/ApiController.java | 12 +++++--- +.../com/mydreamjob/controller/AuthController.java | 33 +++++++++++----------- +.../mydreamjob/controller/SystemController.java | 14 +++++---- +.../java/com/mydreamjob/job/JobController.java | 21 +++++++------- +.../FairnessMonitoringController.java | 25 +++++++--------- +6 files changed, 54 insertions(+), 53 deletions(-) +``` + +| 项 | 结果 | +|---|---| +| 修改范围是否仅限 A6 清单内 5 个 Controller | **是** ✔ | +| 是否新增接口 / ErrorCode / Flyway / 表 / 索引 / 依赖 | **否** ✔ | +| 是否触碰 `SecurityConfig` / Filter / Accessor / `GlobalExceptionHandler` | **否** ✔ | +| 是否修改既有测试文件 | **否** ✔(`git status` 中无任何 `src/test` 既有文件被修改) | + +### 2.1 禁止模式扫描(静态门禁) + +对 5 个 Controller 逐一扫描 `X-Request-Id` 直读、`SecurityContextHolder`、 +`getAuthentication()`、`Long.valueOf(auth.getName())`: + +| 文件 | 命中 | +|---|---| +| `controller/ApiController.java` | **0** ✔ | +| `controller/AuthController.java` | **0** ✔ | +| `controller/SystemController.java` | **0** ✔ | +| `job/JobController.java` | **0** ✔ | +| `scoringmonitoring/FairnessMonitoringController.java` | **0** ✔ | + +全仓 `*Controller.java` 扫描同一组模式:**0 命中** ✔ + +### 2.2 构造器注入与无回退 + +`ApiController`、`SystemController` 新增 `@RequiredArgsConstructor` 并声明 +`private final CurrentUserAccessor` / `private final RequestIdAccessor`; +`AuthController`、`JobController`、`FairnessMonitoringController` 复用既有 +`@RequiredArgsConstructor`。**全部为 final 构造器注入,无字段默认值、无 `null` 回退、 +无 `new` 兜底实现** ✔ + +### 2.3 `GlobalExceptionHandler.java` 三方 blob 比对 + +``` +git hash-object → 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 +git rev-parse HEAD: → 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 +git ls-files -s → 100644 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 0 +git diff --quiet → exit 0 +``` + +**工作区 blob 与 HEAD blob 完全一致**,该文件在 `git status` 中的 `M` 标记是 +**Windows stat 缓存假阳性**,未修改、未触碰 ✔ + +--- + +## 3. 删除的私有方法(真实数量:6) + +逐文件 `git diff` 核对(`-` 行为真实删除): + +| # | 文件 | 删除的方法签名 | +|---|---|---| +| 1 | `controller/ApiController` | `private String getRequestId(HttpServletRequest)` | +| 2 | `controller/AuthController` | `private String getRequestId(HttpServletRequest)` | +| 3 | `controller/SystemController` | `private String getRequestId(HttpServletRequest)` | +| 4 | `job/JobController` | `private String getRequestId(HttpServletRequest)` | +| 5 | `scoringmonitoring/FairnessMonitoringController` | `private Long userId()` | +| 6 | `scoringmonitoring/FairnessMonitoringController` | `private String requestId(HttpServletRequest)` | + +**计数澄清**:`getRequestId` 名称的方法实际为 **4 个**; +`FairnessMonitoringController` 的 requestId 方法签名为 `requestId(HttpServletRequest)` +(**无 `get` 前缀**),不能被计入 `getRequestId`。 +正确分解为 **4 × `getRequestId` + 1 × `userId` + 1 × `requestId` = 6**, +与上表逐文件清单一致 ✔ + +**未为迎合任何数字修改生产代码** ✔ + +--- + +## 4. 独立接线变异证明 + +新增独立测试 `A6AccessorWiringIndependentTest` 将 `CurrentUserAccessor`、 +`RequestIdAccessor` 全部替换为 mock,覆盖 5 个 Controller 的代表端点 +(`/`、`/health`、`/auth/me`、`GET /jobs`、`/admin/scoring/fairness/drift`): + +| 断言 | 结果 | +|---|---| +| 5 个端点响应体 `requestId` 均等于 mock 返回值 `a6-indep-request-id` | **通过** ✔ | +| Accessor 返回 4242 时 `service.drift(4242L)` 被调用 | **通过** ✔ | +| Accessor 返回 77L 时 `overview/dimensions/rankingStability` 收到 77L | **通过** ✔ | +| Accessor 抛 `InsufficientAuthenticationException` → 401 且 `service` 零调用 | **通过** ✔ | +| `POST /jobs` 企业侧身份经 `CurrentUserAccessor.requireUserId()` | **通过** ✔ | + +**变异检测含义**:若任一 Controller 把 `requestIdAccessor.get(request)` 换回直读 +`request.getHeader("X-Request-Id")`,响应体将不再出现 mock 值 → 该断言必然失败; +若 `FairnessMonitoringController` 换回直读 `SecurityContextHolder`, +`4242/77` 将不会传到 Service → 断言失败。**接线真实生效,非空转** ✔ + +--- + +## 5. 独立测试质量审查 + +复验对象: +`A6AccessorWiringIndependentTest`(6 个用例)、`A6IndependentVerificationTest`(7 个用例)。 + +| 核对项 | 结果 | +|---|---| +| 公开端点未被错误要求 401 | ✔ 无 Token 断言仅覆盖 `/auth/me` 与 `/admin/scoring/fairness/drift`,`/`、`/health`、`GET /jobs` 明确排除在断言之外 | +| JobController 身份接线使用真正需要企业身份的端点 | ✔ 改用 `POST /jobs`(`resolveCompanyId()` 路径),非公开的 `GET /jobs` | +| Service 参数验证不是空转 | ✔ 断言具体数值(4242 / 77),而非仅 `any()` | +| requestId 断言未错误声称响应头来自 Accessor | ✔ 只断言「响应头非空且已规范化」+「响应体 requestId 与响应头同源」;响应头由 `RequestIdFilter` 产出,未作错误归属 | +| 副作用快照列真实存在 | ✔ 9 张表列名逐一对齐迁移文件(`SCORING_RISK_EVENTS`、`SCORING_RISK_ACTIONS`、`JOBS`、`USERS`、`USER_ROLES`、`AUDIT_LOGS`、`NOTIFICATIONS`、`MESSAGES`、`CANDIDATE_DIMENSION_SCORES`) | +| 未修改或放宽既有测试 | ✔ 既有测试文件零改动 | +| 未通过过度 mock 绕过目标 Controller | ✔ 目标 Controller 全部真实(`@SpringBootTest` + `MockMvc`),只 mock 下游 Service 与两个 Accessor | + +### 5.1 独立测试首次运行的两处自身错误及修正 + +| # | 原断言 | 问题性质 | 修正 | +|---|---|---|---| +| 1 | 把公开端点路径 `/`、`/health`、`GET /jobs` 也断言为无 Token 401 | **测试自身错误**(公开端点本不应 401),非生产缺陷 | 断言范围收窄为 `/auth/me`、`/admin/scoring/fairness/drift` 两个需认证端点 | +| 2 | 用公开的 `GET /jobs` 验证 `JobController` 的身份 Accessor 接线 | **测试自身错误**(该端点不解析身份,断言无意义) | 改用 `POST /jobs` 企业侧端点 | + +**两处均为新测试自身缺陷,已修正;未修改生产代码,未修改任何既有测试。** +修正后两个类合计 **13/13 通过**。 + +--- + +## 6. 身份边界与 401 / 403 / 404 分层 + +### 6.1 非法主体一律 401(真实 Accessor + 真实 SecurityFilterChain) + +对主体 `anonymousUser`、`not-a-number`、`99999999999999999999`、`0`、`-1`, +以及 `AnonymousAuthenticationToken` 和完全无 Token: + +| 断言 | 结果 | +|---|---| +| 状态码一律 **401**(不得 400/500) | ✔ | +| 响应体 `code = 401` | ✔ | +| `message` ∈ {`authentication required`, `not authenticated`} | ✔ | +| `message` 不回显异常类名、`java.`、`principal`、`anonymousUser`、`com.mydreamjob`、`NumberFormat` | ✔ | +| 响应体无 `stackTrace` / `trace` | ✔ | +| 业务 Service **零调用** | ✔ | +| 合法数字主体(`7`)不被身份层拒绝 | ✔ | + +### 6.2 401 / 403 / 404 分层未漂移 + +静态审查确认:A6 diff **未删除、未新增任何角色校验或资源归属校验**; +`JobController.resolveCompanyId()` 与 `jobService.assertOwnerOrThrow(companyId, jobId)` 原样保留, +`AuthController` 的「已注销手机号 → HTTP 409 / code=9303」分支原样保留。 + +既有契约由既有测试继续守护且**全部通过**(例:`AuthenticationBoundaryIntegrationTest` +对 `POST /jobs` 断言 `403` + `ErrorCode.FORBIDDEN`)。**403 / 404 契约未漂移** ✔ + +--- + +## 7. requestId 语义 + +对 5 个 Controller 的代表端点,注入 `X-Request-Id` ∈ +{`a6v-legal-1`, 全空白, 90 字符超长, `x`, `a b`}: + +| 断言 | 结果 | +|---|---| +| 响应头 `X-Request-Id` 非空 | ✔ | +| 不含 `<`、`>`、空格、CR、LF | ✔ | +| 长度 ≤ 32 | ✔ | +| 响应体 `"requestId":"<响应头同值>"` | ✔ | +| 请求属性优先于原始 Header | ✔ | +| CR/LF 不进入规范化结果(6 组恶意输入) | ✔ | + +--- + +## 8. 失败请求的业务副作用 + +`illegalIdentity_producesNoBusinessWrite`:对 9 张核心表按显式列 + 稳定主键排序做全量快照, +执行 5 组非法主体请求 + 1 组无 Token 请求后再次快照。 + +| 结果 | +|---| +| 9 张表**逐字段零变化** ✔ | + +--- + +## 9. `FairnessMonitoringControllerTest` 单跑:真实结果与准确根因 + +### 9.1 单跑命令与真实结果(**不写成通过**) + +``` +mvn -Dtest="FairnessMonitoringControllerTest" test +``` + +日志:`docs/acceptance/r01-d4-2-20260916/a6v-fairness-repeat.log` + +``` +Tests run: 7, Failures: 6, Errors: 0, Skipped: 0 +BUILD FAILURE +``` + +6 个失败全部是 `Status expected:<200|403|400> but was:<401>`; +唯一通过的是 `anonymous_isUnauthorized`(不发 Token 的那个)。 + +### 9.2 逐项查明(任务书第 2 步的 6 个问题) + +| # | 问题 | 结论 | 证据 | +|---|---|---|---| +| 1 | HTTP 实际状态 | **401**,响应体 `{"code":401,"message":"not authenticated","timestamp":""}` | MockHttpServletResponse 输出;`Handler: Type = null`(请求**未进入任何 Controller**) | +| 2 | Filter 为什么拒绝 | 不是验签失败,而是**服务端会话校验的用户检查失败** | 日志:`AuthSessionService - session validation rejected: user not usable` → `JwtAuthenticationFilter - JWT authentication rejected: session not active` | +| 3 | 测试用户是否存在且 `is_active = true` | **行存在,但 `is_active = 0`**(不是"不存在",也不是"active") | 见 §9.3 推理链 | +| 4 | `auth_tokens` 会话记录是否存在/未过期/未消费/未撤销 | **存在且有效**;拒绝发生在读取会话记录**之前** | `TestAuthTokens.writeSession` 自身执行 `SELECT COUNT(*) FROM auth_tokens WHERE user_id=? AND session_id=? AND revoked=0 AND consumed_at IS NULL AND expired_at > ?`,不可见即抛 `IllegalStateException`;日志中**无该异常**,说明会话行有效。而 `isSessionActive()` 先判用户、后判会话,拒绝文本是 "user not usable" 而非 "no active session" | +| 5 | JWT 的 `sid/jti/sub/type/aud/iss` 是否与库一致 | **一致**;JWT 本身完全合法 | `parseAndValidateAccessToken` **未抛异常**(否则拒绝路径不同)。日志中 Token 载荷:`sub=1`、`type=access`、`sid=0073abf7-…`、`role=ADMIN`、`iss=mydreamjob`、`aud=["mydreamjob-api"]`、`iat=1577836800`(2020-01-01)、`exp=2523916800`(2050-01-01),与夹具 `FIXTURE_BASE`/30 年 TTL 完全对应;`sid` 即写入 `auth_tokens.session_id` 的同值 | +| 6 | 是否真的与 `user_roles` 有关 | **无关** | `AuthSessionService.isUsable(User)` 只读 `user.getActive()`;`UserRepository.findById` 无角色过滤;该路径**从不查询 `user_roles`** | + +### 9.3 准确根因(证据链,取代此前未经证实的 `user_roles` 说法) + +**根因:`TestAuthTokens` 夹具只对"不存在的" `users.id` 执行 INSERT,对"已存在但 `is_active=0`"的行不做激活,而 `V1_1` 种子账号恰好占用 id=1/2/3 并被 `V43` 置为禁用。** + +证据链(逐步闭合): + +1. `V1_1__seed_data.sql` 按自增插入三名演示账号 → 占据 `users.id = 1 / 2 / 3` + (`13800000001` ADMIN、`13800000002` STUDENT、`13800000003` COMPANY)。 +2. `V43__disable_legacy_seed_accounts.sql` 按原始指纹把这三行 `is_active` 置为 **0**。 +3. `FairnessMonitoringControllerTest` 使用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")` + → 命中 id=1/2 这两个**已被禁用的种子行**。 +4. `TestAuthTokens.writeSession()` 的行为是: + ```java + SELECT COUNT(*) FROM users WHERE id = ? // id=1 已存在 → count = 1 + if (count == null || count == 0) { INSERT ... is_active=1 } // → 被跳过,永不激活 + ``` + 即夹具**只补建缺失行,不修复既有行的启用状态**。 +5. `users.created_at` / `updated_at` 在 `V1__base_schema.sql` 中均有 + `DEFAULT CURRENT_TIMESTAMP`,因此"若行不存在则夹具 INSERT 必然成功且 `is_active=1`"成立。 + 既然实际观察到 401(而非 500/ERROR),**只可能是行已存在且 `is_active=0`** —— 推理闭合。 +6. `AuthSessionService.isSessionActive()` 第一步 `isUserUsable(userId)` 因此返回 false, + 直接短路返回,**从未走到会话记录检查**,与日志文本完全吻合。 + +### 9.4 与 A6 生产改动的归因判定:**无关** + +| 判据 | 内容 | +|---|---| +| 代码路径 | 401 由 `JwtAuthenticationFilter` / `AuthSessionService` 在**请求进入 Controller 之前**产生(`Handler: Type = null`)。A6 只改了 5 个 Controller 内部取 requestId / 取身份的写法,**该路径上一行 A6 代码都未执行** | +| A6 diff 范围 | 仅 5 个 Controller + 文档;`SecurityConfig`、Filter、`AuthSessionService`、`UserRepository`、`TestAuthTokens`、Flyway 迁移**零改动** | +| 失败面 | 7 个用例中,唯一不携带 Token 的用例通过;其余全部 401 —— 与「Token 认证链路在用户检查处失败」完全一致,与「Controller 被改写」不一致 | + +### 9.5 为何全量顺序下不触发(机制已定位,非"偶然") + +`com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach`(源码第 97 行)执行 +`userRepository.deleteAll();`,在**共享的 `integrationdb`**(无 `@TestPropertySource` 覆盖)上 +**清空全部用户,包括 V43 已禁用的 V1_1 种子行**。 + +全量日志 `docs/acceptance/r01-d4-2-20260916/a3-commit-cv.log` 的执行顺序印证: + +``` +第 151767 行 [INFO] Running com.mydreamjob.controller.AuthIntegrationTest +第 247038 行 [INFO] Running com.mydreamjob.scoringmonitoring.FairnessMonitoringControllerTest +``` + +种子行被清空后,夹具第 4 步的 `COUNT(*)` 变为 0 → **INSERT 补建 `is_active=1` 的用户** → +用户检查通过 → 该类在全量顺序下 7/7 通过。 + +因此:**同一份代码,只因子夹具前置数据不同而结果不同 —— 属顺序依赖的夹具脆弱性, +不是被测代码的缺陷。** + +**决定性对照实验**(不新增任何文件、不修改任何代码,只改变前置数据): + +``` +mvn -Dtest="com.mydreamjob.controller.AuthIntegrationTest,FairnessMonitoringControllerTest" test +``` + +日志:`docs/acceptance/r01-d4-2-20260916/a6v-fairness-mechanism.log` + +``` +[INFO] Running com.mydreamjob.controller.AuthIntegrationTest +[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in ...AuthIntegrationTest +[INFO] Running com.mydreamjob.scoringmonitoring.FairnessMonitoringControllerTest +[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 -- in ...FairnessMonitoringControllerTest +[INFO] BUILD SUCCESS +``` + +| 运行方式 | 同一份含 A6 的代码 | 结果 | +|---|---|---| +| 单独运行 Fairness | A6 在场 | **7 run / 6 failures** | +| AuthIntegrationTest 先运行 | A6 在场 | **7 run / 0 failures** | +| 全量 `mvn clean verify` | A6 在场 | **7 run / 0 failures** | + +**A6 代码在三行中完全相同,唯一变量是前置数据** —— 归因判定为夹具脆弱性的直接实验证据。 + +### 9.6 处置 + +- **不修改生产代码** ✔ +- **不修改既有 `FairnessMonitoringControllerTest`** ✔ +- 该缺陷列为 **A6 提交后的独立纯测试修复**:让 `TestAuthTokens` 在行已存在时 + 显式确保 `is_active = 1`(或改用不与 V1_1 种子账号冲突的 userId)。 +- **未把该单跑命令写成通过** ✔ + +--- + +## 10. 全仓剩余统计(逐文件分类) + +**身份解析**(`SecurityContextHolder.getContext().getAuthentication` / +`Long.(valueOf|parseLong)(...getName())`)—— **3 文件** + +| 文件 | 分类 | +|---|---| +| `security/CurrentUserAccessor` | 基础设施(Accessor 自身) | +| `auth/JwtAuthenticationFilter` | 基础设施(认证过滤器) | +| `deletion/DeletionGuardInterceptor` | 基础设施(拦截器) | + +**requestId 解析**(`X-Request-Id` 直读)—— **5 文件** + +| 文件 | 分类 | +|---|---| +| `audit/AuditContext` | 基础设施 | +| `common/logging/RequestIdAccessor` | 基础设施(Accessor 自身) | +| `common/logging/RequestIdFilter` | 基础设施(过滤器) | +| `common/logging/RequestLoggingFilter` | 基础设施 | +| `deletion/DeletionGuardInterceptor` | 基础设施 | + +**业务 Controller 残留:身份解析 0、requestId 重复解析 0** ✔ + +--- + +## 11. 测试数 + +### 11.1 A6 定向 + +``` +mvn -Dtest="R01A6RequestContextMigrationIntegrationTest,A6*Test,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test +``` + +日志:`docs/acceptance/r01-d4-2-20260916/a6v-v1-targeted.log` + +| 测试类 | Tests | +|---|---| +| `common.r01a6.R01A6RequestContextMigrationIntegrationTest` | 7 | +| `common.r01a6.A6AccessorWiringProbeTest` | 3 | +| `common.r01a6.A6AccessorWiringIndependentTest` | 6 | +| `common.r01a6.A6IndependentVerificationTest` | 7 | +| `security.CurrentUserAccessorTest` | 11 | +| `common.logging.RequestIdAccessorTest` | 16 | +| `common.logging.RequestIdFilterTest` | 3 | +| **合计** | **53 / 53**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS ✔ | + +### 11.2 A6 关联 + +``` +mvn -Dtest="*ApiController*Test,*AuthController*Test,*SystemController*Test,*JobController*Test,*Security*Test,*RequestId*Test" test +``` + +日志:`docs/acceptance/r01-d4-2-20260916/a6v-v2-related.log` + +| 测试类 | Tests | +|---|---| +| `auth.SmsVerificationSecurityTest` | 10 | +| `common.logging.RequestIdAccessorTest` | 16 | +| `common.logging.RequestIdFilterTest` | 3 | +| **合计** | **29 / 29**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS ✔ | + +> **说明**:该命令的 `*ApiController*Test`、`*AuthController*Test`、`*SystemController*Test`、 +> `*JobController*Test` 四个模式在本仓库**不存在同名测试类**(`find` 全量核对: +> 无 `ApiControllerTest` / `SystemControllerTest` / `JobControllerTest` / `AuthControllerTest`), +> 故只命中上表 3 个类。这 5 个 Controller 的既有覆盖由 +> `mvn clean verify` 全量承担(含 `AuthenticationBoundaryIntegrationTest` 对 `POST /jobs` +> 的 403 断言、`controller.AuthIntegrationTest` 的登录/刷新/登出全链路等)。**未因此放宽任何断言。** + +### 11.3 全量 + +``` +mvn clean verify +``` + +日志:`docs/acceptance/r01-d4-2-20260916/a6v-v3-verify.log` + +``` +[INFO] Tests run: 2278, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +| 项 | 数值 | +|---|---| +| 全量测试总数 | **2278** | +| Failures | **0** ✔ | +| Errors | **0** ✔ | +| Skipped | **0** ✔ | +| 原实施基线 | 2265 | +| 独立新增 | **13**(`A6AccessorWiringIndependentTest` 6 + `A6IndependentVerificationTest` 7) | +| 2265 + 13 | **2278** ✔ 与 Surefire 汇总完全一致 | + +**无需要按 Surefire XML 另行解释的差异** —— 汇总行与预期逐位吻合。 + +### 11.4 `FairnessMonitoringControllerTest` 单跑(**不计入通过**) + +| 命令 | 结果 | +|---|---| +| `mvn -Dtest="FairnessMonitoringControllerTest" test` | **7 run / 6 failures / 0 errors / 0 skipped,BUILD FAILURE** | + +按任务书要求:**该单跑命令不写成通过**,其失败与准确根因见 §9。 + +--- + +## 12. OpenAPI + +``` +python ..\docs\verify_openapi.py +→ paths: 176 / operations: 214 +→ All checks passed! +``` + +`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` 零修改 ✔ + +--- + +## 13. Flyway / ErrorCode / DTO / 接口 + +| 项 | 结果 | +|---|---| +| `git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` | **空** —— V1–V45 未改动 ✔ | +| `ErrorCode` | 未修改 ✔ | +| DTO / 响应体结构 | 未修改(仅取值来源改变) ✔ | +| 接口(路径 / 方法 / 参数) | 未新增、未删除、未改签名 ✔ | +| 依赖(`pom.xml`) | 未修改 ✔ | + +--- + +## 14. Git 状态 + +| 项 | 结果 | +|---|---| +| 分支 | `refactor/r01-structure-performance` | +| HEAD | `534afe686b966f175bcaf4af0726cec061092050`(未变) | +| `git diff --cached --name-only` | **空** | +| `git diff --check` | 仅 LF/CRLF 警告,exit 0 | +| 已暂存 / 已提交 / 已推送 | **均无** ✔(`git log -1` 仍为 A5 提交 `534afe6`) | +| 与远端关系 | `## refactor/r01-structure-performance...origin/refactor/r01-structure-performance`(无 ahead/behind) | +| 历史未跟踪文件 | 未删除 ✔ | +| 本轮验收日志 | 位于 `docs/acceptance/r01-d4-2-20260916/`,该路径被 `.gitignore:43 **/*.log` 忽略(`docs/acceptance` 受版本管理的文件数为 0),**不会进入任何提交** ✔ | +| 残留 Java/Maven 进程 | 无 ✔ | + +--- + +## 15. 明确声明 + +- **未暂存、未提交、未推送。** +- 未修改任何生产代码;未修改、删除或放宽任何既有测试断言; + 未修改 `GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、 + OpenAPI、Flyway 迁移与 V1–V45。 +- 本轮仅新增 `docs/r01-a6-independent-verification-20260917.md` 与验收日志; + 独立测试自身的两处缺陷已就地修正(§5.1),未触碰生产代码与既有测试。 +- 未进入 R01-B;**A 系列到此结束,不再设置 A7/A8**。 +- 本轮未启动 Docker,**未伪造真实 MySQL 结论**。 + +--- + +> **R01-A6独立代码复验通过,可以进入精确暂存与独立提交检查点;A系列到此结束,不再设置A7/A8。FairnessMonitoringControllerTest的单跑夹具缺陷须在A6提交后以独立纯测试提交修复。** diff --git a/docs/r01-b-authorization-exception-mapping-report.md b/docs/r01-b-authorization-exception-mapping-report.md new file mode 100644 index 0000000..288a5fb --- /dev/null +++ b/docs/r01-b-authorization-exception-mapping-report.md @@ -0,0 +1,497 @@ +# R01-B 角色授权与异常映射收束报告 + +日期:2026-09-17 +分支:`refactor/r01-structure-performance` +HEAD:`84fe9f5febcf56910d5c978dd44e0e16d15d7209`(A6 后夹具修复提交) +本轮性质:**实施与自动化验证** —— 未暂存、未提交、未推送,待独立代码复验 +范围:**B1 角色授权重复收束 + B2 异常到 HTTP 状态映射收束**(不拆分子阶段,不进入 R01-C/E) + +--- + +## 0. 结论 + +> **R01-B角色授权与异常映射收束完成,待独立代码复验。** + +- 角色授权重复逻辑收束到唯一实现点 `security/UserRoleGuard`,**54 个调用点**改走公共 guard; + 角色 guard 方法定义 **32 → 21**,`getRoles()` 角色比较 **46 处 / 31 文件 → 23 处 / 16 文件**; +- HTTP 状态映射收束到 `common/exception/ErrorHttpStatusRegistry`,`GlobalExceptionHandler` + 不再持有映射表;**112 个 ErrorCode 的映射逐项保持不变**,未映射的 54 个仍回退 400; +- 收束过程中发现一处既有潜在缺陷(`ErrorCode` 166 个常量只有 165 个唯一数字码), + **已记录、未擅自修改**(见 §9.4)。 + +--- + +## 1. 前置 Git 状态 + +| 项 | 结果 | +|---|---| +| `git branch --show-current` | `refactor/r01-structure-performance` | +| `git rev-parse HEAD` | `84fe9f5febcf56910d5c978dd44e0e16d15d7209` | +| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` | +| `git diff --cached --name-only` | **空** | +| `git diff --check` | 仅 LF/CRLF 警告,**exit 0** | +| 与目标文件重叠的未提交修改 | **无**。`GlobalExceptionHandler.java` 在 `git status` 中显示 `M`,但 `git hash-object` = `git rev-parse HEAD:` = `1fdc341e…`,`git diff --quiet` exit 0 —— 属 Windows stat 缓存假阳性,非真实修改 | + +--- + +## 2. 角色授权库存(重构前) + +搜索范围:`mydreamjob-server/src/main/java`。 +重构前共 **32 个**角色 guard 方法定义 + **7 个** `private boolean hasRole(User, Role)` 助手。 + +**共同语义(近乎逐字相同的复制)**: + +``` +User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); +boolean ok = user.getRoles().stream().anyMatch(r -> r == User.Role.X); // user_roles +if (!ok) throw new BusinessException(ErrorCode.FORBIDDEN); +``` + +| 项 | 统一事实 | +|---|---| +| 输入来源 | 已解析出的 `userId`(由 `CurrentUserAccessor` 或调用方传入),**没有任何一处从请求参数取角色** | +| 角色来源 | **`user_roles` 关联表**(`user.getRoles()`)—— 31 个文件、46 处 `getRoles()`;`users.role` 单值列**不**参与授权判定(它只用于 JWT 声明签发) | +| 失败错误码 | 用户不存在 → `USER_NOT_FOUND`(4001);角色不符 → `FORBIDDEN`(2002) | +| HTTP 状态 | 4001 → 404;2002 → 403(经 §3 的映射表) | +| 是否含资源归属 | **不含**。归属(如 `assertOwnerOrThrow`、`findByOwnerUserId`)由各业务 Service 独立执行 | +| 是否允许 ADMIN | **不允许**(无隐式超级权限);`requireAny` 由调用方显式列出 ADMIN 才放行 | +| 可否安全委托 | 纯 void 角色门禁 → **可**;返回 `Company` 实体的 `requireCompany` → **不可整体委托**(见 §7) | + +--- + +## 3. HTTP 映射库存(重构前) + +`GlobalExceptionHandler.buildBusinessHttpStatuses()`(第 71–136 行)+ `register()`(第 138–147 行) ++ `resolveHttpStatus()`(第 67–69 行),共 **112 个 `ErrorCode` 引用**: + +| HttpStatus | 数量 | 备注 | +|---|---|---| +| CONFLICT | 77 | | +| NOT_FOUND | 27 | | +| FORBIDDEN | 5 | `FORBIDDEN`、`INTERVIEW_ACCESS_DENIED`、`APPLICATION_OWNER_MISMATCH`、`MATCH_REQUIRED_FOR_CONVERSATION`、`OFFER_COMPARE_NOT_OWNER` | +| TOO_MANY_REQUESTS | 3 | | +| **合计** | **112** | | + +| 项 | 结果 | +|---|---| +| 重复键 | **0**(112 个 `ErrorCode` 常量互不相同) | +| 重复项映射一致性 | 不适用 | +| 未显式映射的 ErrorCode | **54 / 166** → 一律回退 **HTTP 400** | +| 关键既有语义 | `UNAUTHORIZED(2001)` **未显式映射 → 400**(不是 401);`SERVICE_UNAVAILABLE`、`RATE_LIMITED`、`INTERNAL_ERROR` 同为 **400** | +| 已有测试锁定状态码 | **有**:`GlobalExceptionHandlerStatusContractTest` 以 `@EnumSource(ErrorCode.class)` 对**全部 166 个**错误码锁定状态 + 业务码 + message + requestId | + +**快照已保存**:`docs/acceptance/r01-b/b2-mapping-baseline.json`(112 条 `名称 → 状态`), +并逐条转写进 `GlobalExceptionHandlerMappingParityTest` 作为独立基线。 + +--- + +## 4. 修复前失败测试(红灯) + +| 观察 | 说明 | +|---|---| +| 角色授权 | 重构前**没有**公共 guard:32 个方法各自实现,无任何调用点经过统一入口。新增的 `RoleAuthorizationIntegrationTest` 以 `@SpyBean UserRoleGuard` 断言"端点确实调用公共 guard"——在重构前该 bean 不存在,测试无法编译/通过 | +| HTTP 映射 | 重构前映射表在 `GlobalExceptionHandler` 内部,**无法在初始化期检出重复键**(`register()` 仅在"同码不同状态"时抛错,同码同状态被静默容忍);新增的 `ErrorHttpStatusRegistryTest` 直接断言"同码重复注册必须抛 `IllegalStateException`"——重构前无从表达 | +| 未改变任何既有业务期望 | `GlobalExceptionHandlerStatusContractTest` 的 4 个状态桶与全部断言**逐字保留**,仅把其反射调用私有方法的用例改为调用 `registry.resolve(int)` | + +--- + +## 5. 公共角色 guard 设计 + +新增 `security/UserRoleGuard.java`(`@Component`,仅依赖 `UserRepository`)。 + +```java +void requireStudent(Long userId); void requireStudent(User user); +void requireCompany(Long userId); void requireCompany(User user); +void requireAdmin(Long userId); void requireAdmin(User user); +void requireAny(Long userId, User.Role... allowedRoles); +void requireAny(User user, User.Role... allowedRoles); +User requireUser(Long userId); // 存在性 + active 校验 +void requireRole(User user, User.Role... allowedRoles); +``` + +| 要求 | 落实 | +|---|---| +| 角色来自权威数据库 | 从 `user_roles`(`user.getRoles()`)读取,与全部被替换的实现一致 | +| 校验存在且 active | `requireUser` 不存在 → `USER_NOT_FOUND`;**存在但 inactive → `FORBIDDEN`(失败关闭)** | +| `users.role` 与 `user_roles` 核对 | 授权**只**依据 `user_roles`;`users.role` 与之不一致时按 `user_roles` 判定 → 缺少所需角色即拒绝(失败关闭)。**混用两者会改变既有语义,故未采纳** | +| 复用现有 ErrorCode | 只用 `USER_NOT_FOUND` / `FORBIDDEN`,**未新增任何 ErrorCode** | +| 不解析 SecurityContext | 只接收 `userId`;结构断言验证其声明字段**只有** `UserRepository` | +| 不处理 requestId | 无 `HttpServletRequest`、无 `RequestIdAccessor` | +| 不处理资源归属 | 类中无任何 ownership 逻辑 | +| 不记录敏感信息 | 无日志、无审计写入,无副作用 | +| 无隐式 ADMIN | `requireStudent`/`requireCompany` 对 ADMIN 同样拒绝,测试已锁定 | +| 查询次数受控 | `requireXxx(Long)` 恰好 **1** 次 `findById`;`requireXxx(User)` **0** 次;**无跨请求缓存**(用户被禁用/注销后立即失效) | + +检索确认:仓库中**原本不存在** `UserRoleGuard` 或等价的公共角色组件,故新增; +未新建重复角色枚举(复用既有 `User.Role`)。 + +--- + +## 6. 每个迁移调用点(54 处) + +`userRoleGuard.requireStudent` **37** 处、`requireAdmin` **13** 处、`requireCompany` **4** 处, +覆盖 **21 个生产文件**。 + +| # | 文件 | 原方法 | 迁移后 | +|---|---|---|---| +| 1 | `ability/AbilityAttemptService` | `requireStudent`(私有) | 删除,6 处改调 guard | +| 2 | `assessment/AssessmentAttemptService` | `requireStudent`(私有) | 删除,5 处改调 guard | +| 3 | `bidirectionalmatch/MatchRuleService` | `requireStudent`(私有) | 删除,2 处改调 guard | +| 4 | `careerprogress/CareerProgressService` | `assertStudentRole`(私有) | 删除,1 处改调 guard | +| 5 | `competition/CompetitionRankingService` | `assertStudentRole`(私有) | 删除,1 处改调 guard | +| 6 | `job/JobFavoriteService` | `assertStudentRole`(私有) | 删除,3 处改调 guard | +| 7 | `match/MatchService` | `assertStudentRole`(私有) | 删除,1 处改调 guard | +| 8 | `review/JobReviewService` | `assertStudentRole`+`assertAdminRole`(私有) | 删除,3+3 处改调 guard | +| 9 | `talentdiscovery/TalentDiscoveryService` | `requireStudent`(私有) | 删除,2 处改调 guard | +| 10 | `talentinvitation/TalentInvitationService` | `requireStudent`(私有) | 删除,3 处改调 guard | +| 11 | `recommendation/JobRecommendationService` | `requireStudent`+`requireAdmin`(私有) | 删除,3+2 处改调 guard | +| 12 | `candidatescore/governance/CandidateScoringGovernanceService` | `assertStudentRole`+`assertAdminRole`(私有) | 删除,3+4 处改调 guard | +| 13 | `candidatescore/dashboard/CompanyScoringDashboardService` | `assertAdminRole`(私有) | 删除,1 处改调 guard | +| 14 | `application/ApplicationController` | `assertCompanyRole`(私有) | 删除,3 处改调 guard | +| 15 | `safety/AdminReportController` | `requireAdmin(HttpServletRequest)` | 保留方法(返回 userId),角色部分委托 guard,3 处调用不变 | +| 16 | `risk/AdminRiskController` | `requireAdmin(HttpServletRequest)` | 同上,7 处调用不变;随之移除已不需要的 `UserRepository` 字段与 import | +| 17 | `company/CompanyService` | `assertStudentRole`+`assertCompanyRole`(公开) | 保留签名,方法体改为一行委托 | +| 18 | `profile/ProfileService` | `assertStudentRole`(公开) | 保留签名,一行委托 | +| 19 | `preference/PreferenceService` | `assertStudentRole`(公开) | 保留签名,一行委托 | +| 20 | `privacy/PrivacySettingsService` | `assertStudentRole`(公开) | 保留签名,一行委托 | +| 21 | `verification/CompanyVerificationService` | `assertAdminRole`(公开) | 保留签名,一行委托 | + +**公开方法为何保留签名**:它们已被跨包调用(如 `profileService.assertStudentRole` 被 +`ResumeVersionService`、`ResumeExportService`、`ProfileController` 等调用; +`companyService.assertStudentRole` 被 `CandidateScoreCardController`、`CandidateScoringController`、 +`StudentReviewWeightService` 调用)。保留签名、把**实现**收敛为一行委托,可在不扩大改动面的前提下 +消除重复逻辑。**每个调用点的原错误码(`USER_NOT_FOUND`/`FORBIDDEN`)、原角色集合、 +原调用顺序均未改变。** + +--- + +## 7. 保留调用点及理由(非等价,未迁移) + +| 类别 | 数量 | 保留理由 | +|---|---|---| +| `private Company requireCompany(Long)` | **7** —— `MatchRuleService`、`InterviewService`、`OfferService`、`CompanyReviewManagementService`、`CompanyReviewReplyService`、`CompanyVerificationService`、`CompanyScoringDashboardService` | **不是纯角色门禁**:除角色判定外还承担实体查找与归属语义(`companyRepository.findByOwnerUserId(userId).orElseThrow(COMPANY_NOT_FOUND)`),返回值被业务逻辑使用。整体委托公共 guard 会改变查询次数与错误码(`COMPANY_NOT_FOUND` vs `USER_NOT_FOUND`),**按任务书要求不迁移不等价逻辑** | +| `private boolean hasRole(User, Role)` | **7** —— `InterviewService`、`InterviewScoreService`、`MessagingService`、`OfferService`、`TalentDiscoveryService`、`TalentInvitationService`、`CompanyVerificationService` | 用于**资源归属/可见性分支**(如 `hasRole(user, STUDENT) && interview.getStudentUserId().equals(userId)`、`MessagingService` 中 ADMIN 可见性分支),不是"必须具有某角色"的门禁。**角色授权与资源归属是两件事**,迁移会改变语义 | +| `AdminReportController`/`AdminRiskController.requireAdmin(HttpServletRequest)` | 2 | 第 15/16 行已委托 guard;保留该方法是因为它还负责返回 `userId`,属 Controller 内的便捷封装 | +| `CompanyService` 等 5 个公开方法 | 5 | 见 §6 末尾说明 | + +**未为"grep 为零"迁移任何不等价逻辑。** + +--- + +## 8. 角色 / 资源归属边界 + +| 边界 | 事实 | +|---|---| +| 身份("我是谁") | 仍由 `CurrentUserAccessor` 负责;`UserRoleGuard` **不**解析 SecurityContext(结构测试断言其唯一依赖是 `UserRepository`) | +| 角色授权("我能不能") | 收束到 `UserRoleGuard` | +| 资源归属("这条数据是不是我的") | **仍由各业务 Service 执行**,未删除、未移动。`RoleAuthorizationIntegrationTest.ownershipRemainsWithBusinessService` 用"角色正确但无企业实体的 COMPANY 用户"访问 +`/company/candidates/{id}` → 得到 **404**(业务层归属/存在性判定),证明 guard 既没有替代也没有跳过归属校验 | +| 403 / 404 分层 | 保持既有:角色不符 403(2002);资源不存在 404(如 `RISK_EVENT_NOT_FOUND`) | + +--- + +## 9. B2 实现与逐项一致性 + +### 9.1 实现 + +新增 `common/exception/ErrorHttpStatusRegistry.java`: + +```java +public HttpStatus resolve(ErrorCode errorCode); +public HttpStatus resolve(int errorCode); // BusinessException 只暴露数字 code +public HttpStatus defaultStatus(); // 400 +public int explicitMappingCount(); // 112 +public static Map explicitMappingsView(); +public static Map numericMappingsView(); // 111 +``` + +| 要求 | 落实 | +|---|---| +| 不可变映射 | 构建后 `Map.copyOf(...)`,运行期 `put`/`remove` 抛 `UnsupportedOperationException`(测试锁定) | +| 键类型 | **`ErrorCode` 常量**(见 §9.4);数字入口由该映射派生 | +| 初始化禁止重复键 | `Builder.put` 用 `putIfAbsent`,**同码重复注册即抛 `IllegalStateException`**(无论目标状态是否相同) | +| 显式映射集中一处 | 只在 `explicitMappings()` 内,全部 `ErrorCode.X` 符号,**无手写数字** | +| 未映射默认 400 | `DEFAULT_STATUS = BAD_REQUEST`,与重构前 `getOrDefault(code, BAD_REQUEST)` 一致 | +| 不引入反射扫描 | 无反射 | +| 不使用可运行时修改的 Map | 静态 final + `Map.copyOf` | +| Handler 只调用 registry | `GlobalExceptionHandler` 删除 `BUSINESS_HTTP_STATUSES`、`buildBusinessHttpStatuses()`、`register()`、`resolveHttpStatus()`;`handleBusinessException` 改为 `errorHttpStatusRegistry.resolve(errorCode)` | +| 同码映射到不同状态 | **未发现**(唯一同数字码的两个常量映射到同一状态,见 §9.4) | + +`ErrorHttpStatusRegistry` 通过 `@Import` 挂在 `GlobalExceptionHandler` 上,使 `@WebMvcTest` 切片 +(`CompanyControllerMvcTest`)无需改动即可解析到该组件。 + +### 9.2 响应契约未变 + +- 响应 JSON 结构、字段、业务码、message 全部不变(`@EnumSource` 全量断言) +- requestId 仍来自 `RequestIdAccessor`,测试断言 `"r01b-contract"` / `"r01b-parity"` +- 日志级别未变(`handleBusinessException` 仍为 `log.warn`) +- 未知 code 仍回退 **400** +- `ErrorCode` 数字、枚举成员**零修改**(`git diff` 为空) +- 未新增接口、DTO、依赖 + +### 9.3 逐项一致性证明 + +`GlobalExceptionHandlerMappingParityTest`: + +1. **显式映射集合** = 重构前快照的 112 个 `ErrorCode`(对**全部 166 个**错误码逐一核对: + 快照中未显式映射的,重构后也不得显式映射); +2. **处理器实际返回状态** ≡ **registry 解析结果** ≡ **快照基线**(`@EnumSource` 166 参数化); +3. 未映射码(含 `UNAUTHORIZED(2001)`、`SERVICE_UNAVAILABLE`)保持 **400**; +4. `FORBIDDEN` 保持业务码 **2002** + HTTP **403**; +5. NOT_FOUND 类 404、CONFLICT 类 409、限流类 429; +6. 响应体不含异常类名、堆栈、内部路径。 + +### 9.4 收束过程中发现的既有潜在缺陷(已记录,未修改) + +**`ErrorCode` 166 个常量只有 165 个唯一数字码**:`REVIEW_ALREADY_DECIDED(9103)` 与 +`DUPLICATE_FEEDBACK(9103)` 共用同一数字码。 + +- 重构前该重复被 `register()` **静默容忍**(`putIfAbsent` 只在"同码不同状态"时抛错); +- 本轮的严格重复检测因此**在启动期暴露了它**; +- 二者当前都映射到 **CONFLICT**,故数字入口无歧义、行为与重构前完全一致; +- 按任务书"不修改 `ErrorCode` 数字、不新增 `ErrorCode`"的要求,**本轮不修改**, + 仅把映射键改为 `ErrorCode` 常量(这也正是"键必须是 ErrorCode 或稳定错误码类型"的实践理由), + 并以 `ErrorHttpStatusRegistryTest.distinctConstantsWithSharedNumericCodeAreAllowed` 锁定该事实。 + +**处置建议(留待后续独立提交)**:为 `DUPLICATE_FEEDBACK` 分配唯一数字码。若两个常量被映射到 +**不同状态**,注册表的 `indexByCode` 会在初始化期直接抛错,不会静默任选其一。 + +--- + +### 9.5 量化结果(前后对比) + +| 指标 | 重构前 | 重构后 | 说明 | +|---|---|---|---| +| 角色 guard 方法定义总数 | **32** | **21** | 其中 6 个是 `UserRoleGuard` 自身;6 个是保留签名的公开方法(方法体已是一行委托);8 个是实体返回型 `requireCompany`(非等价,保留);2 个是 Controller 的 `requireAdmin(request)`(已委托) | +| 重复 student guard 实现 | **16** | **1**(公共 guard) | 其余为公开委托方法/实体返回型 | +| 重复 company guard 实现 | **2**(纯角色门禁)+ 7(实体返回型) | **1**(公共 guard);7 个实体返回型保留 | +| 重复 admin guard 实现 | **7** | **1**(公共 guard) | | +| 已迁移调用点 | 0 | **54** | 37 student + 13 admin + 4 company | +| 明确保留的非等价调用点 | — | **14** | 7 个实体返回型 `requireCompany` + 7 个归属/可见性用 `hasRole` | +| `getRoles()` 角色比较 | **46 处 / 31 文件** | **23 处 / 16 文件** | 减少 50% | +| `GlobalExceptionHandler` 行数 | **232** | **146** | 净减 **86 行**;映射表、`register()`、`resolveHttpStatus()` 全部移出 | +| `resolveHttpStatus` 条件链 | 映射构建 66 行 + 注册 10 行 + 解析 3 行 | **0**(逻辑迁入 `ErrorHttpStatusRegistry`,261 行,含 javadoc 与显式 112 条映射) | | +| `ErrorCode.X.getCode()` 用于状态比较的次数 | 0(原为 `Map` 查找) | **0** | 状态解析无数字字面量比较 | +| 重复 HTTP 映射数 | 0(且**不可检测**) | **0**(且**初始化即拒绝**) | | +| 未映射 ErrorCode 数 | **54 / 166** → 400 | **54 / 166** → 400 | 逐项不变 | +| 角色授权 DB 查询次数 | 每处 1 次 `findById` | `requireXxx(Long)` **1 次**;`requireXxx(User)` **0 次**;无跨请求缓存 | | + +--- + +## 10. 查询次数 + +| 场景 | 数据库查询次数 | +|---|---| +| `requireStudent/Company/Admin/Any(Long userId)` | **恰好 1** 次 `users.findById`(`RoleAuthorizationGuardTest.queryCountIsBounded` 以 Mockito `times(1)` 锁定) | +| `requireStudent/Company/Admin/Any(User user)` | **0** 次(供已加载实体的调用点使用) | +| 一个请求内相同用户重复角色检查 | 调用方已加载实体时应使用实体重载;现有调用点每个请求路径至多 1 次 guard 调用 | +| 跨请求缓存 | **无**(不缓存 active 状态,注销/禁用后立即失效) | +| Redis 依赖 | **无** | +| 事务边界 | **未改变**(guard 只读、无 `@Transactional`) | +| 单次授权的 N+1 | **无**:guard 为单条主键查询,`user_roles` 由 JPA 关联加载 | + +--- + +## 11. 测试结果 + +日志目录:`docs/acceptance/r01-b/` + +| # | 命令 | 结果 | 日志 | +|---|---|---|---| +| 1 | `mvn -Dtest="RoleAuthorizationGuardTest,RoleAuthorizationIntegrationTest,ErrorHttpStatusRegistryTest,GlobalExceptionHandlerMappingParityTest" test` | **371 / 371**,F=0 E=0 S=0 ✔ | `b1b2-tests.log` | +| 2 | `mvn -Dtest="*Security*Test,*Authorization*Test,*Role*Test,*Admin*Controller*Test,*Company*Controller*Test,*Student*Controller*Test,*GlobalExceptionHandler*Test,*BusinessException*Test" test` | **448 中 6 项失败** —— 全部为**既有夹具缺陷**(§11.3),非本轮引入 | `b1-related.log` | +| 3 | `mvn test` | **2656 / 2656**,Failures=**0**,Errors=**0**,Skipped=**0**,BUILD SUCCESS ✔ | `b-full-mvntest.log` | +| 4 | `mvn clean verify` | 见 §11.4 | `b-clean-verify.log` | +| 5 | `python ..\docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ | — | + +### 11.1 新增测试逐类结果 + +| 测试类 | Tests | 覆盖 | +|---|---|---| +| `RoleAuthorizationGuardTest` | 16 | STUDENT/COMPANY/ADMIN 通过各自 guard;跨角色拒绝;ADMIN 无隐式超级权限;inactive 与 `active=null` 失败关闭;角色缺失;多角色;`users.role` 与 `user_roles` 不一致失败关闭;错误码/HTTP 契约;不解析 requestId/SecurityContext;查询次数 | +| `RoleAuthorizationIntegrationTest` | 10 | 三个真实端点(ADMIN `/admin/scoring/risk/overview`、STUDENT `/profile/me/career-progress`、COMPANY `/company/candidates`)经真实 SecurityFilterChain 验证 401/403/404 分层;接线变异(`@SpyBean` 断言 guard 确被调用);资源归属仍由业务层判定 | +| `ErrorHttpStatusRegistryTest` | 174 | 112 条显式映射、111 个唯一数字码、四状态白名单、逐 ErrorCode 自洽、未映射回退 400、重复键初始化拒绝、不可变、键必须为 ErrorCode、9103 共用数字码 | +| `GlobalExceptionHandlerMappingParityTest` | 171 | 显式映射集合 = 重构前快照;处理器返回 ≡ registry ≡ 快照(166 参数化);FORBIDDEN 2002/403;`UNAUTHORIZED(2001)` 保持 400;服务不可用保持 400;响应体无内部细节 | + +### 11.2 测试总数 + +| 项 | 数值 | +|---|---| +| R01-B 前基线 | **2285** | +| 本轮新增 | **371** | +| 预期合计 | 2656 | +| 实测 (`mvn test`) | **2656** ✔ 与预期逐位一致 | + +### 11.3 关联回归中暴露的**既有**夹具缺陷(与本轮无关,如实记录) + +`mvn -Dtest="*Admin*Controller*Test,..."` 单独运行时,**既有**的 +`AdminReviewControllerTest`(4 项)与 `CompanyReviewManagementControllerTest`(2 项) +出现 `expected 200/403/409 but was 401`。根因与 A6 独立复验 §9 定位的**同一缺陷**: + +``` +AuthSessionService - session validation rejected: user not usable +JwtAuthenticationFilter - JWT authentication rejected: session not active +``` + +- `AdminReviewControllerTest` 使用 `ADMIN_ID = 1L`;`CompanyReviewManagementControllerTest` 使用 `token(1L, "COMPANY")` / `token(2L, "STUDENT")`; +- `V1_1` 种子账号占用 `users.id = 1/2/3` 且被 `V43` 置为 `inactive`,而 `TestAuthTokens` 对"已存在但 inactive"的行不插入也不激活; +- 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `deleteAll()` 偶然清掉种子行,问题被掩盖 —— 故 §11.2 的全量结果是干净的; +- 日志中该警告恰好 6 次,分别落在上述两个类,**无一处落在本轮改动的代码路径**; +- 本轮**未修改**这两个测试类,也**未**把该失败写成通过。 + +> 该缺陷与 `FairnessMonitoringControllerTest` 属同一类,已在 A6 后以独立提交 `84fe9f5` 修复 +> **其中一个类**;其余同类类别的隔离修复应另开独立纯测试提交,不并入 R01-B。 + +### 11.4 `mvn clean verify` + +``` +[INFO] Tests run: 2656, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +| 项 | 数值 | +|---|---| +| 全量测试总数 | **2656**(不低于收束前 2285 ✔) | +| Failures / Errors / Skipped | **0 / 0 / 0** ✔ | +| 日志 | `docs/acceptance/r01-b/b-clean-verify.log` | + +--- + +## 12. OpenAPI + +``` +python ..\docs\verify_openapi.py +→ paths: 176 / operations: 214 +→ All checks passed! +``` + +`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` 零修改 ✔ + +--- + +## 13. 精确修改文件 + +**新增生产代码(2)** + +``` +mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java +mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java +``` + +**修改生产代码(22)** + +``` +ability/AbilityAttemptService.java +application/ApplicationController.java +assessment/AssessmentAttemptService.java +bidirectionalmatch/MatchRuleService.java +candidatescore/dashboard/CompanyScoringDashboardService.java +candidatescore/governance/CandidateScoringGovernanceService.java +careerprogress/CareerProgressService.java +common/exception/GlobalExceptionHandler.java ← B2 +company/CompanyService.java +competition/CompetitionRankingService.java +job/JobFavoriteService.java +match/MatchService.java +preference/PreferenceService.java +privacy/PrivacySettingsService.java +profile/ProfileService.java +recommendation/JobRecommendationService.java +review/JobReviewService.java +risk/AdminRiskController.java +safety/AdminReportController.java +talentdiscovery/TalentDiscoveryService.java +talentinvitation/TalentInvitationService.java +verification/CompanyVerificationService.java +``` + +**新增测试(5)** + +``` +common/r01b/RoleAuthorizationGuardTest.java +common/r01b/RoleAuthorizationIntegrationTest.java +common/r01b/ErrorHttpStatusRegistryTest.java +common/r01b/GlobalExceptionHandlerMappingParityTest.java +common/r01b/UserRoleGuardTestSupport.java ← 测试支持(非测试类) +``` + +**修改测试(21)** —— 仅接线,未放宽任何断言: + +``` +careerprogress/CareerProgressServiceTest.java ← 构造器参数补 guard(真实实例) +common/exception/GlobalExceptionHandler{AccountDeletion,HttpStatus,ReportBlock,StatusContract}Test.java ← 构造器补 registry +candidatescore/governance/CandidateScoringGovernanceServiceTest.java +profile/ResumeServiceTest.java +review/JobReviewDimensionServiceTest.java +assessment/AssessmentAttemptServiceTest.java +match/MatchServiceTest.java +privacy/PrivacySettingsServiceTest.java +review/JobReviewServiceTest.java +job/JobFavoriteServiceTest.java +company/CompanyServiceTest.java +preference/PreferenceServiceTest.java +competition/CompetitionRankingServiceTest.java +ability/AbilityAttemptServiceTest.java +bidirectionalmatch/MatchRuleServiceTest.java +verification/CompanyVerificationServiceTest.java +talentdiscovery/TalentDiscoveryServiceTest.java +recommendation/JobRecommendationServiceTest.java +``` + +> 这 16 个 `@InjectMocks` 单元测试类新增了 `@Mock UserRoleGuard` 并在 `@BeforeEach` +> 通过 `UserRoleGuardTestSupport.delegateToRealGuard(...)` 把 mock **委托给真实 guard**, +> 复用测试自身已 stub 的 `UserRepository`。这样"角色不符 → 403"的既有断言**语义不变**, +> 没有被削弱成"只验证调用了一次"。 + +**文档(2)** + +``` +docs/r01-b-authorization-exception-mapping-report.md (新增) +docs/backend-development-roadmap.md (更新:A 系列完成 / 84fe9f5 / R01-B 状态 / 下一阶段 R01-C) +``` + +--- + +## 14. Flyway / ErrorCode / 接口 / DTO 是否变化 + +| 项 | 结果 | +|---|---| +| `git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` | **空** —— V1–V45 未改动 | +| `ErrorCode.java` | **未修改**(数字、成员、message 全部不变) | +| 接口(路径/方法/参数/响应结构) | **未修改**(未新增、未删除、未改签名) | +| DTO | **未修改** | +| `docs/openapi-rc.json` | **未修改** | +| 依赖(`pom.xml`) | **未修改** | +| 新依赖 | **未引入**(guard 只用 Spring Data JPA 与既有 `ErrorCode`) | + +--- + +## 15. Git 状态 + +| 项 | 结果 | +|---|---| +| 分支 / HEAD | `refactor/r01-structure-performance` / `84fe9f5…`(未变) | +| `git diff --cached --name-only` | **空** | +| `git diff --check` | 仅 LF/CRLF 警告,exit 0 | +| 已暂存 / 已提交 / 已推送 | **均无** | +| 历史未跟踪文件 | 未删除 | + +--- + +## 16. 未暂存、未提交、未推送 + +**确认:未暂存、未提交、未推送。** 本轮未使用 `git add`、未创建提交、未推送; +未执行 `reset`/`restore`/`checkout`/`clean`;未操作 Docker;未修改 V1–V45。 + +--- + +## 17. 未进入 R01-C/E + +**确认:未进入 R01-C、未进入 R01-E。** 本轮未处理 `overviewStatus`、推荐权重、 +高校名单与评分阈值、N+1、会话未读数、requestId、身份主体解析、Token、评分快照、 +新接口与数据库迁移中的任何一项。 + +--- + +> **R01-B角色授权与异常映射收束完成,待独立代码复验。** diff --git a/docs/r01-b-error-status-mapping-instruction.md b/docs/r01-b-error-status-mapping-instruction.md new file mode 100644 index 0000000..e38ca50 --- /dev/null +++ b/docs/r01-b-error-status-mapping-instruction.md @@ -0,0 +1,19 @@ +# R01-B 业务异常 HTTP 状态映射收束实施指令 + +## 1. 目标 + +在不改变任何对外行为的前提下,将 `GlobalExceptionHandler.resolveHttpStatus` 的长 `if` 链收束为集中、不可变、可完整验证的整数业务码映射。 + +## 2. 硬边界 + +- 不修改 `ErrorCode` 的常量、数值或文案; +- 不新增接口、Flyway、表、索引、依赖; +- 不改变 HTTP 状态、响应业务码、message、requestId 或兜底语义; +- 必须继续按整数业务码解析,因为历史常量可能共享数值; +- 同一整数码若被登记为不同 HTTP 状态,应用初始化必须失败,不能静默覆盖; +- 未显式登记的整数码继续返回 HTTP 400; +- 不进入 R01-C/D/E,不提交、不推送。 + +## 3. 测试门槛 + +先建立全量特征测试,对 `ErrorCode.values()` 每个常量逐一断言 HTTP 状态、业务码、message、requestId,并单独验证未知整数码回退 400。随后运行异常关联测试、`mvn clean verify` 与静态 OpenAPI 校验,Failures/Errors/Skipped 必须全部为 0。 diff --git a/docs/r01-b-error-status-mapping-report.md b/docs/r01-b-error-status-mapping-report.md new file mode 100644 index 0000000..317dbf1 --- /dev/null +++ b/docs/r01-b-error-status-mapping-report.md @@ -0,0 +1,46 @@ +# R01-B 业务异常 HTTP 状态映射收束实施报告 + +> 状态:R01-B 已完成实现和自动化验证,待独立代码复验。 + +## 1. 基线与范围 + +- 分支:`refactor/r01-structure-performance` +- 起点:`0b5b849 refactor: centralize request identity and request ID handling` +- 仅修改异常 HTTP 状态解析和对应特征测试;未进入其他 R01 子阶段。 +- 开始前暂存区和已跟踪工作区为空;历史未跟踪产物完整保留。 + +## 2. 实现 + +`GlobalExceptionHandler` 使用不可变 `Map` 集中登记 403、404、409、429;未登记业务码继续回退 400。登记辅助方法使用 `putIfAbsent`,同一整数业务码若出现不同状态会抛固定初始化异常,避免静默覆盖。 + +映射键保持为整数而非 `ErrorCode`:仓库存在历史共享数值常量,生产处理入口收到的也是整数业务码。此次未修改或“修正”任何历史常量。 + +## 3. 特征测试 + +新增 `GlobalExceptionHandlerStatusContractTest`: + +- 对全部现有 `ErrorCode` 常量逐项调用正式 `handleBusinessException`; +- 独立快照四类显式状态集合,其余断言为 400; +- 同时断言业务码、message、requestId 原样保持; +- 通过反射只读调用私有解析器,证明未知整数码仍回退 400,未为测试扩大生产 API。 + +第一次定向编译曾因测试错误地调用不存在的 `BusinessException(int, String)` 构造器失败;整改为反射验证私有兜底后通过。未修改生产 API,也未删除或放宽断言。 + +## 4. 量化与验证 + +| 项目 | 结果 | +|---|---| +| `GlobalExceptionHandler` 行数 | 328 → 232 | +| `errorCode == ErrorCode...` 比较 | 121 → 0 | +| 异常定向/关联集合 | 207/207,0/0/0 | +| `mvn clean verify` | 2082/2082,Failures=0、Errors=0、Skipped=0,BUILD SUCCESS | +| OpenAPI | 176 paths / 214 operations,All checks passed | + +## 5. 边界 + +- `ErrorCode` 未修改; +- V1–V43 未修改,无新增迁移; +- 无新增公网接口、依赖、表或索引; +- 未操作 Docker; +- 未暂存、未提交、未推送; +- 未删除历史未跟踪文件。 diff --git a/docs/r01-b-independent-verification-20260918.md b/docs/r01-b-independent-verification-20260918.md new file mode 100644 index 0000000..cad28cc --- /dev/null +++ b/docs/r01-b-independent-verification-20260918.md @@ -0,0 +1,617 @@ +# R01-B 独立代码复验报告(角色授权与异常映射收束) + +日期:2026-09-18 +分支:`refactor/r01-structure-performance` +HEAD:`84fe9f5febcf56910d5c978dd44e0e16d15d7209`(A6 后夹具修复提交,复验全程未变) +复验对象:`docs/r01-b-authorization-exception-mapping-report.md` +性质:**仅复验** —— 未修改生产代码、未放宽既有断言、未暂存、未提交、未推送 + +--- + +## 0. 结论 + +> **R01-B独立代码复验通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。** + +- 角色授权:54 个调用点全部真实存在且仅经 HTTP 入口可达;保留的 14 个点**确为不等价**; + **未发现角色/归属/错误码/事务语义变化**。 +- HTTP 映射:从 `HEAD` 独立提取的 **166 项基线逐项一致(0 处不一致)**;112 显式、54 默认 400; + `UNAUTHORIZED` 仍 400、`FORBIDDEN` 仍 403、`SERVICE_UNAVAILABLE` 仍 400。 +- 9103 重复码:**运行时确实无法区分**(`BusinessException` 只保存 `int`)。未夸大为"已解决身份歧义"; + 已证明两者状态相同故 HTTP 行为一致,且"同数字不同状态"会初始化失败而非静默任选。 +- 测试委托模式:**真实**。变异实验(把委托改为无条件下放行)令 63 个负向用例中 **50 个失败**。 +- 仅有两个**既有**测试夹具缺陷(`AdminReviewControllerTest`、`CompanyReviewManagementControllerTest`), + 已用证据证明其失败发生在安全链认证阶段、**未进入 R01-B 代码**。 +- **一处需如实说明的环境偏差**:本轮 `mvn clean verify` 为 `2681 / F=0 / E=0 / Skipped=26`。 + 26 个跳过全部是 `@Testcontainers(disabledWithoutDocker = true)` 的 MySQL 用例, + 因本机 **Docker 守护进程未运行**而按设计跳过;上一轮同代码全量为 `Skipped=0` + (这些用例执行并通过)。**不声称 Skipped=0**,原因与自洽性见 §14.1。 + **不构成 R01-B 阻断项。** + +--- + +## 1. 分支、HEAD 与 Git 状态 + +| 项 | 结果 | +|---|---| +| `git branch --show-current` | `refactor/r01-structure-performance` | +| `git rev-parse HEAD` | `84fe9f5febcf56910d5c978dd44e0e16d15d7209` | +| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` ✔ | +| `git diff --cached --name-only` | **空** ✔ | +| `git diff --check` | **exit 0**,无空白错误 ✔ | +| R01-B 改动位置 | 全部仍在工作区(22 生产 + 21 测试 + 1 文档) | +| R01-B 之外的新生产改动 | **无**(逐文件比对允许清单,无越界) | + +--- + +## 2. 角色库存独立计数 + +独立复验者自行从 `HEAD` 与工作区分别提取(正则按方法签名匹配,不采信实施报告数字): + +| 项 | HEAD | 工作区 | +|---|---|---| +| 角色 guard 方法定义总数 | **32** | **21** | +| `private boolean hasRole(User, Role)` 助手 | 7 | 7 | +| `getRoles()` 出现次数 / 文件数 | 46 / 31 | 23 / 17 | +| 调用公共 guard 的点位 | 0 | **54** | + +工作区 21 个定义的构成(逐条列出于下): + +| 类别 | 数量 | 明细 | +|---|---|---| +| `UserRoleGuard` 自身 | **6** | `requireStudent/Company/Admin` × (`Long`, `User`) | +| 公开兼容委托(方法体一行) | **6** | `CompanyService.assertCompanyRole`、`CompanyService.assertStudentRole`、`PreferenceService.assertStudentRole`、`PrivacySettingsService.assertStudentRole`、`ProfileService.assertStudentRole`、`CompanyVerificationService.assertAdminRole` | +| 实体返回型 `requireCompany`(非等价,保留) | **7** | `MatchRuleService`、`CompanyScoringDashboardService`、`InterviewService`、`OfferService`、`CompanyReviewManagementService`、`CompanyReviewReplyService`、`CompanyVerificationService` | +| Controller 便捷包装(返回 userId,已委托 guard) | **2** | `AdminRiskController.requireAdmin(HttpServletRequest)`、`AdminReportController.requireAdmin(HttpServletRequest)` | + +6 + 6 + 7 + 2 = **21** ✔ 与工作区实测一致。 + +> 与实施报告的一处**非语义差异**:实施报告 §9.5 写 `getRoles()` 为「23 处 / 16 文件」, +> 独立实测为「23 处 / **17** 文件」(出现次数一致,文件计数差 1)。 +> 该差异不影响任何结论,仅记录以备核对。 + +--- + +## 3. 54 个迁移调用点对账 + +逐调用点清单(文件 / 方法 / 旧 guard / 新 guard / 角色 / 错误码 / 归属 / 查询次数)已落盘: +`docs/acceptance/r01-b/iv-role-callsites.tsv`(54 行 + 表头)。 + +按文件汇总: + +| 文件 | 调用点 | +|---|---| +| `ability/AbilityAttemptService` | 6 | +| `assessment/AssessmentAttemptService` | 5 | +| `candidatescore/governance/CandidateScoringGovernanceService` | 7 | +| `review/JobReviewService` | 6 | +| `recommendation/JobRecommendationService` | 5 | +| `application/ApplicationController` | 3 | +| `job/JobFavoriteService` | 3 | +| `talentinvitation/TalentInvitationService` | 3 | +| `bidirectionalmatch/MatchRuleService` | 2 | +| `company/CompanyService` | 2 | +| `talentdiscovery/TalentDiscoveryService` | 2 | +| `candidatescore/dashboard/CompanyScoringDashboardService` | 1 | +| `careerprogress/CareerProgressService` | 1 | +| `competition/CompetitionRankingService` | 1 | +| `match/MatchService` | 1 | +| `preference/PreferenceService` | 1 | +| `privacy/PrivacySettingsService` | 1 | +| `profile/ProfileService` | 1 | +| `risk/AdminRiskController` | 1 | +| `safety/AdminReportController` | 1 | +| `verification/CompanyVerificationService` | 1 | +| **合计** | **54** | + +| 独立核对项 | 结果 | +|---|---| +| 迁移调用点总数 | **54** ✔ | +| student / admin / company 分布 | **37 / 13 / 4** ✔(与实施报告一致) | +| 重复统计 | **无**(同一行不会被两个正则重复计入) | +| 32 → 21 方法数 | ✔ 逐方法差异已列出:HEAD 独有 17 个定义被删除,工作区新增 `UserRoleGuard` 的 6 个 | +| 是否删除角色检查 | **否** —— 被删方法的 HEAD 调用点数合计 **46**,对应迁移后 54 个调用点(含新增的 `MatchRuleService` 2 处与委托内调用),无一处丢失 | +| 是否扩大 ADMIN 权限 | **否** —— 未引入任何 `requireAny(..., ADMIN)`;ADMIN 仍被 STUDENT/COMPANY 门禁拒绝(HTTP 实测 403) | +| 是否把 404/业务不存在改成 403 | **否** —— `USER_NOT_FOUND` 仍映射 404;`COMPANY_NOT_FOUND` 等业务码未变;实测验端点返回 404 而非 403 | + +--- + +## 4. 14 个保留点及其不等价性核验 + +| 类别 | 数量 | 独立核验结论 | +|---|---|---| +| `private Company requireCompany(Long)` | 7 | **确为不等价**:除角色判定外还执行 `companyRepository.findByOwnerUserId(...)` 并抛 `COMPANY_NOT_FOUND`,返回值被业务使用。用 void guard 替换会丢失实体与错误码语义,且改变查询次数 | +| `private boolean hasRole(User, Role)` | 7 | **确为不等价**:返回**布尔值**用于**分支选择**(如 `listMyInterviews` 按 COMPANY/STUDENT 走不同查询;`listOffers` 还要先取 company 实体),并含归属判定(`hasRole(user, STUDENT) && interview.getStudentUserId().equals(userId)`)。void guard 无法表达"值" | + +已逐处读取源码确认(例:`InterviewService.listMyInterviews`、`OfferService.listOffers` 的 +`if (hasRole(...)) {...} if (hasRole(...)) {...} else 403` 分支结构)。 +**14 与 21 的关系**:21 个方法 = 6(guard 自身)+ 6(一行委托)+ 7(实体返回型)+ 2(Controller 包装); +其中真正"不等价保留"的是 **7 个实体返回型 `requireCompany` + 7 个 `hasRole` 助手 = 14**。 + +**一处残留观察(非阻断)**:`ApplicationController.getCompanyCandidateDetail` 先调 +`requireCompany(userId)`,随后调用的 `companyService.findCompanyByOwnerUserId(userId)` 内部又调 +`assertCompanyRole(userId)` —— 同一请求 2 次角色检查。经与 `HEAD` 逐行比对,**该重复在收束前就存在** +(HEAD 的 `assertCompanyRole` 同样被 Controller 与 `findCompanyByOwnerUserId` 各调一次), +**非本轮引入**,本轮未改变查询次数。可作为后续独立优化点。 + +--- + +## 5. inactive 语义分析 + +| 项 | 结论 | +|---|---| +| 用户不存在 | `USER_NOT_FOUND`(4001) → 404(沿用既有错误码,未新增) | +| 用户存在但 inactive | **`FORBIDDEN`(2002) → 403**(新增的失败关闭;既有实现无此校验) | +| `active` 为 `null` | 同样拒绝(`isActive` 要求 `Boolean.TRUE`) | +| 角色不足 | `FORBIDDEN`(2002) → 403(与既有完全一致) | +| 与既有实现的差异面 | **仅"inactive 用户"这一种输入**:收束前会通过,收束后拒绝 | + +**HTTP 入口的 inactive 用户在 guard 之前已被拒绝**(证据链,非推测): + +1. `JwtAuthenticationFilter.authenticate` 在建立认证前调用 + `authSessionService.isSessionActive(...)`; +2. `AuthSessionService.isSessionActive` 第一步 `isUserUsable(userId)` → + `user.isPresent() && isUsable(user)`,而 `isUsable` 只检查 `user.getActive()`; +3. 为 false 时 `SecurityContextHolder.clearContext()` 并直接返回,**请求根本不会进入 Controller**; +4. 因此对 HTTP 入口而言,guard 的 active 检查是**第二道**,观察不到行为差异。 + +--- + +## 6. 非 HTTP 入口分析 + +对全部 **54 个迁移调用点**逐个追溯调用链,结论:**全部仅经 HTTP 入口可达**。 + +| 入口类型 | 独立核验 | +|---|---| +| HTTP Controller 链路 | 54/54 调用点的宿主方法均由 `@RestController` 直达(21 个文件逐一确认) | +| 定时任务 | 全仓 `@Scheduled` 类共 8 个:`MatchNotificationScheduler`、`CandidateScoringRefreshScheduler`、`PersonalDataExportWorker`、`AccountDeletionService`、`NotificationConsumer`、`OfferExpiryScheduler`、`RiskEventDetector`、`RiskEventScanScheduler` —— **无一个注入或调用任何被迁移的服务或 `UserRoleGuard`** | +| Worker | 同上(`PersonalDataExportWorker`、`CandidateScoringRefreshWorker`、`AccountDeletionCompletionWorker` 的依赖已逐一列出核对) | +| 消息消费者 | `NotificationConsumer` 仅依赖 notification 三件套,未触碰 | +| 内部 Service 调用 | 非 Controller 的调用者仅 4 处:`ResumeVersionService`/`ResumeExportService`/`StudentReviewWeightService`(均只被 Controller 调用)、`MatchService.diagnose`(唯一调用者是 `MatchController`)。**均为 HTTP 链路** | +| 注销/导出/补偿流程 | `AccountDeletionService`、`PersonalDataExportWorker`、补偿扫描器均未触碰迁移点 | +| 管理后台任务 | `AdminRiskController`/`AdminReportController` 的 `requireAdmin` 只被各自 HTTP 端点调用 | + +**因此:不存在"合法的历史数据处理、注销收尾或后台任务需要对 inactive 用户执行受保护动作"的场景。** +独立变量验证:`MatchService.diagnose` 的唯一调用方是 `MatchController`(`grep "\.diagnose("` 全仓仅 1 处)。 + +--- + +## 7. 查询次数 + +| 场景 | 独立实测 | +|---|---| +| `requireXxx(Long userId)` | **恰好 1** 次 `users.findById`(Mockito `times(1)` + `verifyNoMoreInteractions` 锁定) | +| `requireXxx(User user)` | **0** 次(`verifyNoMoreInteractions` 覆盖) | +| guard 写库 | **0**(`never()).save/deleteById/delete`) | +| 单次授权 N+1 | **无**(单条主键查询;`user_roles` 由 JPA 关联加载) | +| 跨请求缓存 | **无**(guard 无状态字段,唯一依赖 `UserRepository`;用户禁用后立即失效) | +| Redis / SecurityContext / requestId | **均未触碰**(结构断言 + 行为断言双重证明) | +| 每请求角色检查次数 | 多数路径 1 次;`/company/candidates/{id}` 为 2 次 —— **与 HEAD 相同,非回归**(见 §4) | + +--- + +## 8. 测试委托模式是否真实(含变异验证) + +### 8.1 静态核验 + +| 项 | 结果 | +|---|---| +| 使用 `delegateToRealGuard` 的测试类 | **16 个**(另 1 个为支持类本身) | +| 每个类是否同时声明 `@Mock private UserRoleGuard userRoleGuard;` | **是**(16/16,各 1 处) | +| 委托是否使用**真实** `UserRoleGuard` 实现 | **是**:`new UserRoleGuard(userRepository)` | +| 是否使用每个测试**自己 stub 的** `UserRepository` | **是**:`delegateToRealGuard(userRoleGuard, userRepository)` 传入该测试类的 `@Mock UserRepository` | +| 是否使用宽泛 `doNothing()` | **否**:`UserRoleGuardTestSupport` 内 **0 处** `doNothing()`;模块内 7 处均为 `lenient().doAnswer(...)` 显式转发 | +| 是否把所有 guard 调用默认放行 | **否**:四个方法均逐字转发到真实 guard;`lenient()` 只用于消除"未被该用例触发"的多余桩告警,不改变行为 | +| 本轮是否新增 `doNothing` | **否**:全仓 10 处 `doNothing()` 全部位于**未被 R01-B 改动**的文件(`git status` 为空),且目标是其他 mock(`favoriteRepository`、`messagingService`、被 mock 的 `companyService` 等) | + +### 8.2 变异验证(决定性) + +把 `UserRoleGuardTestSupport.delegateToRealGuard` 临时改为**空实现**(即 mock 的所有 void 方法无条件放行), +运行 5 个含负向断言的测试类: + +``` +mvn -Dtest="CompanyServiceTest,JobFavoriteServiceTest,PrivacySettingsServiceTest,MatchServiceTest,JobReviewServiceTest" test +→ Tests run: 63, Failures: 13, Errors: 37 ← 50/63 失败 +``` + +失败样例: + +``` +CompanyServiceTest.findCompany_asStudent_shouldReturn403:71 + Expected BusinessException to be thrown, but nothing was thrown. +CompanyServiceTest.updateCompany_asStudent_shouldReturn403:83 + expected: <2002> but was: <5001> +JobFavoriteServiceTest.favorite_asCompany_shouldReturn403:177 + Unexpected exception type thrown ... but was: java.lang.NullPointerException +MatchServiceTest.diagnose_asAdmin_shouldReturn403:91 + Unexpected exception type thrown ... but was: java.lang.NullPointerException +``` + +**判据成立**:若负向测试在"无条件放行"下仍通过,说明测试未覆盖授权;实测 **50 个用例立即失败**, +证明这些测试**真的执行了角色判断**,委托模式未被削弱。 +变异后已把文件**按原 SHA256 还原**(`02f25b74cf5d2192882a77930ec1c748a1049509323bf02668dc5b04822c82a1`,逐字节一致), +临时备份已删除。日志:`docs/acceptance/r01-b/iv-mutation.log`。 + +--- + +## 9. HTTP 映射 166 项对账(独立提取) + +**基线来源**:`git show HEAD:…GlobalExceptionHandler.java` 的 `buildBusinessHttpStatuses()`, +独立解析后写入 `docs/acceptance/r01-b/reconcile.tsv`(166 行)与 +`mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv`(测试资源)。 +**未复用工作区实现的任何映射表**,不存在同源假证明。 + +| 项 | HEAD | 工作区 | 一致 | +|---|---|---|---| +| 枚举常量总数 | 166 | 166 | ✔ | +| 显式映射常量数 | 112 | 112 | ✔ | +| 显式映射覆盖的唯一数字码 | 111 | 111 | ✔ | +| 默认 400 项 | 54 | 54 | ✔ | +| **逐项状态不一致数** | — | — | **0** ✔ | +| HEAD 中重复注册 | 无 | 无 | ✔ | + +关键项逐项核对: + +| ErrorCode | 数字码 | HEAD | 工作区 | +|---|---|---|---| +| `UNAUTHORIZED` | 2001 | 400(未登记默认) | **400** ✔ | +| `FORBIDDEN` | 2002 | 403 | **403** ✔ | +| `SERVICE_UNAVAILABLE` | 9001 | 400(未登记默认) | **400** ✔ | +| `USER_NOT_FOUND` | 4001 | 404 | **404** ✔ | +| `APPLICATION_DUPLICATE` | 8001 | 409 | **409** ✔ | +| `ABILITY_RETAKE_LIMIT` | 9811 | 429 | **429** ✔ | +| `REVIEW_ALREADY_DECIDED` | 9103 | 409 | **409** ✔ | +| `DUPLICATE_FEEDBACK` | 9103 | 409 | **409** ✔ | + +(上表数字码与状态均取自独立提取的 `docs/acceptance/r01-b/reconcile.tsv`。) + +响应 JSON、requestId、日志行为未变:`GlobalExceptionHandler.handleBusinessException` 仍为 +`log.warn`、仍用 `ApiResponse.error(errorCode, e.getMessage(), requestIdAccessor.get(request))`, +仅把状态解析改为 `errorHttpStatusRegistry.resolve(errorCode)`。 + +--- + +## 10. 112 显式 / 54 默认统计(独立复核) + +| 项 | 实施报告 | 独立复核 | +|---|---|---| +| 显式映射 | 112 | **112** ✔ | +| 未映射(默认 400) | 54 | **54** ✔ | +| 唯一数字码 | 111(未声明) | **111** ✔(9103 共用) | + +--- + +## 11. 9103 重复码的真实运行时语义 + +### 11.1 逐层查清 + +| 层 | 事实 | +|---|---| +| `BusinessException` 保存什么 | **`private final int errorCode;`** —— 只保存数字,**枚举身份在构造时即丢失**;无 `ErrorCode` 字段,无 `fromCode` 反查 | +| `GlobalExceptionHandler` 拿到什么 | `int errorCode = e.getErrorCode();` → **int** | +| `ErrorHttpStatusRegistry.resolve(...)` 以什么为键 | `resolve(ErrorCode)` 用**枚举键**表 `EXPLICIT`;**运行时路径 `resolve(int)` 用派生数字表 `BY_CODE`** | +| 是否使用 `ErrorCode.values()` / `fromCode()` / 首次匹配 | **全部没有**(registry 与 handler 中 `values()`/`valueOf`/`ordinal`/`stream()` 命中为 0;`ErrorCode` 亦无 `fromCode` 辅助方法) | +| 相同数字码不同枚举是否可能被错误归类 | **运行时不可能区分** —— 两者都是 9103,`resolve(9103)` 只有一个结果 | +| 映射是否依赖枚举声明顺序 | **不依赖**。`EXPLICIT` 按**注册顺序**构建(与枚举声明顺序无关),`BY_CODE` 由它派生并用 `putIfAbsent` 去重;顺序只影响"谁先写入",而冲突时**抛错**而非取先者 | + +### 11.2 明确声明(不夸大) + +> **运行时无法区分 `REVIEW_ALREADY_DECIDED` 与 `DUPLICATE_FEEDBACK`**:`BusinessException` 只携带 +> `int 9103`,Handler 也只拿到该数字。因此**不能声称"以枚举为键已彻底解决身份歧义"**; +> 准确表述是:**两者当前都映射为 409 CONFLICT,故 HTTP 行为一致**; +> 枚举键表的价值在于**配置期可审计与可去重**,而不是运行期消歧。 + +### 11.3 独立测试对六项要求的覆盖 + +| # | 要求 | 覆盖用例 | +|---|---|---| +| 1 | 两个枚举常量都显式注册 | `bothSharedNumericCodeConstantsAreExplicitlyMappedTo409` | +| 2 | 两者分别解析为 409 | 同上 | +| 3 | `resolve(9103)` 行为明确 | `runtimeCannotDistinguishTheTwoSharedConstants`(= CONFLICT,并断言两个异常的业务码相同) | +| 4 | 改成不同状态时初始化/解析必须失败 | `conflictingStatusForSharedNumericCodeFailsFast`(构造 `9103 → CONFLICT` + `9103 → NOT_FOUND`,调用数字索引构建必须抛 `IllegalStateException`,消息含 `9103`;同时反证状态相同时允许) | +| 5 | 不依赖 `ErrorCode.values()` 顺序 | `mappingDoesNotDependOnEnumDeclarationOrder`(正序/逆序注册结果相同 + 逐项比对基线) | +| 6 | 未映射 code 仍为 400 | `unknownCodeFallsBackTo400AndResponseIsClean` | + +--- + +## 12. 独立新增测试 + +| 测试类 | Tests | 结果 | +|---|---|---| +| `common/r01b/R01BIndependentRoleVerificationTest` | 11 | **通过** | +| `common/r01b/R01BIndependentHttpMappingParityTest` | 9 | **通过** | +| `common/r01b/R01BWiringMutationTest` | 5 | **通过** | +| **合计** | **25** | **25 / 25**,F=0 E=0 S=0 ✔ | + +日志:`docs/acceptance/r01-b/iv-1-independent.log` + +覆盖要点: + +- **角色**:active 三角色各自通过;三类交叉全部拒绝;ADMIN 不自动通过 STUDENT/COMPANY; + 用户不存在(`USER_NOT_FOUND`);inactive 与 `active=null`(`FORBIDDEN`);`roles` 为空; + 多角色;`users.role` 与 `user_roles` 不一致时按 `user_roles` 失败关闭; + `requireXxx(Long)` 恰好 1 次查询、`requireXxx(User)` 零查询;无写库; + 不触碰 SecurityContext(在上下文里放 ADMIN 仍按传入 userId 判定)/requestId/Redis; + 三个真实 HTTP 端点验证 **401 / 403 / 404** 分层。 +- **接线变异**:guard 拒绝 → 403 且**下游业务方法零调用**;guard 放行 → **STUDENT 也能通过 ADMIN 端点** + (若端点仍保留自有角色比较,STUDENT 不可能通过 → 该断言即变异检测器); + guard 收到正确 userId;归属失败仍由业务层返回 404。 +- **映射**:166 项按**名称**逐项比对 HEAD 基线(不作数字去重)、112/54 计数、 + 9103 六项、未知码 400、响应体无堆栈与内部类名。 + +--- + +## 13. 两个 Controller 测试单跑结果(如实记录,未写成通过) + +| 命令 | 结果 | +|---|---| +| `mvn -Dtest="AdminReviewControllerTest" test` | **11 run / 4 failures / 0 errors / 0 skipped**,BUILD FAILURE | +| `mvn -Dtest="CompanyReviewManagementControllerTest" test` | **3 run / 2 failures / 0 errors / 0 skipped**,BUILD FAILURE | + +失败断言:`expected 200/400/409 but was 401`(6 项合计)。 + +**根因证据链(非按日志文本猜测)**: + +| 证据 | AdminReview | CompanyReviewMgmt | +|---|---|---| +| `Handler: Type = null` 次数 | 4 | 2 | +| `session validation rejected: user not usable` | 4 | 2 | +| `JWT authentication rejected: session not active` | 4 | 2 | +| 401 响应体 | `{"code":401,"message":"not authenticated","timestamp":""}` | 同 | +| 测试使用的用户 ID | `ADMIN_ID = 1L` | `token(1L,"COMPANY")` / `token(2L,"STUDENT")` | +| 是否在 R01-B 改动清单内 | **否** | **否** | + +**判定**: + +1. `Handler: Type = null` 证明请求**从未进入任何 Controller**,`JobReviewService` / + `UserRoleGuard` / `GlobalExceptionHandler` **均未被调用** —— 因此**与 R01-B 新逻辑无关**; +2. 401 由 `SecurityConfig` 的认证入口产出(body 形状 `{"code":401,"message":"not authenticated"}`), + 不是 `GlobalExceptionHandler` 的 `ApiResponse` 形状(后者会带业务码); +3. 拒绝发生在 `AuthSessionService.isUserUsable` —— 用户 id=1/2 正是 `V1_1` 种子账号、 + 被 `V43` 置为 inactive; +4. 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `deleteAll()` 偶然清除种子行, + 问题被掩盖,故 §14 全量为绿。 + +**处置**:未修改这两个测试;未写成通过;不让它们阻断对 R01-B 生产代码的判断。 +**R01-B 提交后须以独立纯测试提交修复**(与 A6 后 `84fe9f5` 修复 `FairnessMonitoringControllerTest` 同法)。 + +--- + +## 14. 全量测试 + +| 命令 | 结果 | 日志 | +|---|---|---| +| `mvn -Dtest="RoleAuthorizationGuardTest,RoleAuthorizationIntegrationTest,ErrorHttpStatusRegistryTest,GlobalExceptionHandlerMappingParityTest" test` | **371 / 371**,F=0 E=0 S=0 ✔ | `iv-2-impl.log` | +| `mvn -Dtest="*Security*Test,*Authorization*Test,*Role*Test,*GlobalExceptionHandler*Test,*BusinessException*Test" test` | **425 / 425**,F=0 E=0 S=0 ✔(该命令不含两个已知夹具类,已单独记录于 §13) | `iv-3-related.log` | +| `mvn clean verify` | 见 §14.1 | `iv-4-clean-verify.log` | + +### 14.1 `mvn clean verify` + +``` +[INFO] Tests run: 2681, Failures: 0, Errors: 0, Skipped: 26 +[INFO] BUILD SUCCESS +``` + +| 项 | 数值 | +|---|---| +| 测试总数 | **2681**(= 前次 2656 + 本轮新增独立测试 25)✔ 不低于 2656 | +| Failures | **0** ✔ | +| Errors | **0** ✔ | +| **Skipped** | **26 —— 未达"0"要求,原因如下(环境性,非代码回归)** | + +#### Skipped=26 的准确原因(如实记录,不掩饰、不写成 0) + +26 个跳过**全部**来自 4 个 MySQL / Testcontainers 测试类: + +| 测试类 | Skipped | +|---|---| +| `job.JobSearchSpecificationMySqlIntegrationTest` | 15 | +| `candidatescore.refresh.CandidateScoringRefreshMySqlIntegrationTest` | 6 | +| `job.JobSearchQuerySideEffectMySqlIntegrationTest` | 4 | +| `job.JobSearchQueryExplainMySqlIntegrationTest` | 1 | +| 合计 | **26** | + +**根因**:这些类标注 `@Testcontainers(disabledWithoutDocker = true)`(见 +`JobSearchSpecificationMySqlIntegrationTest:44` 等),Testcontainers 在 **Docker 守护进程不可用**时 +按设计**环境性跳过**。本机实测: + +``` +docker info → failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine; + check if the path is correct and if the daemon is running +``` + +**为何与 R01-B 无关**: + +1. 本轮全程**未操作 Docker**(任务书明令禁止),Docker 守护进程不在运行属环境状态; +2. **紧邻的上一次全量 `mvn clean verify`(R01-B 实施阶段)为 `2656 / F=0 / E=0 / Skipped=0`** —— + 同一份生产代码下这 4 个类**执行并通过**,证明它们未被 R01-B 破坏; +3. 本轮相对该次运行,**生产代码零改动**(仅新增 3 个测试类 + 1 个测试资源 + 1 份文档); +4. 计数自洽:`2656 − 26(改为跳过)+ 25(本轮新增)= 2655 执行 + 26 跳过 = 2681`, + 即**除这 26 个 Docker 条件用例外的全部测试仍执行且全部通过**。 + +**结论**:该偏差为**环境条件**,不构成 R01-B 的阻断项;但按任务书"跳过必须是 0"的字面要求, +本环境**无法满足**,故**不声称 Skipped=0**。若需在 Docker 可用环境下复核, +重新执行 `mvn clean verify` 即可(无需任何代码改动)。 + +--- + +## 15. OpenAPI + +``` +python ..\docs\verify_openapi.py +→ paths: 176 / operations: 214 +→ All checks passed! +``` + +`git diff -- docs/openapi-rc.json` → **空** ✔ + +--- + +## 16. 精确文件范围 + +**R01-B 生产改动(22 修改 + 2 新增)** + +``` +修改: ability/AbilityAttemptService、application/ApplicationController、assessment/AssessmentAttemptService、 + bidirectionalmatch/MatchRuleService、candidatescore/dashboard/CompanyScoringDashboardService、 + candidatescore/governance/CandidateScoringGovernanceService、careerprogress/CareerProgressService、 + common/exception/GlobalExceptionHandler、company/CompanyService、competition/CompetitionRankingService、 + job/JobFavoriteService、match/MatchService、preference/PreferenceService、 + privacy/PrivacySettingsService、profile/ProfileService、recommendation/JobRecommendationService、 + review/JobReviewService、risk/AdminRiskController、safety/AdminReportController、 + talentdiscovery/TalentDiscoveryService、talentinvitation/TalentInvitationService、 + verification/CompanyVerificationService +新增: security/UserRoleGuard、common/exception/ErrorHttpStatusRegistry +``` + +**测试改动(21 修改 + 4 新增 + 1 支持类 + 1 基线资源)**、**文档(1 修改 + 1 新增)**。 +本轮独立复验**仅新增**: + +``` +mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentRoleVerificationTest.java +mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java +mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BWiringMutationTest.java +mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv +docs/r01-b-independent-verification-20260918.md +docs/acceptance/r01-b/*(验收日志与对账产物,被 .gitignore 覆盖) +``` + +**未修改任何生产代码** ✔(`git diff` 生产文件数与复验开始前一致,均为 R01-B 实施者留下的改动)。 + +--- + +## 17. Git 边界 + +| 检查 | 结果 | +|---|---| +| `git diff --check` | **exit 0**,无空白错误 ✔ | +| `git diff --cached --name-only` | **空** ✔ | +| `git diff --name-only -- …/db/migration` | **空** —— V1–V45 未改 ✔ | +| `git diff -- docs/openapi-rc.json` | **空** ✔ | +| `git diff -- mydreamjob-server/pom.xml` | **空** ✔ | +| `ErrorCode.java` | **未修改** ✔ | +| 接口 / DTO | **未修改** ✔ | +| 历史未跟踪文件 | 完整保留 ✔ | +| Java / Maven 残留进程 | 无 ✔ | +| HEAD / upstream | `84fe9f5…` / **0 0** ✔ | + +--- + +## 18. 是否存在阻断 + +**无阻断。** + +| 复核项 | 结论 | +|---|---| +| 角色语义变化 | **无**(仅新增 inactive 失败关闭,且 HTTP 入口不可观测、非 HTTP 入口不存在) | +| 归属语义变化 | **无**(归属仍由原 Service 执行,实测 404 未被改写为 403) | +| 错误码变化 | **无**(`USER_NOT_FOUND`/`FORBIDDEN` 沿用,未新增 ErrorCode) | +| HTTP 映射变化 | **无**(166 项逐项一致) | +| 事务语义变化 | **无**(guard 只读、无 `@Transactional`,未改变任何事务边界) | +| ADMIN 权限扩大 | **无** | +| 测试削弱 | **无**(变异实验 50/63 失败) | +| 阻断项 | **0** | + +--- + +## 19. 未暂存、未提交、未推送 + +**确认:未暂存、未提交、未推送。** 本轮未执行 `git add`、未创建提交、未推送; +未执行 `reset`/`restore`/`checkout`/`clean`;首轮复验未操作 Docker,随后在负责人授权的 +补充复验中仅启动本机既有 Docker Desktop 并由 Testcontainers 创建临时 MySQL 容器, +未重建项目容器、未删除 volume;未删除历史未跟踪文件。 +变异实验对测试支持文件的临时修改已**按原 SHA256 逐字节还原**并清理备份。 + +--- + +## 20. 未进入 R01-C/E + +**确认:未进入 R01-C、未进入 R01-E。** 本轮未处理 `overviewStatus`、推荐权重、 +高校名单与评分阈值、N+1、会话未读数、requestId、身份主体解析、Token、评分快照、 +新接口与数据库迁移中的任何一项。 + +--- + +> **R01-B独立代码复验通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。** + +--- + +## 21. Docker / Testcontainers 补充复验(2026-09-18) + +### 21.1 首轮 SKIP 记录保留 + +首次独立复验时 Docker 守护进程不可用,以下 4 个测试类合计 26 个 MySQL/Testcontainers +用例按 `disabledWithoutDocker = true` 跳过;该事实及原因分析保留在本报告第 14 节,未删除、 +未改写成首次即通过: + +| 测试类 | Tests | 首轮 Skipped | +|---|---:|---:| +| `CandidateScoringRefreshMySqlIntegrationTest` | 6 | 6 | +| `JobSearchQueryExplainMySqlIntegrationTest` | 1 | 1 | +| `JobSearchQuerySideEffectMySqlIntegrationTest` | 4 | 4 | +| `JobSearchSpecificationMySqlIntegrationTest` | 15 | 15 | +| **合计** | **26** | **26** | + +### 21.2 环境恢复 + +经负责人授权启动本机既有 Docker Desktop: + +```text +Docker Server: 29.6.2 +Testcontainers: 1.21.3 +MySQL image: mysql:8.4 +``` + +未重建项目容器、未删除或修改既有 volume。Testcontainers 实际连接 Windows npipe Docker +Engine,并成功启动 Ryuk 与临时 MySQL 8.4 容器。 + +### 21.3 26 个 MySQL 用例定向补验 + +执行: + +```text +mvn -Dtest="CandidateScoringRefreshMySqlIntegrationTest,JobSearchQueryExplainMySqlIntegrationTest,JobSearchQuerySideEffectMySqlIntegrationTest,JobSearchSpecificationMySqlIntegrationTest" test +``` + +结果: + +```text +Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +4 个类均真实启动 MySQL 8.4 Testcontainer,未使用 H2 代替,未通过修改 +`disabledWithoutDocker` 或测试选择规则规避执行。 + +### 21.4 完整干净构建 + +随后执行: + +```text +mvn clean verify +``` + +结果: + +```text +Tests run: 2681, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +并重新执行 OpenAPI 校验: + +```text +paths: 176 +operations: 214 +Valid JSON: True +All checks passed! +``` + +本次补验未修改任何生产代码、测试代码、Flyway、`ErrorCode`、DTO、接口、依赖或 +`openapi-rc.json`。 + +### 21.5 最终冻结结论 + +首轮因环境导致的 26 个 SKIP 已由真实 Docker/Testcontainers 执行闭环,最终硬性门禁为 +`Failures=0 / Errors=0 / Skipped=0`。 + +> **R01-B独立代码复验及Docker/Testcontainers补充验证全部通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。** diff --git a/docs/r01-c-fairness-fixture-isolation-fix-20260918.md b/docs/r01-c-fairness-fixture-isolation-fix-20260918.md new file mode 100644 index 0000000..8b96faf --- /dev/null +++ b/docs/r01-c-fairness-fixture-isolation-fix-20260918.md @@ -0,0 +1,183 @@ +# R01-C 评分/Fairness 组合测试污染隔离修复(独立纯测试提交) + +日期:2026-09-18 +分支:`refactor/r01-structure-performance` +R01-C 提交:`7d4704ee28e4d8e2d1fbcd174a990fc6bfe68169` +本轮性质:**纯测试修复** —— **生产代码 diff = 0** + +--- + +## 0. 结论 + +`*CandidateScoring*Test,*Fairness*Test` 组合在**任意顺序**下均全绿; +污染源已按**逐类行级快照实测**精确归因(**未按类名猜测定性**), +**5 个候选类经证明零残留、未做任何修改**。 + +--- + +## 1. 最初的错误归因及纠正 + +| 阶段 | 归因 | 判定 | +|---|---|---| +| R01-C 复验阶段(初判) | `CompanyScoringDashboardPersistenceIntegrationTest`(用 `LocalDate.now()` 生成当前期间且无清理) | **错误** —— 该类**根本不在**失败命令内;单独与 Fairness 组合 **3 次全绿** | +| 二分定位 | `*CandidateScoring*Test` 是污染所在半边;进一步收敛到 `CandidateScoringPersistenceIntegrationTest + FairnessMonitoringHttpIntegrationTest`(7 用例 / 2 失败) | **正确但不完整** —— 修复后污染量仅由 +15 降到 +12,说明仍有其它污染源 | +| 本轮(逐类实测) | 见 §2,**5 个类**合计残留 15 个 student | **完整** —— 与受害测试观测的 +15(修复前)/ +12(仅修 1 类后)**精确吻合** | + +--- + +## 2. 每个候选类的实测残留表 + +**方法**:临时诊断探针 `ZR01CLeakProbeTest`(置于 `candidatescore.zz` 以确保 alphabetical 下**最后**运行), +对 `candidate_dimension_scores` 的**当前期间**抓取**行级快照** +(`student_user_id / dimension / period / status / version`)与相关表计数。 +每次 `mvn` 均为**全新 JVM + 全新 H2 库**,故"运行后快照"即为该类在**干净上下文中**的净残留。 +探针诊断完成后**已删除**(非交付物)。 + +| 候选类 | 单独运行 | 当前期间残留行数 | 残留 student 数 | 已有 @AfterEach | 是否需修 | +|---|---|---|---|---|---| +| `CandidateScoringPersistenceIntegrationTest` | 3/3 通过 | **24**(3×8) | **3** | 无 | **是**(已修) | +| `CandidateScoringRefreshClaimFenceTest` | 7/7 通过 | **16** | **2** | 无 | **是** | +| `CandidateScoringRefreshClaimTokenTest` | 14/14 通过 | **8** | **1** | 有(不全) | **是** | +| `CandidateScoringRefreshFailureTransactionTest` | 4/4 通过 | **8** | **1** | 无 | **是** | +| `CandidateScoringRefreshInfrastructureTest` | 15/15 通过 | **57** | **8** | 无 | **是** | +| `CandidateScoringClockBoundaryIntegrationTest` | 3/3 通过 | **0** | 0 | 无 | **否**(无需修改) | +| `CandidateScoringRefreshBatchIsolationTest` | 5/5 通过 | **0** | 0 | 无 | **否**(无需修改) | +| `R01D4GetWritesSnapshotEvidenceTest` | 6/6 通过 | **0** | 0 | 无 | **否**(无需修改) | +| `CandidateScoringGovernancePersistenceIntegrationTest` | 5/5 通过 | **0** | 0 | 无 | **否**(无需修改) | +| `CompanyScoringDashboardPersistenceIntegrationTest` | 11/11 通过 | **0** | 0 | 无 | **否**(无需修改) | + +> **5 个候选类经实测零残留**(其中 4 个无 `@AfterEach` 但依然不留残留, +> 说明其写入被事务回滚或未落入 `current_period`)。**未因"名字可疑"而修改它们。** + +--- + +## 3. +15、+12 的精确来源 + +| 项 | 组成 | 合计 | +|---|---|---| +| **修复前 +15** | Persistence 3 + ClaimFence 2 + ClaimToken 1 + FailureTransaction 1 + Infrastructure 8 | **15** ✔ 与受害测试观测 `100 → 115` 完全一致 | +| **仅修 Persistence 后 +12** | ClaimFence 2 + ClaimToken 1 + FailureTransaction 1 + Infrastructure 8 | **12** ✔ 与观测 `100 → 112` 完全一致 | + +关键佐证:`CandidateScoringRefreshInfrastructureTest:271` 以循环 +`"131" + String.format("%08d", i + 7000)`(i=0..7)创建 **8 个用户**, +与探针实测的 **8 个残留 student** 精确对应。 + +--- + +## 4. 修复内容(仅改测试,生产代码 0 改动) + +| 文件 | 修复前 | 修复后 | +|---|---|---| +| `CandidateScoringPersistenceIntegrationTest` | 3 个用例各建 1 个学生并物化当前期间快照,**零清理** | 登记自有 userId;`@BeforeEach` **断言独占手机号不存在**;`@AfterEach` 按外键顺序**精确 id** 删除快照 → 角色 → 用户 | +| `CandidateScoringRefreshClaimFenceTest` | 残留 16 行 | 新增 `@AfterEach`:按**本类独占手机号前缀** `193`/`192` 解析出精确 id 后按外键顺序删除 | +| `CandidateScoringRefreshClaimTokenTest` | 残留 8 行(原有 `@AfterEach` 未覆盖快照) | 同上,前缀 `190`/`189` | +| `CandidateScoringRefreshFailureTransactionTest` | 残留 8 行 | 同上,前缀 `130` | +| `CandidateScoringRefreshInfrastructureTest` | 残留 57 行 | 同上,前缀 `132`/`13107` | + +**统一遵守的约束**:独占手机号(不与 V1_1 种子账号 `138…`、其它测试 `t`、`1390000xxx` 冲突); +按外键顺序(`candidate_dimension_scores` / `candidate_scoring_refresh_tasks` → `user_roles` → `users`); +**不使用 `deleteAll`/`TRUNCATE`/`@DirtiesContext`/`sleep`**;不捕获忽略清理异常; +**不修改生产统计或查询**;**不通过改期望 `populationSize` 掩盖污染**;不依赖测试执行顺序。 + +> **未修改** `FairnessMonitoringHttpIntegrationTest` 的任何断言 —— 其 `populationSize` 期望值 +> 仍为既有的 100 / 200,污染消除后**自然精确命中**。 + +--- + +## 5. 修复前失败与修复后结果 + +| 组合 | 修复前 | 修复后 | +|---|---|---| +| `CandidateScoringPersistenceIntegrationTest,FairnessMonitoringHttpIntegrationTest` | **7 run / 2 failures** | **7 / 0** ✔ | +| `*CandidateScoring*Test,*Fairness*Test`(原失败命令) | **296 / 2 failures**(另一次 260 / 2) | **260 / 0** ✔ | + +失败断言(修复前):`$.data.populationSize expected:<100> but was:<115>`、 +`expected:<200> but was:<215>`。 +修复后 `populationSize` **精确等于既有期望** 100 / 200 ✔ + +--- + +## 6. 双向顺序各 3 次结果 + +实际执行顺序**从日志核对**(非依据命令参数): + +| 顺序 | 实测类顺序 | 结果 | +|---|---|---| +| `-Dsurefire.runOrder=alphabetical` ×3 | `candidatescore.CandidateScoringCalculatorTest` … → … `scoringmonitoring.FairnessStatisticsCalculatorTest`;**Fairness HTTP 在所有 Candidate 类之后** | **260 / F=0 / E=0 / S=0** ×3 ✔ | +| `-Dsurefire.runOrder=reversealphabetical` ×3 | `scoringmonitoring.FairnessStatisticsCalculatorTest` … → … `candidatescore.CandidateScoringCalculatorTest`;**Fairness HTTP 在 Candidate 类之前** | **260 / F=0 / E=0 / S=0** ×3 ✔ | + +**两个方向 6 次运行全部 260/260,Failures=0、Errors=0、Skipped=0。** + +--- + +## 7. 行级清理与反证 + +新增独立隔离门禁 `R01CScoringFixtureIsolationGateTest`(与污染类共用 `integrationdb`): + +| # | 断言 | 覆盖要求 | +|---|---|---| +| 1 | 5 个已修污染类的**独占手机号段**在库中零残留用户 | 自有 users 残留为 0 | +| 2 | 若发现残留,进一步报告其**当前期间快照数**(失败信息含类名/前缀/用户数/快照数) | 自有快照残留为 0 | +| 3 | 当前期间不得存在指向已删除用户的**孤立快照行** | 清理不留孤儿 | +| 4 | 其它 period 的行数可查询且非负(清理不波及) | 其它 period 不受影响 | +| 5 | 断言以**独占前缀**为界 → 清理只影响本类登记的数据 | 不删除其他测试数据 | +| 6 | 门禁与污染类同库同 JVM 运行,**只要任一污染类遗留自有行即失败** | Fairness 统计不再依赖先前顺序 | + +`CandidateScoringPersistenceIntegrationTest` 的 `@BeforeEach` 前置断言 +("独占夹具手机号不得预先存在")即"**预置冲突时前置断言失败**"的反证: +若上轮清理失效,测试在**开始前**即失败,而不是让残留静默参与断言。 + +--- + +## 8. `clean verify` 与 OpenAPI + +``` +docker info → ServerVersion=29.6.2(Testcontainers 真实执行,未跳过) +mvn clean verify → Tests run: 2746, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS +python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed! +``` + +| 项 | 数值 | +|---|---| +| 测试总数 | **2746** = R01-C 基线 **2743** + 本轮新增隔离门禁 **3** ✔(不低于 2743 + 新增数) | +| Failures / Errors / **Skipped** | **0 / 0 / 0** ✔ | +| OpenAPI | **176 / 214** ✔ | +| 生产代码 diff | **0** ✔ | + +--- + +## 9. 精确文件清单 + +**被修复的测试文件(5)** + +``` +mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java +mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java +mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java +mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java +mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java +``` + +**新增隔离测试(1)** + +``` +mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CScoringFixtureIsolationGateTest.java +``` + +**报告(1)**:本文件 + +**路线图(1)**:`docs/backend-development-roadmap.md` —— 仅追加一条夹具问题关闭记录 + +> 临时诊断探针 `ZR01CLeakProbeTest` **已在诊断后删除**,不进入提交。 + +--- + +## 10. 生产代码修改数 = 0 + +**确认:本轮生产代码 diff = 0。** 未修改 V1–V45、`ErrorCode`、DTO、公网接口、 +`pom.xml`、`docs/openapi-rc.json`;未修改任何生产查询或统计逻辑; +未修改 `FairnessMonitoringHttpIntegrationTest` 的断言。 + +--- + +> **评分与 Fairness 组合测试污染已完成精确归因,并通过独立纯测试提交修复。** diff --git a/docs/r01-c-independent-verification-20260918.md b/docs/r01-c-independent-verification-20260918.md new file mode 100644 index 0000000..5802a69 --- /dev/null +++ b/docs/r01-c-independent-verification-20260918.md @@ -0,0 +1,347 @@ +# R01-C 独立代码复验报告(评分与推荐规则结构收束) + +日期:2026-09-18 +分支:`refactor/r01-structure-performance` +HEAD:`9954600650c3e7cd40a5f4fa55bc38595973f214`(复验全程未变) +复验对象:`docs/r01-c-scoring-recommendation-rules-refactor-report.md` +性质:**仅复验** —— 未修改生产代码、未放宽既有断言、未暂存、未提交、未推送、未进入 R01-E + +--- + +## 0. 结论 + +> **R01-C独立代码复验通过,可以进入精确暂存与独立提交检查点。** + +- `overviewStatus` **确为唯一产出点**;三处调用方全部委托; +- 偏好公式实现点 **2 → 1**(HEAD 的 `TalentDiscoveryService` 确实内联了第二套等价公式); +- 985/211 名单定义点 **唯一**;规则集**不可变、无框架依赖、无 setter、集合只读**; +- 生产变更 **新增 1 + 修改 2 = 3 个生产文件**; +- 三类变异均使对应测试失败,恢复后**无残留**; +- Docker 已按给定路径启动,**Testcontainers 26 项真实执行、Skipped=0**; +- 一处**既有测试隔离脆弱性**被独立发现并定性(见 §10.3),**与 R01-C 无关**。 + +--- + +## 1. 独立库存结果 + +不采信实施报告数字,独立搜索证据: + +| # | 核查项 | 独立结论 | 证据 | +|---|---|---|---| +| 1 | `overviewStatus` 是否只有 `CandidateOverviewStatusPolicy` 一个规则产出点 | **是** | 全 `src` 中 `"PARTIAL"` 仅在该类**主代码**出现一次(`CandidateOverviewStatusPolicy.java:43`);其余命中全部在测试的断言/测试数据中,非产出点 | +| 2 | 三个调用方是否全部委托该策略 | **是** | `CandidateScoringEngineService:123`、`:161`(`fromComputedResults`);`CompetitionRankingCalculator:103`(`fromSnapshots`)—— 均为直接委托调用 | +| 3 | `JobRecommendationService` 是否已经只是编排 | **是** | `preferenceScore` 方法体 13 行:构造 `Criteria`/`Target` → `JobPreferenceScoreCalculator.calculate(...)`,无任何内联权重 | +| 4 | `TalentDiscoveryService` 修复前是否确实维护了第二套等价偏好公式 | **是(已由 HEAD 证实)** | `git show HEAD:…TalentDiscoveryService.java` 的 `preferenceScore` 为内联 `total += 30/20/20/15/15` + `BigDecimal.valueOf(got*100L).divide(...,2,HALF_UP)` —— 与计算器**逐字等价** | +| 5 | 本轮是否只新增一个规则集并修改两个文件 | **是** | 新增 `CandidateScoringRuleSet.java`;修改 `CandidateScoringCalculator.java`、`TalentDiscoveryService.java` | +| 6 | **生产代码变更总数** | **新增 1 + 修改 2 = 3 个生产文件** | 与 `git diff --name-status` + 未跟踪文件清单一致 | + +--- + +## 2. overviewStatus 唯一产出点证据 + +``` +main/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicy.java:43: return "PARTIAL"; +``` + +`"PARTIAL"` 在**主代码**中的唯一出现点即为该策略类(第 43 行)。 +状态聚合器**必须**能够产出 `PARTIAL`,因此该字符串的出现位置即为产出点的充要证据。 +调用方 3 处(`CandidateScoringEngineService` ×2、`CompetitionRankingCalculator` ×1)全部为委托。 + +**结构门禁已加入独立复验测试**:扫描 `src/main/java/com/mydreamjob` 全部 `.java`, +断言含 `"PARTIAL"` 的文件**恰为** `CandidateOverviewStatusPolicy.java`。 + +--- + +## 3. 偏好公式修复前后实现点数量 + +| 时点 | 30/20/20/15/15 规则实现处 | 证据 | +|---|---|---| +| 修复前(HEAD) | **2** —— `recommendation/JobPreferenceScoreCalculator.java` + `talentdiscovery/TalentDiscoveryService.java`(内联) | `git grep -l "total += 30\|CITY_WEIGHT" HEAD` → 2 个文件 | +| 修复后(工作区) | **1** —— 仅 `recommendation/JobPreferenceScoreCalculator.java` | 同 grep → 1 个文件 | + +`preferenceScore` 方法定义仍为 2 处(两个 Service 各一),但**两处都只是编排**,均调用 +`JobPreferenceScoreCalculator.calculate(...)`;规则**实现**唯一。 + +--- + +## 4. 985/211 名单定义点数量 + +| 时点 | 定义点 | 证据 | +|---|---|---| +| HEAD | **2**(`C985`、`C211`)**均在** `CandidateScoringCalculator.java` | `git grep "Set C985\|Set C211" HEAD` | +| 工作区 | **2**(`C985`、`C211`)**均在** `candidatescore/rules/CandidateScoringRuleSet.java` | `grep` 主代码 → 唯一文件 | + +判定为"唯一权威定义点":名单**只在规范集内定义一次**,计算器内**已无**名单定义 +(独立测试断言 `CandidateScoringCalculator.java` 不含 `Set C985` / `C211`)。 + +| 项 | HEAD | 工作区 | 一致 | +|---|---|---|---| +| C985 唯一项 | 38 | **38** ✔ | | +| C211 字面量条数 | 62 | **62** ✔ | | +| C211 唯一项 | 61 | **61** ✔ | | +| `北京科技大学` 重复项 | 存在 | **原样保留** ✔ | | + +--- + +## 5. 规则集不可变性和依赖边界 + +逐项核验 `CandidateScoringRuleSet.java`: + +| 要求 | 结果 | +|---|---| +| `final` | ✔ `public final class CandidateScoringRuleSet`(私有构造) | +| 无 setter | ✔ 主代码 `grep "public void set"` 命中 0 | +| 无可变集合泄漏 | ✔ 集合经 `Collections.unmodifiableSet(new HashSet<>(...))` 包装后返回 | +| 返回集合不可修改 | ✔ 独立测试断言 `add`/`remove`/`clear` 均抛 `UnsupportedOperationException` | +| 无 Spring 注解 | ✔ `@Component`/`@Service`/`@Autowired`/`@Value` 命中 0 | +| 无数据库/Repository | ✔ `Repository`/`JdbcTemplate`/`EntityManager` 命中 0 | +| 无配置中心/环境变量 | ✔ `System.getenv`/`Properties` 命中 0 | +| 不接运行时可变配置 | ✔ 全部规则为静态常量,只能随代码发布变更 | +| BigDecimal 字符串构造 | ✔ 17 个常量全部 `new BigDecimal("…")`;`new BigDecimal(<数字>)` 命中 **0** | +| 同值不同义不合并 | ✔ `EDUCATION_LEVEL_MASTER(85)` 与 `SCHOOL_TIER_985(85)` 为不同常量;`EDUCATION_LEVEL_UNKNOWN(50)` 与 `SCHOOL_TIER_MISSING(50)` 同理 | +| 规则版本单一定义点 | ✔ `CandidateScoringRuleSet.VERSION` 为权威;`CandidateScoringCalculator.RULES_VERSION` 现**指向该常量**(赋值而非字面量) | + +--- + +## 6. BigDecimal 精确结果与边界表(独立测试推导) + +独立复验测试 `R01CIndependentVerificationTest`(18 个用例)全部通过, +期望值**手工推算**,断言 `isEqualByComparingTo` + `scale` + `toPlainString()`(不使用近似比较): + +| 输入 | 期望 | 说明 | +|---|---|---| +| 五维全命中(可用权重 100) | **100.00** | 上界 | +| 五维全缺失 / 零分母 | **null** | 非 0.00 | +| 城市(30) 命中、其余缺失 | **100.00** | 单维独立 | +| 用工类型(20) 命中、其余缺失 | **100.00** | | +| 薪资(20) 命中(边界相等 20000 == 20000) | **100.00** | 相等视为命中 | +| 行业(15) 命中、其余缺失 | **100.00** | | +| 岗位族(15) 命中、其余缺失 | **100.00** | | +| 城市命中(30) + 行业未命中(15) → 30/45 | **66.67** | HALF_UP 舍入 | +| 城市未命中(30) + 行业命中(15) → 15/45 | **33.33** | | +| 四方存在命中两项 → 15/65 | **23.08** | | +| 薪资低一单位(19999 < 20000) | **0.00** | 下界 | +| 薪资高一单位(20001) | **100.00** | | +| 岗位 salaryMax 为 null | **0.00** | | + +**跨服务逐值一致**:以反射调用两个 Service 的私有 `preferenceScore`,同一组输入下 +`JobRecommendationService` 与 `TalentDiscoveryService` 的返回值 +**`isEqualByComparingTo` 相等且 `scale` 相同**(三组输入:全命中 / 部分命中 / 全不命中)。 + +**overviewStatus 边界**(独立推导):全可用→`OK`;部分可用→`PARTIAL`;全不可用(全 `PENDING`)→`PENDING`; +含 `INSUFFICIENT_DATA`→`INSUFFICIENT_DATA`;空列表(计算结果入口)→`PENDING`、(快照入口)→`INSUFFICIENT_DATA`; +`null`(计算结果入口)→`NPE`;未知状态字符串→`INSUFFICIENT_DATA`;**输入顺序反转结果不变**。 + +--- + +## 7. 三类变异测试的失败证据 + +| # | 变异 | 文件 | 结果 | +|---|---|---|---| +| 1 | `if (computableCount > 0)` → `> 1`(overviewStatus 边界) | `CandidateOverviewStatusPolicy` | **18 run / 1 failure** ✔ | +| 2 | `INDUSTRY_WEIGHT = 15` → `10`(偏好权重) | `JobPreferenceScoreCalculator` | **18 run / 1 failure** ✔ | +| 3 | 985 名单删除 `"北京大学", `(被测试覆盖的学校) | `CandidateScoringRuleSet` | **18 run / 1 failure** ✔ | + +**恢复核验**: + +- 变异前的模式唯一性检查:三个替换串在目标文件中的出现次数均为 **1**; +- 恢复后 `git diff --quiet` 对两个**已跟踪**目标文件均为**逐字节一致**; + 其中 `JobPreferenceScoreCalculator.java` 的 SHA-256 恢复为 `5771647b…`, + 与**首次诊断时**测得的哈希完全相同; +- 未跟踪的 `CandidateScoringRuleSet.java` 经内容检查确认变异条目已还原 + (`"北京大学", "清华大学", "中国人民大学"` 出现次数为 1); +- `git diff --name-status` 仅显示 3 个预期文件,**无变异残留**;`git diff --check` exit 0; +- **未使用 `git restore` / `git checkout`**。 + +> 透明说明:脚本首次报告的三次 `restored_ok=NO` 经逐层排查确认是**我方脚本的变量取值缺陷**, +> 而非文件损坏 —— 用同一 Python 读写惯用法对未变异文件做往返实验,SHA-256 保持不变; +> 再对目标文件执行"变异→还原"全程,A/B 哈希一致(`5771647b…`)。 +> 上述三项内容级核验(git 逐字节一致 + 哈希比对 + 变异条目还原)均已独立完成。 + +--- + +## 8. Docker 启动路径与 `docker info` 结果 + +| 项 | 结果 | +|---|---| +| 复验开始时 `docker info` | **失败**(`failed to connect … npipe:////./pipe/dockerDesktopLinuxEngine`) | +| 指定的已知路径 | `D:\DevApps\DockerDesktop\Docker Desktop.exe` —— **存在**(15,142,832 字节,2025-07-23) | +| 启动命令 | `Start-Process -FilePath 'D:\DevApps\DockerDesktop\Docker Desktop.exe' -WindowStyle Hidden` | +| 轮询 | 每 15 秒一次,上限 180 秒 | +| **结果** | **约 15 秒后成功**:`docker info` → `ServerVersion=29.6.2` ✔ | + +未重建、删除或清理任何容器、volume 或 image。 + +--- + +## 9. Testcontainers 实际执行数 + +``` +mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest,CandidateScoringRefreshMySqlIntegrationTest,JobSearchQuerySideEffectMySqlIntegrationTest,JobSearchQueryExplainMySqlIntegrationTest" test +→ Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS +``` + +| 测试类 | Tests | Skipped | +|---|---|---| +| `JobSearchSpecificationMySqlIntegrationTest` | 15 | **0** | +| `CandidateScoringRefreshMySqlIntegrationTest` | 6 | **0** | +| `JobSearchQuerySideEffectMySqlIntegrationTest` | 4 | **0** | +| `JobSearchQueryExplainMySqlIntegrationTest` | 1 | **0** | +| **合计** | **26** | **0** ✔ | + +这 26 项正是此前因 Docker 不可用而跳过的全部用例,现已**真实执行并通过**。 + +--- + +## 10. 定向、全量、clean verify 的 Tests/Failures/Errors/Skipped + +| 命令 | Tests | Failures | Errors | Skipped | 结论 | +|---|---|---|---|---|---| +| 独立复验测试 `R01CIndependentVerificationTest` | **18** | 0 | 0 | 0 | ✔ | +| 定向 `*CandidateScoring*Test,*OverviewStatus*Test,*JobPreference*Test,*JobRecommendation*Test,*TalentDiscovery*Test,*Fairness*Test` | 296 | **2** | 0 | 0 | 见 §10.3(既有隔离脆弱性) | +| `mvn test` | 见 §10.1 | | | | | +| `mvn clean verify` | 见 §10.1 | | | | | + +### 10.1 全量与 clean verify + +``` +mvn test → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS +mvn clean verify → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS +``` + +| 项 | 数值 | +|---|---| +| 测试总数 | **2743** | +| 下限校验 | 实施基线 **2725** + 本轮独立新增 **18** = **2743** ✔ **恰好等于下限** | +| Failures / Errors / **Skipped** | **0 / 0 / 0** ✔ | +| BUILD | **SUCCESS** ✔ | + +**Skipped=0 的达成路径**:实施阶段因 Docker 不可用而跳过的 26 个 Testcontainers 用例, +在本轮 Docker 启动后**全部真实执行**(见 §9),因此不再计入 Skipped; +`2743 = 2725(实施阶段总数,含 26 项跳过)+ 18(本轮独立新增)= 2699 执行 + 26 执行 + 18`。 + +**该结果同时反证 §10.3 的定性**:`FairnessMonitoringHttpIntegrationTest` 在**全量顺序下通过** +(`mvn test` 与 `mvn clean verify` 均为 0 失败),说明其失败只在我方**自选的定向类组合**下触发, +是组合相关的既有隔离脆弱性,而非 R01-C 的行为差异。 + +### 10.2 Testcontainers 已执行 + +见 §9(26 项,Skipped=0)。 + +### 10.3 定向命令中的 2 个失败:既有测试隔离脆弱性(与 R01-C 无关) + +**现象**:定向命令中 `FairnessMonitoringHttpIntegrationTest` 有 2 项失败: + +``` +$.data.populationSize expected:<100> but was:<115> +$.data.populationSize expected:<200> but was:<215> +``` + +**定性证据链**: + +1. **单独运行该类:4/4 全部通过**(`mvn -Dtest="FairnessMonitoringHttpIntegrationTest" test` → `Tests run: 4, Failures: 0`); +2. 失败仅在**与其他类同批**时出现,且两次都恰好多 **15** 行 → 典型的共享 H2 库数据泄漏; +3. **泄漏源已定位**:`candidatescore/dashboard/CompanyScoringDashboardPersistenceIntegrationTest` + 以 `LocalDate.now()` 生成 **当前期间**(`period = String.format("%04d-%02d", …)`), + 并向 `candidate_dimension_scores` 插入该期间的快照; + `FairnessMonitoringHttpIntegrationTest` 同样使用 `YearMonth.now()`,且其 `populationSize` + **未按自身前缀限定范围**,因此把同批类的行一并计入; +4. **该泄漏源不在 R01-C 改动范围内**(`git diff --name-only` 中无该文件); +5. R01-C 的生产改动仅为"教育规则常量改读规则集"与"偏好分改委托共用计算器", + **不涉及任何持久化、清理或计数逻辑**。 + +**判定**:属**既有测试隔离脆弱性**(与 A6/R01-B 定位的同类问题同源),**非 R01-C 行为差异**, +不构成 R01-C 阻断项。按任务要求**如实记录、不写成通过**,也不修改该类测试。 + +**明确结论**: + +1. **R01-C 单独测试通过** —— `FairnessMonitoringHttpIntegrationTest` 单独运行 4/4 全绿; + 全量 `mvn test` / `mvn clean verify`(2743/2743)中该类亦通过; +2. **泄漏源(`CompanyScoringDashboardPersistenceIntegrationTest`)与受害测试 + (`FairnessMonitoringHttpIntegrationTest`)均不在 R01-C 生产改动内**(3 个生产文件中无它们); +3. **该问题将由下一独立纯测试提交修复**(修污染源:独占 period/studentId + 精确清理), + **不并入 R01-C 提交**; +4. **该次定向组合命令(296/2 失败)不得写成全绿** —— 上表如实保留 `Failures=2`。 + +--- + +## 11. OpenAPI 数量 + +``` +python ..\docs\verify_openapi.py +→ paths: 176 / operations: 214 +→ All checks passed! +``` + +`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` **未被修改** ✔ + +--- + +## 12. 精确文件清单 + +**本轮 R01-C 生产改动(新增 1 + 修改 2 = 3 个生产文件)** + +``` +新增: mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java +修改: mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java +修改: mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java +``` + +**实施者新增测试(4)**:`mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/` 下 +`OverviewStatusPolicyCharacterizationTest`、`PreferenceScoreCharacterizationTest`、 +`CandidateScoringRuleSetCharacterizationTest`、`RecommendationOrderingCharacterizationTest` + +**本轮独立复验新增(2)** + +``` +mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CIndependentVerificationTest.java +docs/r01-c-independent-verification-20260918.md +``` + +**文档(1 修改)**:`docs/backend-development-roadmap.md` + +**本轮未修改任何生产代码** —— 复验前后 `git diff --name-only -- …/src/main` 恒为上述 2 个文件。 + +--- + +## 13. 是否修改迁移、ErrorCode、接口、依赖 + +| 项 | 结果 | +|---|---| +| V1–V45 / Flyway | **未修改**(`git diff --name-status -- …/db/migration` 为空) | +| `ErrorCode` | **未修改** | +| DTO | **未修改** | +| 公网接口 / OpenAPI 契约 | **未修改**(176/214,`openapi-rc.json` 未变) | +| `pom.xml` / 依赖 | **未修改** | +| Security / Token / 角色 / requestId | **未修改** | +| D4 快照物化语义 | **未修改** | +| 排名、申诉、公平性抑制规则 | **未修改** | + +**未为了测试通过修改生产公式或放宽既有断言**(独立测试的期望值均独立推导; +实施者的 40 个特征测试断言未被改动)。 + +--- + +## 14. Git 状态 + +| 项 | 结果 | +|---|---| +| 分支 | `refactor/r01-structure-performance` ✔ | +| HEAD | `9954600650c3e7cd40a5f4fa55bc38595973f214`(未变)✔ | +| `git diff --cached --name-only` | **空** ✔ | +| `git diff --check` | **exit 0** ✔ | +| `git diff --name-status` | 仅 `docs/backend-development-roadmap.md`、`CandidateScoringCalculator.java`、`TalentDiscoveryService.java`(**无变异残留**)✔ | +| HEAD 与 upstream | `0 0` ✔ | +| 历史未跟踪文件 | 完整保留 ✔ | +| 残留 Java/Maven 进程 | 无 ✔ | + +--- + +## 15. 未暂存、未提交、未推送、未进入 R01-E + +**确认:** 未执行 `git add`;未创建提交;未推送;未执行 `reset`/`restore`/`checkout`/`clean`; +未进入 R01-E;未继续开发。 + +--- + +> **R01-C独立代码复验通过,可以进入精确暂存与独立提交检查点。** diff --git a/docs/r01-c-pure-calculation-instruction.md b/docs/r01-c-pure-calculation-instruction.md new file mode 100644 index 0000000..fca1342 --- /dev/null +++ b/docs/r01-c-pure-calculation-instruction.md @@ -0,0 +1,17 @@ +# R01-C 纯计算与重复规则收束实施指令 + +## 目标 + +在不改变评分、排名和推荐结果的前提下,收束候选人综合状态规则,并将岗位偏好评分拆成可独立验证的纯计算器。 + +## 行为边界 + +- 评分引擎继续按固定 8 维判断完整性,且 OK/ESTIMATED 必须有非空分数才可计算; +- 竞争排名继续按当前快照集合大小判断完整性,并保持历史“按状态可计算”的口径; +- 推荐偏好权重保持城市30、用工类型20、最低薪资20、行业15、岗位族15; +- 薪资边界继续使用 `salaryMax >= expectedSalaryMin`; +- 百分比继续保留两位小数并使用 HALF_UP; +- 不改数据库、接口、DTO、排序、分页、权限、审计或开关语义; +- 不进入 R01-D/E,不暂存、不提交、不推送。 + +必须先用特征测试锁定两套 overview 输入适配差异和偏好评分边界,再替换生产调用点。 diff --git a/docs/r01-c-pure-calculation-report.md b/docs/r01-c-pure-calculation-report.md new file mode 100644 index 0000000..d755d17 --- /dev/null +++ b/docs/r01-c-pure-calculation-report.md @@ -0,0 +1,24 @@ +# R01-C 纯计算与重复规则收束实施报告 + +> 状态:R01-C 已实现并通过自动化与干净构建,待独立代码复验。 + +## 实现结果 + +新增 `CandidateOverviewStatusPolicy`,用一个纯决策内核产出 OK/PARTIAL/PENDING/INSUFFICIENT_DATA。两个入口显式保留历史适配差异:评分引擎固定 8 维并要求 score 非空;竞争排名按已有快照数量和状态判断。原两个业务类中的 `overviewStatus` 实现均已删除。 + +新增 `JobPreferenceScoreCalculator`,把原先压缩在单行方法中的 30/20/20/15/15 权重计算提取为无 Spring、无数据库依赖的纯函数。`JobRecommendationService` 仅负责解析已有 JSON 字段并组装输入;权重、匹配条件、空信号返回 null、薪资相等边界及 HALF_UP 两位小数全部保持。 + +## 测试 + +- `CandidateOverviewStatusPolicyTest`:5 条,覆盖两个适配入口的 OK/PARTIAL/PENDING/INSUFFICIENT_DATA、空集合、固定八维、null score 差异,以及评分结果列表为 null 时的历史快速失败语义; +- `JobPreferenceScoreCalculatorTest`:7 条,覆盖无信号、全匹配、全不匹配、权重与舍入、薪资相等/null、null 集合,以及历史 JSON 数组中 null 元素的兼容性; +- 独立复核发现 `List.copyOf` 会拒绝历史 JSON 数组中的 null 元素,可能把原先可计算的数据变成 500;已改为允许 null 的防御性只读拷贝,并先补回归测试锁定旧语义; +- 定向关联集合:33/33; +- 第一次定向测试因夹具让城市和行业同时匹配却期望 66.67 而失败,修正为行业不匹配后同一断言通过;未改生产公式或放宽断言。 + +## 全量结果 + +- `mvn clean verify`:2094/2094,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项均为既有环境条件跳过项,R01-C 定向与关联测试 Skipped=0; +- OpenAPI:176 paths / 214 operations,All checks passed; +- V1–V43 未修改,无新增接口、ErrorCode、依赖、表或索引; +- 暂存区为空,未提交、未推送,历史未跟踪产物保留。 diff --git a/docs/r01-c-scoring-recommendation-rules-refactor-report.md b/docs/r01-c-scoring-recommendation-rules-refactor-report.md new file mode 100644 index 0000000..07a9732 --- /dev/null +++ b/docs/r01-c-scoring-recommendation-rules-refactor-report.md @@ -0,0 +1,437 @@ +# R01-C 评分与推荐规则结构收束报告 + +日期:2026-09-18 +分支:`refactor/r01-structure-performance` +HEAD:`9954600650c3e7cd40a5f4fa55bc38595973f214`(R01-B 提交 `70b9e10` + 夹具修复 `9954600`) +本轮性质:**结构重构** —— 未暂存、未提交、未推送,待独立代码复验 +铁律:**所有输入对应的评分、状态、排序、推荐结果、解释内容、规则版本与舍入结果必须与重构前逐项一致** + +--- + +## 0. 结论与一处必须先说明的口径差异 + +> **R01-C评分与推荐规则结构收束完成,待独立代码复验。** + +**权威库存(§2)推翻了指令的两项前提,已按你的裁决实施:** + +| 指令主题 | 库存实际 | 本轮处置 | +|---|---|---| +| 1. 消除**重复的 overviewStatus** 规则 | **不存在重复**:全仓 `"PARTIAL"` 只出现在 `CandidateOverviewStatusPolicy`(状态聚合器必须能产出 PARTIAL),3 个调用点已全部委托它。该策略由既有提交 `b68519e` 创建 | **无可合并项,不做任何改动**;以结构门禁测试锁定"唯一产出点" | +| 2. 拆分 `JobRecommendationService.preferenceScore()` | 该目标方法**已是 13 行编排方法**,重逻辑已在 `JobPreferenceScoreCalculator`(同为 `b68519e` 产物)。**但** `TalentDiscoveryService.preferenceScore` 内联了**第二套**完全相同的 30/20/20/15/15 规则 | 按裁决**收束该真实重复**:改委托共用计算器,**规则实现处 2 → 1** | +| 3. 高校名单/阈值/权重强类型化 | **真实待办**:`CandidateScoringCalculator` 内为裸常量,无规则集类 | **已完成**:新增 `candidatescore/rules/CandidateScoringRuleSet` | + +**未为了"grep 归零"制造改动**;主题 1 的"已唯一"结论附结构门禁测试可证。 + +--- + +## 1. 分支、HEAD、Git 状态 + +| 项 | 结果 | +|---|---| +| `git branch --show-current` | `refactor/r01-structure-performance` | +| `git rev-parse HEAD` | `9954600650c3e7cd40a5f4fa55bc38595973f214` ✔ | +| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` ✔ | +| `git diff --cached --name-only` | **空** ✔ | +| `git diff --check` | exit 0,无空白错误 ✔ | +| 开始前未提交的已跟踪修改 | **无**(工作区干净)✔ | +| 历史未跟踪文件 | 完整保留 ✔ | + +--- + +## 2. 上游: 三份权威库存 + +### 2.1 overviewStatus 库存 + +| 项 | 事实 | +|---|---| +| 实现处 | **唯一**:`candidatescore/CandidateOverviewStatusPolicy`(`final`、纯静态、无 Spring 依赖、无 DB、无系统时间、无副作用) | +| 入口 | `fromComputedResults(List, int expectedDimensions)`、`fromSnapshots(List)` | +| 调用方 | `CandidateScoringEngineService`(2 处)、`CompetitionRankingCalculator`(1 处)—— 全部为委托 | +| 判断顺序 | ① 空/null → `emptyStatus`(计算结果入口为 `PENDING`,快照入口为 `INSUFFICIENT_DATA`);② 可计算数 == 期望数 → `OK`;③ 可计算数 > 0 → `PARTIAL`;④ 全为 `PENDING` → `PENDING`;⑤ 否则 `INSUFFICIENT_DATA` | +| 可计算定义 | `valueAvailable && ("OK".equals(status) || "ESTIMATED".equals(status))` | +| 阈值 | 无数值阈值(纯计数比较,边界为"相等"与"大于 0") | +| null 行为 | 计算结果入口:`results == null` → **NPE**(`Objects.requireNonNull`);快照入口:`null` → `INSUFFICIENT_DATA` | +| 边界相等 | `computableCount == completeCount` 判 OK(相等即 OK,不是严格大于) | +| 返回值 | 字符串状态(沿用既有字符串契约,**未新建语义重复的枚举**) | +| 是否参与响应/快照/解释/排名/监控 | 参与:候选评分概览响应、竞争力排名卡状态;不参与快照版本推进 | +| 两份实现是否等价 | **不适用 —— 只有一份实现** | + +### 2.2 preferenceScore 库存(重构前,两套) + +| 项 | `JobPreferenceScoreCalculator`(共用) | `TalentDiscoveryService`(内联,已收束) | +|---|---|---| +| 输入 | `Criteria(cities, employmentTypes, expectedSalaryMin, industries, jobFamilies)` + `Target(city, employmentType, salaryMax, industryCode, jobFamilyCode)` | `PreferenceSignal(cities, employmentTypes, expectedSalaryMin, industries, jobTypes, …)` + `Job` + `JobRecommendationProfile` | +| 维度与权重 | 城市 30 / 用工类型 20 / 最低薪资 20 / 行业 15 / 岗位族 15 | **完全相同** | +| 缺失维度 | 该维度不计入 available(等效"不计分") | 完全相同 | +| 空集合/null | `Criteria` 紧凑构造器把 `null` 归一化为 `List.of()`;`expectedSalaryMin == null` 时该维度不计 | `readStrings` 恒返回非 null 列表;`expectedSalaryMin == null` 同上 | +| 字符串规范化 | **无**(精确匹配,不 trim、不大小写折叠) | **无**(同) | +| 计算顺序 | 城市 → 用工类型 → 薪资 → 行业 → 岗位族(固定顺序累加) | **完全相同** | +| scale / RoundingMode | `divide(..., 2, HALF_UP)`,`BigDecimal.valueOf(matched * 100L)` | `BigDecimal.valueOf(got * 100L).divide(..., 2, HALF_UP)` —— **同一式子** | +| 上下界 | `available == 0 → null`;否则 ∈ [0.00, 100.00] | 同 | +| 对总分/排序影响 | `JobRecommendationService` 中偏好分权重 40(与 RIASEC 35、能力 25 加权平均后降序 + jobId 升序) | `TalentDiscoveryService` 中作为候选打分的一个分量 | + +### 2.3 硬编码规则库存 + +| 项 | 重构前位置 | 数量/值 | +|---|---|---| +| 985 高校集合 | `CandidateScoringCalculator.C985`(`new HashSet<>(List.of(...))`) | **38 项**(38 唯一) | +| 211 高校集合 | `CandidateScoringCalculator.C211` | **62 条字面量 / 61 唯一**(`北京科技大学` 重复一次) | +| 层级判定顺序 | `isBlank → 985 → 211 → endsWith("大学"/"学院") → 兜底` | 985 优先于 211 | +| 学历层次分数 | 内联 switch | 博士 100 / 硕士 85 / 本科 70 / 大专 55 / 其他 50 | +| 学校档次分数 | 内联 | 985=85 / 211=75 / 其他=65 / 缺失=50 | +| 教育合成权重 | 内联 | 0.55 / 0.45;子项满分 55 / 45 | +| 默认维度权重 | `DEFAULT_WEIGHT` | `new BigDecimal("12.50")` | +| 上界常量 | `HUNDRED` | `new BigDecimal("100")` | +| rulesVersion | `RULES_VERSION` | `"candidate-scoring-v0.1"`(**另有** `AbilityScoreMapper.MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1"`,语义不同) | +| 消费者 | `CandidateScoringEngineService`、`CompetitionRankingCalculator`、`TalentDiscoveryService`、`CandidateScoreCardService` 等 | +| 同数不同义 | 存在且**保持独立命名**:`85`(学历硕士)≠ `85`(学校档次 985);`50`(学历其他)≠ `50`(学校缺失/兜底) | + +--- + +## 3. 重构前特征测试(先建基线) + +目录 `mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/`,**40 个用例**, +期望值均为**手工推算或独立抄录**(不调用被测实现生成),使用 `isEqualByComparingTo` 并额外断言 `scale` 与 `toPlainString()`,**未使用 `isCloseTo` 之类宽松误差**。 + +| 测试类 | Tests | 覆盖 | +|---|---|---| +| `OverviewStatusPolicyCharacterizationTest` | 8 | 四种状态、空集合、null、`expectedDimensions` 门禁、OK 但分数缺失、两入口语义差异、**唯一产出点结构门禁** | +| `PreferenceScoreCharacterizationTest` | 17 | 全匹配/全不匹配/部分匹配/单维度、薪资相等边界与 null、四维加权 82.35、空集合与 null、大小写与脏值、空串、上下界 | +| `CandidateScoringRuleSetCharacterizationTest` | 10 | 985 首/中/末、211 首/中/末与重复项、两名单无交集、精确匹配(空白/简繁/大小写不匹配)、学历与档次分数、版本、八维度、只读集合 | +| `RecommendationOrderingCharacterizationTest` | 5 | 分数不同降序、同分按 jobId、完全同分确定性、score=null 抛 NPE、分页切片越界 | + +**基线运行(重构前):`Tests run: 40, Failures: 0, Errors: 0, Skipped: 0`** —— 见 `docs/acceptance/r01c/baseline.log`。 +基线期间发现并如实修正了一处**我方测试假设错误**:`isValidDimension(null)` 实际抛 `NullPointerException`(`Set.of` 契约),并非返回 `false`;已按真实行为锁定,未改生产代码。 + +--- + +## 4. overviewStatus 唯一策略 + +**未新增组件**:`CandidateOverviewStatusPolicy` 已是唯一策略且无 Spring/DB/时间依赖。 +本轮仅**新增结构门禁测试**:扫描 `src/main/java/com/mydreamjob` 下所有 `.java`, +断言 `"PARTIAL"` 的出现文件**恰好**为 `CandidateOverviewStatusPolicy.java`。 +若将来出现第二个聚合器,该测试立即失败。 + +--- + +## 5. 两份旧实现是否完全等价 + +**不存在两份 overviewStatus 实现**,故不适用"合并前等价性比对"。§六 的停止条件(两实现不等价)**未触发**。 + +**preferenceScore 的两套实现经逐行比对为等价**: + +| 比对项 | 结论 | +|---|---| +| 维度顺序 | 城市 → 用工类型 → 薪资 → 行业 → 岗位族,**一致** | +| 权重 | 30/20/20/15/15,**一致** | +| 缺失维度处理 | 不计入分母,**一致** | +| 薪资判定 | `job.salaryMax != null && salaryMax >= expectedSalaryMin`,**一致** | +| 岗位族取值 | `profile.getJobFamilyCode()`,**一致** | +| 计算式 | `BigDecimal.valueOf(x * 100L).divide(BigDecimal.valueOf(total), 2, HALF_UP)`,**一致** | +| 零分母 | `null`,**一致** | +| null 列表 | 计算器归一化为空列表;内联版由 `readStrings` 保证非 null —— **观察行为一致** | + +--- + +## 6. preferenceScore 拆分 + +- `JobRecommendationService.preferenceScore()`:**13 行**(重构前后一致),职责为"提取 Criteria/Target → 委托计算器",已是可快速读懂的编排方法。 +- `TalentDiscoveryService.preferenceScore()`:由 **12 行内联公式** 改为 **17 行编排**(含 javadoc),规则实现移出。 +- 拆分粒度按**实际存在的维度**(5 个),**未为匹配示例创造不存在的维度**;未引入 Stream;未改变可见性;未产生无意义的一行包装。 +- **30/20/20/15/15 规则实现处:2 → 1**(`grep "total += 30\|CITY_WEIGHT"`)。 + +--- + +## 7. BigDecimal 运算顺序与舍入证明 + +| 项 | 重构前 | 重构后 | 一致 | +|---|---|---|---| +| 偏好分构造 | `BigDecimal.valueOf(matched * 100L)` | 同一表达式(改为计算器内执行) | ✔ | +| 除法 | `divide(BigDecimal.valueOf(available), 2, RoundingMode.HALF_UP)` | 同 | ✔ | +| 教育维度 | `eduLevel*0.55 + schoolTier*0.45`,`setScale(2, HALF_UP)` | 同一表达式(权重改读规则集常量,**值相同**) | ✔ | +| `MathContext.DECIMAL128` | 未使用 | 未引入 | ✔ | +| scale | 全部不变(断言 `toPlainString()`) | 同 | ✔ | +| 新引入 double/float | — | **无** | ✔ | + +特征测试 `PreferenceScoreCharacterizationTest` 逐项断言 `scale == 2` 与精确字符串(如 `"82.35"`、`"66.67"`、`"33.33"`)。 + +--- + +## 8. 强类型规则设计 + +新增 `candidatescore/rules/CandidateScoringRuleSet`: + +| 要求 | 落实 | +|---|---| +| `final` / 不可变 / 无 setter | ✔ `public final class`,私有构造,全部 `static final` | +| 不可变集合 | ✔ `Collections.unmodifiableSet(new HashSet<>(...))` | +| 唯一当前规则实例 | ✔ 静态常量(随代码发布变更) | +| 版本常量 | ✔ `VERSION = "candidate-scoring-v0.1"`,**与重构前逐字一致** | +| 不接数据库/外部动态配置 | ✔ 无 Spring、无 JDBC、无配置读取 | +| BigDecimal 字符串构造 | ✔ 全部 `new BigDecimal("…")` | +| 同数不同义不合并 | ✔ `EDUCATION_LEVEL_MASTER(85)` 与 `SCHOOL_TIER_985(85)` 独立命名;`EDUCATION_LEVEL_UNKNOWN(50)` 与 `SCHOOL_TIER_MISSING(50)` 独立命名 | +| 按业务语义组织 | ✔ 版本与汇总 / 学历层次 / 学校档次 / 高校名单 四组 | +| 对外只读 | ✔ `c985()` / `c211()` 返回不可修改视图(测试断言 add/remove/clear 均抛 `UnsupportedOperationException`) | + +**未建立"杂物常量类"**:只收纳本轮明确要求的四组,未把计算器内其余维度阈值一并塞入。 + +--- + +## 9. 高校名单:数量与逐项一致证明 + +| 项 | 重构前(计算器内) | 重构后(规则集) | +|---|---|---| +| 985 定义处 | 1 | 1(位置变更) | +| 985 唯一项 | 38 | **38** ✔ | +| 211 定义处 | 1 | 1(位置变更) | +| 211 字面量条数 | 62 | **62** ✔ | +| 211 唯一项 | 61 | **61** ✔ | +| `北京科技大学` 重复 | 存在 | **原样保留**(按裁决不去重) | +| 字符串 | — | 逐项未改动(首/中/末抽样断言) | +| 判定优先级 | 985 → 211 | 985 → 211 ✔ | +| 模糊匹配 | 无 | **无**(新增测试断言"北京大学 "、"北京大学"、"北京大學" 均不匹配) | +| 对外只读 | `HashSet`(可变,仅 private) | 不可修改视图 ✔ | + +**计算器内名单定义数:2 → 0**;名单只在唯一规则源定义。 + +--- + +## 10. 阈值和权重逐项一致 + +| 规则 | 重构前值 | 重构后常量 | 值 | +|---|---|---|---| +| 学历 博士 | 100 | `EDUCATION_LEVEL_DOCTOR` | 100 ✔ | +| 学历 硕士 | 85 | `EDUCATION_LEVEL_MASTER` | 85 ✔ | +| 学历 本科 | 70 | `EDUCATION_LEVEL_BACHELOR` | 70 ✔ | +| 学历 大专 | 55 | `EDUCATION_LEVEL_JUNIOR_COLLEGE` | 55 ✔ | +| 学历 其他 | 50 | `EDUCATION_LEVEL_UNKNOWN` | 50 ✔ | +| 档次 985 | 85 | `SCHOOL_TIER_985` | 85 ✔ | +| 档次 211 | 75 | `SCHOOL_TIER_211` | 75 ✔ | +| 档次 其他 | 65 | `SCHOOL_TIER_OTHER` | 65 ✔ | +| 档次 缺失 | 50 | `SCHOOL_TIER_MISSING` | 50 ✔ | +| 教育缺失维度分 | 100 | `EDUCATION_MISSING_SCORE` | 100 ✔ | +| 学历权重 | 0.55 | `EDUCATION_LEVEL_WEIGHT` | 0.55 ✔ | +| 档次权重 | 0.45 | `EDUCATION_SCHOOL_TIER_WEIGHT` | 0.45 ✔ | +| 子项满分 | 55 / 45 | `EDUCATION_LEVEL_FULL_SCORE` / `…TIER…` | 55 / 45 ✔ | +| 默认维度权重 | 12.50 | `DEFAULT_DIMENSION_WEIGHT` | 12.50 ✔ | +| 满分上界 | 100 | `FULL_SCORE` | 100 ✔ | + +**裸教育规则 BigDecimal 命中数(计算器内):23 → 6**(其余 6 处为非教育维度阈值,本轮未纳入规则集)。 +数值一律为字符串构造,**未把整数阈值错误转换为百分比小数**。 + +--- + +## 11. rulesVersion + +| 项 | 值 | +|---|---| +| 常量 | `CandidateScoringRuleSet.VERSION` | +| 值 | **`candidate-scoring-v0.1`**(与重构前 `CandidateScoringCalculator.RULES_VERSION` **逐字一致**) | +| 是否新增版本 | **否** | +| 响应中的版本值 | **未改变**(`CandidateScoringCalculator.RULES_VERSION` 现指向同一常量) | +| 同义词区分 | `AbilityScoreMapper.MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1"` 保持独立,未合并 | +| 快照历史一致性 | 快照写入仍使用同一 `RULES_VERSION`,未变 | + +--- + +## 12. 排名顺序对账 + +`JobRecommendationService` 排序比较器**未改动**: +`Comparator.comparing(JobRecommendationItem::getScore).reversed().thenComparing(JobRecommendationItem::getJobId)`。 +`slice` 的下标计算未改动。特征测试锁定:分数降序、同分按 jobId 升序、完全同分确定性、越界切片为空。 + +**偏好分对排序的影响路径未变**:偏好分仍以权重 40 参与 `top/bottom` 加权平均(RIASEC 35、能力 25), +`divide(bottom, 2, HALF_UP)`。收束只替换了偏好分的**计算处**,未触碰分值本身。 + +--- + +## 13. 解释字段与贡献求和 + +| 校验 | 结论 | +|---|---| +| `CandidateScoringExplanationAssembler` 贡献求和 | 未修改该链路;`addContribution` 的入参在收束后**取值相同**(改读常量,值不变) | +| 各维度贡献之和 = 总分 | 教育维度仍为 `eduLevel*0.55 + schoolTier*0.45`,与两个 contribution 的求和一致 ✔ | +| overviewStatus 在响应/解释/快照/看板一致 | 唯一策略未被改动,仍由三处同一入口产出 ✔ | +| rulesVersion 在响应/快照/历史一致 | 同一常量 ✔ | +| 推荐解释中的偏好分 = 排序实际使用的分 | `TalentDiscoveryService` 收束后仍把同一计算结果用于打分与解释字段(同一局部变量 `p`) ✔ | +| "展示一套、排序另一套" | **未出现** ✔ | + +--- + +## 14. 查询数与副作用 + +| 项 | 结论 | +|---|---| +| 新增数据库查询 | **无**。规则集为静态常量;偏好分计算为纯函数 | +| 推荐一次查询的 SQL 数量 | **不增加**:`TalentDiscoveryService.preferenceScore` 收束后**零查询**(输入已由批量读取提供);`JobRecommendationService` 未变 | +| 评分计算新增写入 | **无** | +| GET 评分接口零写快照 | **未改动**该迁移 | +| RabbitMQ / 审计 / 通知 | 未新增 | +| 缓存键 | 未改变 | + +--- + +## 15. 变异测试(三类,均恢复原字节) + +| # | 变异 | 目标文件 | 结果 | +|---|---|---|---| +| 1 | `isComputable` 中 `"ESTIMATED"` → `"OK"`(改变状态边界) | `CandidateOverviewStatusPolicy` | **8 run / 2 failures** ✔ | +| 2 | `SALARY_WEIGHT` 20 → 25(改变偏好权重) | `JobPreferenceScoreCalculator` | **17 run / 1 failure** ✔ | +| 3 | 985 名单删除 `"北京大学"` | `CandidateScoringRuleSet` | **10 run / 2 failures** ✔ | + +**恢复核验**:三个文件均已还原,其中 #1 因还原脚本误替换了同一行中的另一处 `"OK".equals`, +已定位并**逐字节修正**,`git diff --quiet` 确认与 HEAD 完全一致;#2、#3 的 SHA-256 恢复一致。 +**未使用 `git restore/checkout`**;变异内容未留在工作区(见 §20 的差异范围核对)。 + +--- + +## 16. 定向、关联、全量测试数 + +| 命令 | 结果 | 日志 | +|---|---|---| +| R01-C 定向(4 个特征类) | **40 / 40**,F=0 E=0 S=0 ✔ | `baseline.log` / `after-characterization.log` | +| 关联回归(`*CandidateScoring*Test,*ScoringDashboard*Test,*Fairness*Test,*JobRecommendation*Test,*Recommendation*Test,*CompetitionRanking*Test,*Appeal*Test,*TalentDiscovery*Test`) | **331 / 331**,F=0 E=0,**Skipped=6** | `related.log` | +| `mvn test` | 见 §16.1 | `full-mvntest.log` | +| `mvn clean verify` | 见 §16.1 | `clean-verify.log` | + +### 16.1 全量结果与 Docker 说明(如实记录) + +``` +mvn test → Tests run: 2725, Failures: 0, Errors: 0, Skipped: 26 BUILD SUCCESS +mvn clean verify → Tests run: 2725, Failures: 0, Errors: 0, Skipped: 26 BUILD SUCCESS +``` + +| 项 | 数值 | +|---|---| +| 测试总数 | **2725** = 本轮基线 **2685** + 本轮新增 **40** ✔(**不低于 2685** 的要求满足) | +| Failures / Errors | **0 / 0** ✔ | +| **Skipped** | **26 —— 未达"0"要求**,原因如下 | + +#### Skipped=26 的准确原因 + +26 个跳过**全部**来自 4 个 MySQL / Testcontainers 类(与 R01-B 复验时同一批): +`JobSearchSpecificationMySqlIntegrationTest`(15)、`CandidateScoringRefreshMySqlIntegrationTest`(6)、 +`JobSearchQuerySideEffectMySqlIntegrationTest`(4)、`JobSearchQueryExplainMySqlIntegrationTest`(1)。 +它们标注 `@Testcontainers(disabledWithoutDocker = true)`,Docker 不可用时按设计跳过。 + +**环境状态变化(非代码原因)**: + +- 本轮**开始时** `docker info` 成功:`ServerVersion=29.6.2`(满足 §三 的"Docker 可用"前置要求); +- 执行到关联回归时再次检查,Docker 守护进程已停止: + `failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine`; +- `wsl -l -v` 显示 `docker-desktop` 发行版为 **Stopped**; + `C:\Program Files\Docker\Docker\Docker Desktop.exe` **不存在**(该目录下仅有 `cli-plugins`), + `%LOCALAPPDATA%\Docker` 下只有 log/run/tasks/wsl 等运行期目录,**没有可启动的 Desktop 前端**。 + +**处置**:未强行以非受支持方式拉起 Docker(`wsl -d docker-desktop` 明确不被支持), +**不声称 Skipped=0**。除这 26 个 Docker 条件用例外的 **2699 个测试全部执行且全部通过**。 +**不构成 R01-C 阻断项**。 + +#### 独立复验补齐(2026-09-18,本段为复验追加,不覆盖上述事实) + +独立复验发现了**正确的 Docker Desktop 安装路径**并据此启动: + +| 项 | 结果 | +|---|---| +| 路径 | `D:\DevApps\DockerDesktop\Docker Desktop.exe`(存在,15,142,832 字节) | +| 启动 | `Start-Process -FilePath '…\Docker Desktop.exe' -WindowStyle Hidden`,轮询上限 180 秒 | +| `docker info` | **约 15 秒后成功** → `ServerVersion=29.6.2` | +| 原先跳过的 26 项 | **26 / 26 实际执行**,`Tests run: 26, Failures: 0, Errors: 0, Skipped: 0` | + +**最终全量(复验阶段)**: + +``` +mvn test → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS +mvn clean verify → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS +``` + +`2743 = 2725(本报告阶段总数,含 26 项跳过)+ 18(独立复验新增测试)`; +其 **Skipped=0** 正是因为那 26 项已真实执行。**本阶段未改动任何代码即补足了这 26 项。** + +--- + +## 17. OpenAPI + +``` +python ..\docs\verify_openapi.py +→ paths: 176 / operations: 214 +→ All checks passed! +``` + +`git diff -- docs/openapi-rc.json` → **空** ✔ + +--- + +## 18. 精确修改文件 + +**生产代码变更总数:新增 1 个生产类、修改 2 个生产类,共 3 个生产文件。** + +**新增生产类(1)** + +``` +mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java +``` + +**修改生产类(2)** + +``` +candidatescore/CandidateScoringCalculator.java ← 版本/权重改读规则集;删除 C985/C211;教育数值改读常量 +talentdiscovery/TalentDiscoveryService.java ← preferenceScore 委托 JobPreferenceScoreCalculator +``` + +> **独立复验补充(2026-09-18)**:`git status` 会在 Windows 上把 +> `CandidateOverviewStatusPolicy.java` 与 `JobPreferenceScoreCalculator.java` 标为 ` M` +> (变异实验重写过这两个文件、mtime 变化)。经 `git hash-object` 与 `git rev-parse HEAD:` +> 比对,两者 blob **完全相同**,属 **Windows stat 缓存假阳性**,**不在本提交范围内**。 + +**新增测试(4)**:`common/r01c/` 下 4 个特征测试类 + +**文档(2)**:`docs/r01-c-scoring-recommendation-rules-refactor-report.md`(新增)、 +`docs/backend-development-roadmap.md`(更新) + +> **未新增**:`CandidateOverviewStatusPolicy` 相关改动(已唯一)、`JobRecommendationService` 改动(已是编排)。 + +--- + +## 19. Flyway / ErrorCode / DTO / 接口变化 + +| 项 | 结果 | +|---|---| +| Flyway / 迁移 | **未改动** ✔ | +| `ErrorCode` | **未改动** ✔ | +| DTO / 响应结构 | **未改动** ✔ | +| 公网接口 / OpenAPI | **未改动**(176/214,`openapi-rc.json` 未变) ✔ | +| Token / 权限 / requestId | **未改动** ✔ | +| 依赖 / `pom.xml` | **未改动** ✔ | +| 评分公式数值 / 排序 / 同分规则 / 小样本与互补风险抑制 / 申诉回滚 / 快照版本推进 / Fairness / 消息与 Offer 等 | **均未改动** ✔ | + +--- + +## 20. Git 状态 + +| 项 | 结果 | +|---|---| +| 分支 / HEAD | `refactor/r01-structure-performance` / `9954600…`(未变) | +| `git diff --cached --name-only` | **空** ✔ | +| `git diff --check` | exit 0 ✔ | +| 生产代码改动范围 | **恰为 2 个文件**,与 §18 一致(变异内容未残留)✔ | +| 已暂存 / 已提交 / 已推送 | **均无** ✔ | +| 历史未跟踪文件 | 未删除 ✔ | + +--- + +## 21. 未暂存、未提交、未推送 + +**确认。** 未执行 `git add`;未创建提交;未推送;未执行 `reset`/`restore`/`checkout`/`clean`; +未进入 R01-E。 + +--- + +## 22. 未进入 R01-E + +**确认。** 本轮未处理 N+1、会话未读数、`overviewStatus` 之外的其他查询收束、 +数据库迁移或任何 R01-E 范围内的主题。 + +--- + +> **R01-C评分与推荐规则结构收束完成,待独立代码复验。** diff --git a/docs/r01-d1-percentile-performance-report.md b/docs/r01-d1-percentile-performance-report.md new file mode 100644 index 0000000..529e780 --- /dev/null +++ b/docs/r01-d1-percentile-performance-report.md @@ -0,0 +1,25 @@ +# R01-D1 百分位性能收束报告 + +> 状态:R01-D1 已实现并通过自动化与干净构建,待独立复验。 + +## 修改范围 + +- 新增纯函数 `CandidatePercentileCalculator`; +- `CompanyScoringDashboardService` 保留原调用位置,仅将百分位计算委托给纯函数; +- 未修改候选人综合分、排序、分页、过滤、披露门槛或响应结构。 + +## 算法与兼容性 + +旧实现对每个候选分数再次扫描全部分数,复杂度为 O(n²)。新实现先排序,再以每个分数第一次出现的位置作为“严格低于该分数的数量”,复杂度为 O(n log n)。分母仍为 `有效分数数 - 1`,仍使用 10 位中间精度、HALF_UP,并最终保留两位小数。 + +等价测试覆盖空集合、单样本、全 null、输入顺序、null 位置、并列分数,以及由 `{null, 0, 1.0, 1.00, 2.5}` 生成的长度 0–6 全组合,并逐项与旧 O(n²) 公式比较。 + +首次实现使用 `HashMap`,测试立即发现 `1.0` 与 `1.00` 被错误视为不同键;改为按 `BigDecimal.compareTo` 排序的 `TreeMap` 后恢复旧语义。另一个首次失败来自手写并列夹具把最高分期望误写为 66.67;旧公式预言机证明应为 100.00,修正夹具期望,未改变生产公式。 + +## 验证 + +- 定向与看板关联测试:28/28,Failures=0、Errors=0、Skipped=0; +- `mvn clean verify`:2099/2099,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项为既有环境条件跳过项,R01-D1 定向及关联测试 Skipped=0; +- OpenAPI:176 paths / 214 operations,All checks passed; +- 无 Flyway、ErrorCode、公网接口、表、索引或依赖变化; +- 未暂存、未提交、未推送。 diff --git a/docs/r01-d2-personal-data-query-report.md b/docs/r01-d2-personal-data-query-report.md new file mode 100644 index 0000000..9989a3b --- /dev/null +++ b/docs/r01-d2-personal-data-query-report.md @@ -0,0 +1,29 @@ +# R01-D2 个人数据导出查询收束报告 + +> 状态:R01-D2 已完成批量查询整改、自动化验证与干净构建,待独立复验。 + +## 原问题 + +`PersonalDataCollector` 在遍历父记录时逐条查询子记录:投递状态历史、反馈、面试历史、Offer 历史、会话、消息和评分申诉历史均形成 N+1;投递列表还被重复查询两次。 + +## 整改 + +- 每类父记录先收集 ID,再用 Repository `IN` 查询一次性读取子记录; +- 在内存中按父 ID 分组,并继续按原父记录顺序组装,保留时间线内部升序、消息内部降序和每会话最多 10000 条的既有语义; +- 投递列表只读取一次,同时供投递、时间线和反馈三个 section 使用; +- 会话实体改用 `findAllById`,消息按全部 conversationId 一次批量读取; +- 未改变导出 section 名称、字段、脱敏、简历文件读取或 ZIP 构建语义。 + +## 测试证据 + +- `PersonalDataCollectorQueryBudgetTest`:3 条; +- 投递规模 1 与 50 时,投递主查询、状态历史查询、反馈查询均固定各 1 次; +- 面试历史、Offer 历史、会话实体、消息、申诉历史均固定为每类 1 次批量查询;旧逐条 Repository 方法明确断言为 0 次; +- 父记录顺序与每个父记录下的时间线顺序有独立断言; +- 定向及数据导出关联回归:19/19,Failures=0、Errors=0、Skipped=0; +- `mvn clean verify`:2102/2102,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项为既有环境条件跳过项,R01-D2 定向与关联测试 Skipped=0; +- OpenAPI:176 paths / 214 operations,All checks passed。 + +## 边界 + +无 Flyway、表、索引、ErrorCode、公网接口或依赖变化;未暂存、未提交、未推送。 diff --git a/docs/r01-d3-conversation-unread-query-report.md b/docs/r01-d3-conversation-unread-query-report.md new file mode 100644 index 0000000..80f5344 --- /dev/null +++ b/docs/r01-d3-conversation-unread-query-report.md @@ -0,0 +1,48 @@ +# R01-D3 会话未读数查询收束报告 + +## 结论 + +`MessagingService.listConversations` 已从逐会话查询最近消息和已读状态,收束为一次分组未读统计。会话数量从 1 增长到 20、50 时,消息相关业务 SQL 数保持固定;本次未改变公网接口、响应字段、权限、消息撤回或已读语义。 + +## 修复前问题 + +会话列表先查询成员关系和会话,再对每个会话调用 `countUnread`。每次调用都会查询最近 500 条消息及其已读记录,因此总查询数随会话数线性增长。 + +## 实现 + +- `MessageRepository.countUnreadByConversationIds` 使用一条分组 SQL 批量统计全部会话。 +- 使用 `ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY created_at DESC)` 保留每个会话“最近 500 条”的历史窗口。 +- `RECALLED` 消息不计入未读,`status IS NULL` 仍按历史语义计入。 +- 存在当前用户 `message_reads` 的消息不计入未读。 +- 没有未读行的会话由 Service 映射为 `0`。 +- `getConversation` 的单会话路径未改动,避免扩大本批范围。 + +## 查询预算证据 + +`MessageListQueryBudgetIntegrationTest.listConversationsUsesFixedQueriesForOneTwentyAndFiftyConversations` 使用真实 Spring/JPA/H2 数据源代理统计业务 SQL,并混合 DIRECT、TALENT_INVITATION、SENT、RECALLED、已读和未读消息。 + +| 会话数 | 业务查询数 | 结果 | +| ---: | ---: | --- | +| 1 | 3 | PASS | +| 20 | 3 | PASS | +| 50 | 3 | PASS | + +三条查询分别为成员关系、会话批量读取、未读分组统计,查询数不随会话数增长。 + +## 语义边界 + +`groupedUnreadCountKeepsHistoricalLatestFiveHundredWindow` 构造 501 条顺序明确的消息,证明最旧消息位于窗口外时不被统计。测试首次编写时,实体的 `@CreatedDate` 覆盖夹具传入时间,造成 501 条消息时间并列;随后改为保存后按精确消息主键写入确定时间。该失败属于夹具问题,不是生产缺陷,也未通过放宽断言处理。 + +## 验证 + +- 定向边界测试:3/3,Failures=0、Errors=0、Skipped=0。 +- 关联消息测试:此前同批运行 42/42 通过。 +- `mvn clean verify`:2104 项,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项均为既有环境条件测试。 +- 静态 OpenAPI:176 paths / 214 operations,校验通过。 + +## 边界 + +- 未新增或修改 Flyway、表、索引、ErrorCode、公网接口。 +- 未改变 DIRECT / TALENT_INVITATION 响应隐私语义。 +- 未改变消息发送、撤回、已读、分页和权限语义。 +- 原生窗口 SQL 仍需在独立真实 MySQL 验收中复核执行计划与结果。 diff --git a/docs/r01-d4-1-scoring-materialization-implementation-report.md b/docs/r01-d4-1-scoring-materialization-implementation-report.md new file mode 100644 index 0000000..224cdd5 --- /dev/null +++ b/docs/r01-d4-1-scoring-materialization-implementation-report.md @@ -0,0 +1,87 @@ +# R01-D4-1「候选人评分快照可靠物化基础设施」实施报告 + +日期:2026-09-16 +分支:`refactor/r01-structure-performance`;HEAD 检查点:`b10c4a7 docs: design scoring snapshot materialization`(后于 `b56f792`) +性质:D4-1 实施(**GET 兜底未删除**,三 GET 仍经 upsertSnapshots 写库) + +## 1. Git 基线 +- 分支正确;设计检查点已提交 `b10c4a7`(3 个设计文件)。 +- 暂存区空;未 commit/push 本轮实现;V1–V43 未修改;V44 为唯一新增迁移。 +- `GlobalExceptionHandler.java` 保持行尾符标记现象(内容 diff 为空)未动。 + +## 2. V44 表结构(`V44__candidate_scoring_refresh_tasks.sql`) +`candidate_scoring_refresh_tasks`: id PK、student_user_id、target_period(7)、status、requested_at、processing_started_at、processed_at、next_attempt_at、attempts、last_error_code(40)、version、created_at、updated_at;`uk_refresh_task_student_period(student_user_id,target_period)`、`ck_refresh_task_status`(PENDING/PROCESSING/RETRY/COMPLETED/FAILED)、`idx_refresh_task_claim(status,next_attempt_at)`、FK→users(id) ON DELETE CASCADE。不保存 Token/手机号/简历/评分源数据。 + +## 3. 任务唯一键与状态机 +唯一键 (student_user_id, target_period)。状态机:PENDING→PROCESSING→COMPLETED;失败→RETRY(attempts+1、next_attempt_at 退避)→达上限 FAILED;COMPLETED/FAILED 再登记→PENDING+attempts=0;RETRY 再登记→PENDING(attempts 保留)。 + +## 4. 登记事务语义(`CandidateScoringRefreshRequestService#requestRefresh`) +原生参数化 upsert 合并;REQUIRED 传播——随调用方业务事务,**源业务提交失败则登记一并回滚**;登记不触发线程内计算、不发 RabbitMQ、不写审计、无敏感值;period 取 capturedNow 单次读取。 + +## 5. Worker 领取与并发(`CandidateScoringRefreshWorker`) +数据库条件更新领取(`status IN (PENDING,RETRY) AND next_attempt_at 到期` 置 PROCESSING,0 行即被并发方领取);无 JVM 锁/Thread.sleep。领取使用独立短事务;计算、8 维 Writer 与完成状态使用另一个 `REQUIRES_NEW` 原子事务;失败状态再用独立事务持久化。因此单维写失败会回滚该任务全部快照,但不会污染同批其他任务。并发双 Worker 仅一次有效物化。 + +## 6. PROCESSING 期间新请求防丢失 +登记对 PROCESSING 保留状态并推进任务 version;Worker 领取时记录 claimedVersion,完成时仅在 version 未变化时置 COMPLETED;处理期间有新请求导致 version 变化时回到 PENDING。另有 10 分钟(可配置)PROCESSING 租约超时回收:置 RETRY/WORKER_TIMEOUT 并允许其他实例重新领取,达到重试上限转 FAILED,避免进程退出造成永久卡死。 + +## 7. 重试与 FAILED +失败捕获→固定分类(COMPUTE_FAILED/WRITE_FAILED/UNKNOWN_REFRESH_FAILURE;租约超时为 WORKER_TIMEOUT)入 last_error_code(不含异常原文/SQL/手机号/堆栈)→独立事务 attempts+1→RETRY+next_attempt_at;达上限 FAILED;失败不回抛、不回滚同批其他任务;补偿扫描可重新登记。 + +## 8. Writer 比较字段(`CandidateScoringSnapshotWriter`) +score、status、sub_scores_json、weight、rules_version 全部相同→version/updated_at 不变;任一变化→version+1;calculated_at 不触发;JSON 写入前 canonical(字段字典序)防伪变化;null-safe 相等用 `(= ? OR (IS NULL AND ? IS NULL))`(MySQL/H2 双兼容)。 + +## 9. version 精确语义 +普通物化:值变才 +1(D4-1 已授权语义收敛——重复相同 GET/重放不再制造版本噪声);rulesVersion 变化强制 +1。 + +## 10. Governance 强制路径 +`recomputeAndSnapshot`(申诉决定/回滚)委托 `upsertForcingVersion` 无条件 +1,保持 `afterVersion=beforeVersion+1`、snapshot_history 与 scoreAtAppeal/snapshotVersionAtAppeal/回滚限额语义不变;普通物化不写 snapshot_history。 + +## 11. 实际接入的真实输入源(写侧 Service,同事务登记) +ProfileService#updateProfile;ResumeVersionService#createVersion/updateVersion/deleteVersion;PreferenceService#savePreferences;AbilityAttemptService#submit;ApplicationService#apply/withdraw/updateStatus;OfferService#transitionStatus;CandidateFeedbackService#createFeedback;MessagingService#sendMessage(仅 STUDENT 发送者登记)。补偿扫描兜底 audit/时间窗口。 + +## 12. 明确未接入 +RIASEC(AssessmentAttemptService);InterviewScoreService;JobReviewService;AbilityAttemptService#saveAnswers;ResumeVersionService#setDefault(公式未读默认标记);消息撤回;只读查询;AuditService 实时登记(防 刷新→审计→刷新 循环,审计变化由补偿扫描覆盖)。 + +## 13. 时间窗口与补偿扫描(`CandidateScoringRefreshCompensationScanner`) +基础版:按 user id 做 keyset 分页(页大小默认 100)遍历近 90 天投递/近 30 天审计活跃学生并登记刷新任务;一次扫描会跨越全部页面,不再反复只处理首批 ID;不直接写快照。rulesVersion 切换由事件/重扫覆盖。**补偿扫描仍是 D4-1 简化版,尚无持久化扫描游标与单轮总预算,完整窗口和大数据量调度规则留待 D4-2 前完善;GET 兜底因此必须保留**。 + +## 14. 修复前失败测试(TDD 证据) +实现前基线即检查点后现状(b10c4a7 上证据测试 5/5 全绿,其中"重复 GET version+1"断言证明旧行为);实施中 Writer 两次 H2 语法不兼容(`<=>`、VALUES() 表达式)→ Errors=7→4→0 收敛;@InjectMocks 缺新依赖 → Errors=55→30→0;Persistence 旧断言与新契约冲突 → 1 Failure 被改写为幂等断言。最终全绿。 + +## 15. H2 与真实 MySQL 结果 +H2 定向集成已覆盖:登记合并、并发领取、处理中重登记、租约超时恢复、跨三页以上补偿扫描、单任务中途写失败全回滚、失败状态独立提交、同批任务事务隔离及外层业务回滚。2026-09-16 Docker 恢复后新增 `CandidateScoringRefreshMySqlIntegrationTest`,使用独立 `mysql:8.4` Testcontainers、正式 V1–V44 Flyway 与 `ddl-auto=validate`,5/5 通过:V44/schema、Writer 幂等与强制版本、双线程领取唯一胜者、部分写失败全回滚且同批成功任务不受影响、PROCESSING 重登记及超时恢复。未复用或修改开发库。 + +## 16. 查询预算 +登记:1 条 upsert;Worker:到期 ID 查询 + 每任务独立 claim/load + computeAll + 8 维锁定比较/条件写入 + complete;失败另有 1 次独立状态写。补偿扫描每页 1 条 keyset 查询并为命中学生逐条登记;总查询数随符合条件学生数增长,尚未满足最终查询预算,不能据此进入 D4-2。 + +## 17. RabbitMQ 与数据库副作用 +Worker/登记不发布 RabbitMQ、不写 notification、不写 snapshot_history、不写 audit;测试 20/22 验证。快照仅 candidate_dimension_scores 与任务表。 + +## 18. 全量测试 +Docker 恢复后的最终实测 `mvn clean verify`:**2130/2130,Failures=0、Errors=0、Skipped=0,BUILD SUCCESS**。其中既有 Q01 MySQL 20 项与新增 D4-1 MySQL 5 项均真实执行。OpenAPI **176 paths / 214 operations**,校验通过。 + +## 19. OpenAPI +176 paths / 214 operations,Actuator 不进业务契约;无新增公网刷新接口。 + +## 20. 精确修改文件 +新增:V44 迁移;`candidatescore/refresh/`:CandidateScoringRefreshTask、CandidateScoringRefreshRequestService、CandidateScoringSnapshotWriter、CandidateScoringRefreshWorker、CandidateScoringRefreshTaskExecutor、CandidateScoringRefreshCompensationScanner、CandidateScoringRefreshScheduler;测试 `CandidateScoringRefreshInfrastructureTest`、`CandidateScoringRefreshFailureTransactionTest`。 +修改:CandidateScoringCalculator(upsert 委托 Writer + recompute 强制路径);写侧 8 Service(登记接入);application.yml(app.scoring.refresh.* 默认关闭);测试适配:R01D4GetWritesSnapshotEvidenceTest/CandidateScoringPersistenceIntegrationTest(version 幂等契约演进)、CandidateScoringCalculatorTest/ClockConsistencyTest/FairnessMonitoringPersistenceIntegrationTest(构造/委托验证)、6 个 Service 测试补 Mock 依赖(未放宽断言)。 + +## 21. Git 状态 +未 commit/push;暂存区空;V44 唯一新增迁移;历史未跟踪文件未删未移;GlobalExceptionHandler 行尾现象保持。 + +## 22. 进入 D4-2 的网关(当前:**未达到**) +- [x] 唯一 Writer + 幂等 version 语义(H2 验证) +- [x] 任务登记/防丢失/并发领取/重试/failure 分类(H2 验证) +- [x] 写侧 8 类事件登记 + 不接入项确认 +- [x] 真实 MySQL 8.4 核心基础设施验收(V44/validate、Writer、并发领取、事务隔离、重登记与超时恢复) +- [ ] D4-2 前消费者黑盒(GET 零写切换、趋势/申诉/排名/看板/监控、副作用与 RabbitMQ) +- [ ] 补偿扫描完整窗口规则 + 查询预算真实库验证 +- [ ] 写侧覆盖证明(滚动运行期对账) +→ 未满足 → **不得移除 GET 写入** + +## 23. 剩余风险 +补偿扫描仍缺持久化游标与单轮总预算;POST/PUT 申诉决定/回滚在真实库对 version 语义的回归未验(H2 已验);普通 Writer 的 MySQL 幂等、差异更新、强制版本、`SELECT FOR UPDATE` 与 no-op upsert 已有专用真实库证据。 + +## 最终状态 +> **R01-D4-1 核心基础设施及真实 MySQL 8.4 验证已完成;GET 兜底仍保留。** 进入 D4-2 前仍需把补偿扫描收束为可持续的持久游标/单轮预算,并在 D4-2 同批完成消费者、副作用及 RabbitMQ 黑盒,不得直接删除 GET upsert。 diff --git a/docs/r01-d4-2-blocker-remediation-20260916.md b/docs/r01-d4-2-blocker-remediation-20260916.md new file mode 100644 index 0000000..cf00f4b --- /dev/null +++ b/docs/r01-d4-2-blocker-remediation-20260916.md @@ -0,0 +1,277 @@ +# R01-D4-2 阻断缺陷定向整改报告(F1–F4 及两项次生缺陷) + +日期:2026-09-16 +分支:`refactor/r01-structure-performance` +HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(整改全程未变) +来源:`docs/r01-d4-2-independent-mysql-acceptance-20260916.md` 判定的 4 项 FAIL + 2 项次生缺陷 +整改性质:**仅修复已确认缺陷**,未进入其他 R01 工作 + +--- + +## 1. 结论 + +> **R01-D4-2 阻断缺陷定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。** + +| 缺陷 | 状态 | 修复前失败证据 | 修复后 | +|---|---|---|---| +| F1 Offer 自动过期不登记 | 已修复 | 4 个断言失败 | 全通过 + 真实 MySQL 复验 | +| F2 反馈 update/delete 不登记 | 已修复 | 4 个断言失败 | 全通过 + 真实 MySQL 复验 | +| F3 简历版本与任务提交边界不对称 | 已修复 | 1 个边界断言失败 | 全通过 + 真实 MySQL 复验 | +| F4 ABILITY 敏感字段断言缺失 | 已恢复(并增强) | 原断言已删(本次新增更强版本) | 全通过 | +| 次生:claim fence 缺失 | 已修复 | — | 全通过(含双 Worker 并发) | +| 次生:单任务失败阻断批次 | 已修复 | — | 全通过(含循环隔离单测) | + +--- + +## 2. 修复前失败测试结果(TDD 证据) + +命令: +``` +mvn -Dtest="OfferExpiryRefreshRegistrationTest,CandidateFeedbackRefreshRegistrationTest,ResumeVersionRefreshAtomicityTest,CandidateScoringRefreshClaimFenceTest,CandidateScoringRefreshBatchIsolationTest,CandidateScoringRefreshInfrastructureTest" test +``` +日志:`docs/acceptance/r01-d4-2-20260916/fix-before.log` + +| 测试方法 | 失败信息(节选) | +|---|---| +| `CandidateFeedbackRefreshRegistrationTest.updateFeedback_registersRefreshTask:125` | `expected: <1> but was: <0>` —— updateFeedback 未登记 | +| `CandidateFeedbackRefreshRegistrationTest.deleteFeedback_registersRefreshTask_afterRowRemoved:137` | `expected: <1> but was: <0>` —— deleteFeedback 未登记 | +| `CandidateFeedbackRefreshRegistrationTest.updateFeedback_ratingUnchanged_...:149` | `expected: <1> but was: <0>` | +| `CandidateFeedbackRefreshRegistrationTest.feedbackLifecycle_...:182` | `update 登记 ==> expected: <1> but was: <0>` | +| `ResumeVersionRefreshAtomicityTest.createVersion_outerTransactionRollsBack_...:102` | 外层事务回滚后版本行存在(1)而刷新任务为 0 —— 提交边界不对称 | +| `OfferExpiryRefreshRegistrationTest`(4 个) | 首轮先因夹具 FK 报错,修正夹具后 `expireOne` 未登记 → 断言失败 | + +> 说明:F1/claim-fence 的首轮运行还包含**测试自身夹具缺陷**(H2 不支持 `DATE_FORMAT`、FK 删除顺序、简历文件缺 PDF 签名、offer 夹具缺列)。这些已先行修正,产品缺陷证据以上表为准。 + +--- + +## 3. 各写侧的事务边界 + +| 写侧入口 | 登记位置 | 事务边界 | 原子性依据 | +|---|---|---|---| +| `OfferExpiryWorker#expireOne` | `OfferExpiryWorker.java`(`saveAndFlush` 之后、`notifyExpired` 之前) | 方法自身的 `REQUIRES_NEW` | 状态变更、还价过期标记、刷新登记、历史行同属一个事务;任一步失败全部回滚 | +| `CandidateFeedbackService#createFeedback` | 既有(`:82` 之后) | 方法 `@Transactional` | 与 feedback 插入同事务 | +| `CandidateFeedbackService#updateFeedback` | 新增(`save` 之后) | 方法 `@Transactional` | 与 rating 更新同事务 | +| `CandidateFeedbackService#deleteFeedback` | 新增(`delete` 之后;先固定 `studentUserId`) | 方法 `@Transactional` | 与删除同事务 | +| `ResumeVersionWriter#saveNewVersion` | 新增(`save` 之后) | 组件自身的 `REQUIRES_NEW` | **与版本行同一提交边界**(F3 核心) | +| `ResumeVersionService#createVersion` | 已移除(原 `:71`) | 外层 `@Transactional` | 不再重复登记,避免外层事务耦合 | +| `ResumeVersionService#updateVersion / #deleteVersion` | 既有 | 方法 `@Transactional` | 与版本更新/删除同事务 | + +**F3 设计选择**:保留 `ResumeVersionWriter` 的 `REQUIRES_NEW`(既有并发冲突重试防护,移除会破坏"单次冲突只回滚自身、外层可安全重试"的设计),按任务书推荐把 `requestRefresh` 放入该写事务组件,`CandidateScoringRefreshRequestService` 以 `REQUIRED` 加入。外层不再重复登记。 + +--- + +## 4. 简历版本与任务的原子性证据 + +测试:`ResumeVersionRefreshAtomicityTest#createVersion_outerTransactionRollsBack_versionRowAndTaskCommitTogether` + +场景:用 `TransactionTemplate` 包裹 `createVersion`,返回后抛异常使外层回滚。 + +断言: +- `resume_versions` 行数 = 1(既有设计:`REQUIRES_NEW` 独立提交,外层回滚不使其消失) +- `candidate_scoring_refresh_tasks` 行数 = 1 —— **版本行存在则刷新任务必须同样存在** + +修复前:`expected: <1> but was: <0>`(任务丢失)。 +修复后:通过。 + +真实 MySQL 复验:`F3 createVersion 成功 st=200` → `版本行与任务同时存在(无一半提交)versions=3 tasks=1`(三次调用收敛为一条任务)。 + +**文件系统操作未被纳入数据库原子性承诺**:测试 mock 了 `ResumeStorageService`;生产代码中"存量文件清理"仍由 `createVersion` 既有的 `catch(RuntimeException)` 负责,与数据库事务边界解耦,未做改动。 + +--- + +## 5. claim fence 的字段与 SQL 条件 + +**所有权标识**:`processing_started_at`(每次 claim 写入的领取时刻)。 + +不采用"仅校验 `version`":同一 claim 期间到达的新请求会合法推进 `version`,此时仍需回到 `PENDING`,所以 `version` 不能单独充当所有权闸门。 + +| 操作 | SQL 条件 | +|---|---| +| `claim` | `WHERE id=? AND status IN ('PENDING','RETRY') AND (next_attempt_at IS NULL OR next_attempt_at <= ?)`;随后 `SELECT ... processing_started_at` 记入 `ClaimedTask.claimedAt` | +| `materialize` → COMPLETED | `WHERE id=? AND status='PROCESSING' AND processing_started_at=? AND version=?` | +| `materialize` → PENDING 回退 | `WHERE id=? AND status='PROCESSING' AND processing_started_at=?`(不带 version:新请求推进 version 正是回退的触发条件) | +| `recordFailure` | `WHERE id=? AND status='PROCESSING' AND processing_started_at=?` | + +API 变更:`ClaimedTask` 新增 `LocalDateTime claimedAt` 字段;`recordFailure(...)` 新增 `claimedAt` 形参。 + +--- + +## 6. stale worker 的处置结果 + +三种情形与处置: + +| 情形 | 条件命中 | 处置 | +|---|---|---| +| 同一 claim,无新请求 | COMPLETED 更新 1 行 | 置 `COMPLETED` | +| 同一 claim,期间有新请求 | COMPLETED 0 行 → PENDING 更新 1 行 | **回到 `PENDING`**,下一轮重算 | +| 已被其他 Worker 重新领取 | COMPLETED 0 行 → PENDING 0 行 | **安静返回**,不覆盖新持有者(不抛异常) | +| 失败登记遇 stale claim | `recordFailure` 更新 0 行 | **安静返回**,不抛异常(原先抛 `IllegalStateException`) | + +`processOne` 的失败登记另加一层保护:`recordFailure` 自身异常仅告警,不向外抛。 + +测试证据(`CandidateScoringRefreshClaimFenceTest`): +- `staleWorkerCompletion_cannotOverwriteNewWorkerClaim` —— 旧 claim 完成路径 `assertDoesNotThrow`,且任务保持新持有者的 `PROCESSING` 与 `processing_started_at` +- `staleWorkerFailure_cannotOverwriteNewWorkerClaim` —— 旧 claim 失败登记同样安静退出 +- `requestDuringSameClaim_completionFallsBackToPending` —— 同 claim 期间新请求 → 回到 `PENDING` +- `claim_exposesProcessingStartedAtAsOwnershipFence` —— `claimedAt` 已暴露 + +不使用 `sleep`,不使用 JVM 锁。 + +--- + +## 7. 单任务失败是否影响同批后续任务 + +**修复前**:`recordFailure` 在 `updated != 1` 时抛 `IllegalStateException`,异常点位于 Worker 的 catch 块内,而 `processDue` 的 `for` 循环无保护 → 同批剩余任务本轮全部不处理。 + +**修复后**: +- `recordFailure` 的 stale claim 改为安静返回(不再抛) +- `processOne` 捕获失败登记自身异常 +- `processDue` 的循环体增加 `try/catch`(兜底,日志只记录 taskId 与异常类名) + +测试证据: +- `CandidateScoringRefreshWorkerLoopIsolationTest`(纯单元,精确构造"第一项失败且失败登记也抛异常"): + - `recordFailureThrowing_doesNotAbortRemainingTasksInBatch` —— 后续第 2、3 项仍被 `materialize` + - `recordFailureThrowingInMiddle_doesNotAbortRemainingTasks` —— 中间项失败不阻断前后 + - `claimLosing_skipsTaskWithoutRecordingFailure` —— claim 失败(被并发抢走)不记失败、继续下一项 +- `CandidateScoringRefreshBatchIsolationTest`(真实 H2 + mock 计算器): + - `firstTaskFails_secondTaskStillProcessed`、`failingTaskInMiddle_doesNotBlockRemainingTasks` + - `lastErrorCode_neverLeaksExceptionMessage` —— 分类为固定值 `COMPUTE_FAILED`,不含手机号/Token/异常原文 + - `batchTerminalState_matchesRealOutcome` —— 批次结束无残留 `PROCESSING` + +--- + +## 8. 恢复后的 ABILITY 敏感字段断言清单 + +位置:`CandidateScoringRefreshInfrastructureTest#abilitySnapshotPayload_hasNoSensitiveFields_andKeepsWhitelistedKeys` +方式:**经真实 `CandidateScoringSnapshotWriter` 写入 → 从真实数据库读回 `sub_scores_json` → 递归断言**(不是检查 DTO 定义,也不复用旧测试文本)。 + +**正向断言(允许字段必须存在且取值正确)**: +`source`(= `V36_ABILITY_ASSESSMENT`)、`sourceResultId`(= 123)、`validUntil`、`questionSetVersion`、`assessmentRulesVersion`、`mappingRulesVersion`、`weights`、`dimensionScores` + +**负向断言(递归、大小写不敏感、覆盖嵌套对象与数组)** —— 以下 key 一律禁止: +``` +answer, answers, rawanswer, rawanswers, +question, questions, questionid, questioncode, questionstem, +option, options, optionid, selectedoption, selectedoptionid, +answerkey, competencytag, stem, +scoringrule, awardedscore, explanation, +phone, email, mobile, token, password, accesstoken +``` +(在任务书要求的 10 项之上,保留了原验收报告中的 `answerKey/questionCode/competencyTag/stem/scoringRule/awardedScore/explanation/phone/email`,并补充 `mobile/token/password/accessToken`) + +**大小写策略**:key 小写化后**精确匹配**(避免白名单字段 `questionSetVersion` 因含子串 `question` 而误报);另有一层原始 JSON 文本层断言,禁止出现 `""` 形式的 key 字面量。 + +--- + +## 9. 自动化测试数量 + +| 命令 | 结果 | +|---|---| +| 定向(12 个类,含 6 个新增) | **104 / 104**,F=0 E=0 S=0 | +| 关联回归(`*CandidateScoring*Test,*Scoring*Test,*Offer*Test,*Feedback*Test,*ResumeVersion*Test,*Application*Test,*Appeal*Test,*Ranking*Test,*Fairness*Test`) | **558 / 558**,F=0 E=0 S=0,BUILD SUCCESS | +| `mvn clean verify` | **2163 / 2163**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(3:47) | +| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | + +较整改前(2131)新增 **32** 个测试(6 个新测试类:Offer 过期登记 7、反馈登记 8、简历原子性 4、claim fence 6、批次隔离 4、循环隔离 3)。 + +> 过程中出现并已修复的**测试自身缺陷**(非产品):新测试类未清理 H2 共享内存库残留,导致 `InterviewScoreControllerTest` / `MessageRecallPersistenceIntegrationTest` / `MessagingBlockIntegrationTest` 的 tearDown 遇 FK 冲突(23 errors)。已为全部新测试类补 `@AfterEach` 精确清理,复跑全绿。 + +--- + +## 10. 真实 MySQL / Docker 结果 + +实例:最新 JAR(`mvn clean verify` 产出,82,441,449 bytes),`SCORING_REFRESH_ENABLED=true`、`OFFER_EXPIRY_INTERVAL_MS=3000`、compensation-interval 5s、limit 2。PID 2856,验收后已停止。 + +`docs/acceptance/r01-d4-2-20260916/v2-out.txt`:**PASS=18 / FAIL=0 / SKIP=0** + +| 项 | 真实 MySQL 结果 | +|---|---| +| F1 Offer 自动过期 | 夹具 offer(精确 INSERT,id=15)被定时任务置为 `EXPIRED` → **生成刷新任务 1 条** → `COMPLETED`(0.1s) | +| F2 create | `POST /applications/88/feedback` 200 → 任务 `COMPLETED`(0.7s) | +| F2 update | `PUT /applications/88/feedback`(rating 5→2)200 → 任务 `COMPLETED`(1.3s),无失败分类、attempts=0 | +| F2 delete | `DELETE /applications/88/feedback` 200 → 任务 `COMPLETED`(1.3s) | +| F3 简历版本 | `POST /profile/me/resume-versions` 200 → 版本行与任务**同时存在**,三次调用收敛为 1 条任务 | +| 三个 GET 零写 | 审计/通知/消息/申诉/用户/业务表**逐字段零变化**(豁免表仅限后台 Worker 与补偿扫描合法改动的 3 张) | +| 趋势/排名/看板 | 4 个端点全部 200 | +| 快照完整性 | 学生 A 当前周期 **8 维**快照齐备 | +| RabbitMQ 终态 | 4 个队列(`queue.sms.verification` / `queue.notification` / `queue.notification.dlq` / `queue.audit`)**ready=0、unacked=0** | +| 日志与审计 | 未出现手机号、Token、评分原始输入或 SQL 绑定参数;错误分类仅固定枚举值 | + +--- + +## 11. OpenAPI 数量 + +静态 `docs/openapi-rc.json` 与 `docs/verify_openapi.py`:**176 paths / 214 operations**,`All checks passed!` —— 与本轮整改前一致,**未新增公网刷新、任务、补偿或管理接口**。 + +--- + +## 12. 精确修改文件 + +**生产代码(6 个,全部在任务书允许清单内)** + +| 文件 | 改动 | +|---|---| +| `offer/OfferExpiryWorker.java` | 注入 `CandidateScoringRefreshRequestService`;`expireOne` 在状态提交后同事务登记 | +| `feedback/CandidateFeedbackService.java` | `updateFeedback` / `deleteFeedback` 同事务登记(删除前固定 `studentUserId`) | +| `resume/ResumeVersionWriter.java` | 注入登记服务;`saveNewVersion` 在与版本行同一 `REQUIRES_NEW` 事务内登记 | +| `resume/ResumeVersionService.java` | 移除 `createVersion` 中的重复登记(登记已移入 Writer) | +| `candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java` | `ClaimedTask` 增 `claimedAt`;`claim` 读回 `processing_started_at`;`materialize` 的 COMPLETED/PENDING 两分支与 `recordFailure` 全部加所有权闸门;stale claim 改为安静返回 | +| `candidatescore/refresh/CandidateScoringRefreshWorker.java` | `processOne` 失败登记加保护;`processDue` 循环体加 `try/catch` | + +**测试(新增 6 个类;修改 2 个既有类仅补依赖/调用签名)** + +新增:`OfferExpiryRefreshRegistrationTest`、`CandidateFeedbackRefreshRegistrationTest`、`ResumeVersionRefreshAtomicityTest`、`CandidateScoringRefreshClaimFenceTest`、`CandidateScoringRefreshBatchIsolationTest`、`CandidateScoringRefreshWorkerLoopIsolationTest` +修改:`CandidateScoringRefreshInfrastructureTest`(新增 F4 测试 + 增补 `Map` import + `recordFailure` 新签名)、`OfferExpiryWorkerTest`(补 `@Mock` 依赖) + +**未超出允许范围**:无需修改清单外的生产文件(清单中的 `CandidateScoringRefreshTaskRepository.java` 在项目内**不存在** —— D4-1 采用 JdbcTemplate 而非 JPA Repository,属清单笔误,无需报告偏离)。 + +--- + +## 13. 是否修改迁移 / ErrorCode / 接口 + +| 项 | 结论 | +|---|---| +| V1–V44 迁移 | **未修改**(`git diff --name-only -- db/migration` 为空);V44 仍为唯一新增迁移 | +| 新增迁移 | **无** | +| 表 / 索引 | **未新增** | +| `ErrorCode` 枚举 | **未修改**(0 个文件) | +| 公网接口 | **未新增**(OpenAPI 保持 176/214) | +| 评分公式 / 权重 / 排名规则 / 消费者行为 | **未修改** | +| RabbitMQ 消息 / 通知 | 未引入 | + +--- + +## 14. Git 状态 + +``` +分支:refactor/r01-structure-performance +HEAD:b10c4a732740e42353ca826f63141c3dd2a49915(未变) +暂存区:空 +git add -A / commit / push:均未执行 +删除文件:无 +git diff --check:无空白错误 +``` + +验收数据已按精确主键清理(`users` 残留 0;`offers`/`resume_versions`/`applications`/`jobs`/`companies`/`dimension_scores`/`refresh_tasks` 全部归零)。`audit_logs` 中 actor 为本轮 4 个隔离用户的 **14 行**属 append-only 合规记录,保留并登记(关联 ID:`user_id ∈ {8006, 8007, 8008, 8009}`)。 + +唯一测试夹具(精确 INSERT,已登记并已清理):`user_roles` 为 8008/8009 各插入 1 行 `COMPANY`(项目无公网接口可授予该角色);`offers` 插入 1 条已过期 SENT 记录用于触发 F1 定时路径(id=15)。 + +--- + +## 15. 明确声明 + +- **未暂存、未提交、未推送。** +- 未进入其他 R01 工作。 +- 未修改评分公式、权重、状态、规则版本、排名、申诉或公平性语义。 +- 已按要求「先写失败测试 → 保存修复前失败结果 → 再改生产代码」,失败证据见 `fix-before.log`。 + +--- + +## 16. 待最终独立增量复验项 + +1. F1–F4 的修复在**独立复验**中重新验证(本轮为整改方自测)。 +2. `claim fence` 的双 Worker 真实并发复验(本轮已有真实 MySQL 层的 stale claim 单线程构造 + 纯单测并发场景;**真实双 JAR 竞争仍未做**)。 +3. PROCESSING 防丢失的真实 HTTP 窗口复验(沿用 D4-2 验收的方式引用真实 MySQL 并发集成测试)。 +4. 申诉审核/回滚的真实黑盒(本轮未改动申诉路径,仍属 D4-2 遗留 SKIP)。 + +> R01-D4-2 阻断缺陷定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。 diff --git a/docs/r01-d4-2-claim-fence-remediation-20260916.md b/docs/r01-d4-2-claim-fence-remediation-20260916.md new file mode 100644 index 0000000..85ded77 --- /dev/null +++ b/docs/r01-d4-2-claim-fence-remediation-20260916.md @@ -0,0 +1,234 @@ +# R01-D4-2 claim fence 定向整改报告(不可复用 claim token) + +日期:2026-09-16 +分支:`refactor/r01-structure-performance` +HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(整改全程未变) +来源:`docs/r01-d4-2-final-independent-incremental-verification-20260916.md` 的 FAIL 项(R-1) +整改范围:**仅修复 fence 唯一性**,未进入其他 R01 项目 + +--- + +## 1. 结论 + +> **R01-D4-2 claim fence 定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。** + +采用任务书指定的**不可复用 claim token** 方案。`processing_started_at` 秒级精度导致的 fence 可重复问题已消除 —— 所有权判定不再依赖任何时间值、时间精度或配置下界。 + +--- + +## 2. 修复前失败测试(TDD 证据) + +新增 `CandidateScoringRefreshClaimTokenTest`(13 个测试),在**只加入 V45 列、未改 Java**时运行: + +命令:`mvn -Dtest="CandidateScoringRefreshClaimTokenTest" test` +日志:`docs/acceptance/r01-d4-2-20260916/fence-before.log` + +**结果:Tests run: 10, Failures: 6** + +任务书要求复现的三条,全部复现成功: + +| 要求 | 测试方法 | 失败信息 | +|---|---|---| +| ① 两次领取拥有完全相同的 `processing_started_at` | `sameTimestamp_staleWorkerPendingRevert_mustNotTouchNewClaim` | 断言 `assertEquals(SAME_INSTANT, processingStartedAt(taskId))` **通过**,证明两次领取时间值相同 | +| ② Worker B 已重新领取 | 同上 | 断言 `assertEquals("PROCESSING", status(taskId))` **通过** | +| ③ **Worker A 的 PENDING 回退可以错误修改 B 的任务** | 同上 | **`expected: but was: `** ← 核心缺陷复现 | + +同时复现了失败登记的同类覆盖: +`sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim` → **`expected: but was: `** + +其余 4 条失败为 `claim_token` 尚未写入(值为 `null`)。 + +**关键点**:测试使用常量 `SAME_INSTANT = LocalDateTime.of(2026, 9, 16, 12, 0, 0)`,两次领取传入**完全相同**的时间值,**未使用任何 sleep 或跨秒等待**。 + +--- + +## 3. 实现 + +### 3.1 迁移 V45 + +`mydreamjob-server/src/main/resources/db/migration/V45__add_scoring_refresh_claim_token.sql` + +```sql +ALTER TABLE candidate_scoring_refresh_tasks + ADD COLUMN claim_token VARCHAR(36) NULL + COMMENT '本次领取的不可复用令牌(UUID),终态/待处理时清空;仅用于并发所有权判定'; +``` + +- **仅新增列**,未增加索引 —— 查询依据:`claim_token` 只出现在既有的**按主键 `id` 定位**的 UPDATE 中(`WHERE id=? AND ...`),不参与扫描或排序,因此无需新索引。 +- 未修改 V1–V44,未修改业务表数据,未新增公网接口或 ErrorCode。 + +### 3.2 `CandidateScoringRefreshTaskExecutor` + +| 操作 | 关键改动 | +|---|---| +| `claim` | 生成 `java.util.UUID.randomUUID().toString()`,在领取 SQL 中**原子写入** `claim_token`;读回并放入 `ClaimedTask` | +| `materialize` → COMPLETED | `WHERE id=? AND status='PROCESSING' AND claim_token=? AND version=?`;`SET claim_token=NULL` | +| `materialize` → PENDING 回退 | `WHERE id=? AND status='PROCESSING' AND claim_token=?`;`SET claim_token=NULL`;0 行 → 安静返回 | +| `recordFailure` | 参数由 `LocalDateTime claimedAt` 改为 `String claimToken`;`WHERE id=? AND status='PROCESSING' AND claim_token=?`;`SET claim_token=NULL`;0 行 → 安静返回 | +| `recoverStale` | `SET claim_token=NULL`(回收即释放所有权) | +| `ClaimedTask` | 新增 `String claimToken`;保留 `claimedAt` 但**仅用于超时扫描与观测**,不再参与任何 WHERE 条件 | + +`processing_started_at` 的用途收敛为:超时扫描(`WHERE status='PROCESSING' AND processing_started_at < ?`)与观测。所有权证明完全由 `claim_token` 承担。 + +### 3.3 语义保持 + +- 同一 claim 期间出现新请求 → `version` 前进 → COMPLETED 更新 0 行 → **PENDING 回退**(`claim_token` 未变,闸门放行)✔ +- 新 Worker 已重新领取 → `claim_token` 已变 → 完成、失败、回退**全部更新 0 行并安静退出** ✔ +- 进入 PENDING / COMPLETED / RETRY / FAILED 均清空 `claim_token` ✔ +- 不依赖 `Thread.sleep`、时间精度或配置下界 ✔ + +--- + +## 4. 测试覆盖(任务书清单逐项) + +`CandidateScoringRefreshClaimTokenTest`(13 个测试,全部通过): + +| 任务书要求 | 测试方法 | +|---|---| +| 同一时间戳下两次领取的 claim token 不同 | `twoClaimsAtSameInstant_getDifferentTokens` | +| 旧 Worker 不能完成新 claim | `sameTimestamp_staleWorkerPendingRevert_mustNotTouchNewClaim`(含完成尝试) | +| 旧 Worker 不能将新 claim 回退 PENDING | 同上(核心断言) | +| 旧 Worker 不能记录新 claim 失败 | `sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim` | +| 当前 Worker 可以正常完成 | `currentHolder_canCompleteNormally` | +| 同一 claim 期间 version 前进会正确回到 PENDING | `requestDuringSameClaim_versionAdvances_completionFallsBackToPending` | +| stale recovery 后生成新 token | `staleRecovery_generatesNewTokenOnNextClaim` | +| terminal/PENDING 状态 token 被清空 | `terminalAndPendingStates_clearClaimToken`、`failedTerminalState_clearsClaimToken` | +| 双 Worker 循环至少 100 次无覆盖、无永久 PROCESSING | `doubleWorkerLoop_100Rounds_noOverwrite_noStuckProcessing_noLostRequest` | +| token 不进入日志、审计、响应或错误信息 | `claimToken_doesNotLeakIntoAuditOrNotificationTables`、`claimToken_rowNeverExposedInTaskStatusQueriesUsedByObservability` | + +补充:`claim_repeatedOnDifferentTasks_producesDistinctTokens`、`tokenIsUuidShaped_andNotDerivedFromIds`(UUID 形态、不可复用、不由 ID 派生)。 + +**全部使用固定时间值**,无 sleep、无跨秒等待。 + +--- + +## 5. 回归 + +| 命令 | 结果 | +|---|---| +| `mvn -Dtest="*CandidateScoringRefresh*Test" test` 等 7 个 fence 相关类 | **49 / 49**,F=0 E=0 S=0 | +| 关联回归(9 组通配符) | **571 / 571**,F=0 E=0 S=0 | +| `mvn clean verify` | **2176 / 2176**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | +| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | + +较整改前(2163)新增 **13** 个测试,全部为 claim token 相关。 + +**过程中修正的测试侧问题(非产品缺陷)**: + +1. `CandidateScoringRefreshMySqlIntegrationTest#v44MigrationAndSchemaValidationAreApplied` 原断言任务表 13 列 —— V45 后为 14 列,同步更新并**新增** V45 成功性、唯一性、`claim_token` 存在性断言(属本次授权迁移的计划内演进,非放宽)。 +2. `OfferExpiryRefreshRegistrationTest#expireOne_refreshTaskCarriesNoSensitiveFields` 原禁止列名含 `TOKEN` —— `claim_token` 是随机 UUID 而非用户凭据,禁止列表改为 `ACCESS_TOKEN/REFRESH_TOKEN/PASSWORD/SECRET` 等真实凭据列,并**新增**断言要求 `claim_token` 必须存在。 +3. 本人新增测试中的两处断言缺陷(UUID 随机字符集几乎必然含任务 ID 数字;`newTask()` 复用同学生导致任务合并),已改为有意义的断言。 + +--- + +## 6. 真实 MySQL 最小增量复验 + +使用本轮 `clean verify` 产出的最新 JAR。物化实例 PID 11104(`SCORING_REFRESH_ENABLED=true`),只读实例 PID 18372(`false`),均已停止,8080/8081 空闲。 + +### 6.1 V45 迁移与 schema validation + +``` +version | description | success +44 | candidate scoring refresh tasks | 1 +45 | add scoring refresh claim token | 1 +v45_rows = 1 failed_migrations = 0 +claim_token | varchar(36) | YES +task_cols = 14 +``` +Hibernate `ddl-auto=validate` 通过,日志中 `schema-validation`/`SchemaManagementException` 命中 **0** ✔ + +### 6.2 claim fence 100 轮竞争(固定相同 processing_started_at) + +脚本 `iv-fence-loop2.py`:使用与生产 `claim`/`recoverStale`/`materialize`/`recordFailure` **逐字一致**的 SQL,两次领取的 `processing_started_at` **固定为 `2026-09-16 12:00:00`**。 + +``` +轮次 = 100(两次领取的 processing_started_at 固定为 2026-09-16 12:00:00) +[PASS] 100 轮:A 的完成/失败/回退全部 0 行、未覆盖 B、B 均完成、无永久 PROCESSING +``` + +每轮断言: +- A 用旧 token 的完成 / 失败 / PENDING 回退 → **受影响行数均为 0** +- A 未改动 B 的 `status`、`claim_token`、`attempts`、`last_error_code` +- B 用新 token 完成 → 1 行 +- 终态无残留 `PROCESSING` + +(脚本首轮运行暴露了自身缺陷:`ROW_COUNT()` 跨 `docker exec` 连接失效、回收步骤的 `WORKER_TIMEOUT` 被误判为污染;已修正后重跑。) + +### 6.3 三个 GET 零写 + +**PASS=24 / FAIL=0 / SKIP=0** —— 三个目标 GET ×3 + 趋势 GET ×3 全部 HTTP 200,**22 张表逐字段零变化** ✔ + +### 6.4 无快照学生 + +新学生 8019:`before=0` → 3 次 GET(均 200)→ `after=0` ✔ + +### 6.5 五条写侧链路 + +**PASS=44 / FAIL=0 / SKIP=0** —— Offer 自动过期、Feedback create/update/delete、简历版本创建;每条均验证「业务变化 → 唯一任务 → 周期正确 → Worker COMPLETED → 无失败分类 → 八维齐备 → 不写 snapshot_history」;相同输入重放不推进 version ✔ + +### 6.6 消费者最小回归 + +**PASS=33 / FAIL=0 / SKIP=0** —— 趋势(当前周期 2026-09)、排名(rank/percentile/cohortSize)、企业看板 4 端点 + 越权 403、公平性 5 端点(抑制语义保留)、申诉取证非空;消费者 GET 逐字段零写 ✔ + +### 6.7 RabbitMQ + +4 个队列(`queue.sms.verification` / `queue.notification` / `queue.notification.dlq` / `queue.audit`)**ready=0、unacked=0** ✔ + +### 6.8 token 不泄露 + +- 测试断言:token 不出现在 `audit_logs` / `notifications` / `messages` / `candidate_dimension_scores.sub_scores_json`;`last_error_code` 仅固定枚举 ✔ +- 运行日志:未出现 token 形态内容;`claim_token` 不参与任何日志输出语句 ✔ +- 数据库:token 仅存于 `candidate_scoring_refresh_tasks.claim_token`,且在终态/PENDING 时被清空 ✔ + +--- + +## 7. 精确修改文件 + +**生产代码(3 个)** + +| 文件 | 改动 | +|---|---| +| `db/migration/V45__add_scoring_refresh_claim_token.sql` | **新增**:仅加 `claim_token VARCHAR(36) NULL` | +| `candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java` | `claim` 生成并原子写入 UUID;COMPLETED / PENDING 回退 / `recordFailure` 的全部 WHERE 由 `processing_started_at` 改为 `claim_token`;四处(含 `recoverStale`)`SET claim_token=NULL`;`ClaimedTask` 增 `claimToken`;`recordFailure` 形参改 `String claimToken` | +| `candidatescore/refresh/CandidateScoringRefreshTask.java` | 实体新增 `claimToken` 映射;`processingStartedAt` 注释标明不再作为所有权证明 | + +**配置/接口/数据**:未修改 `application.yml`;未新增 ErrorCode;未新增公网接口;未修改评分公式、权重、快照内容、任务合并语义;未把 `DATETIME(6)` 当作最终修复(`processing_started_at` 精度保持不变)。 + +**测试(新增 1 个类;适配 4 个既有类)** + +新增:`CandidateScoringRefreshClaimTokenTest`(13 个测试) +适配:`CandidateScoringRefreshMySqlIntegrationTest`(列数 13→14 + V45 断言)、`OfferExpiryRefreshRegistrationTest`(禁止列名调整 + `claim_token` 存在性断言)、`CandidateScoringRefreshClaimFenceTest` 与 `CandidateScoringRefreshInfrastructureTest`(`recordFailure` 传 `claimToken()`)、`CandidateScoringRefreshWorkerLoopIsolationTest`(mock 签名 + `ClaimedTask` 构造) + +--- + +## 8. Git 状态 + +``` +分支:refactor/r01-structure-performance +HEAD:b10c4a732740e42353ca826f63141c3dd2a49915(未变) +暂存区:空 +git add -A / commit / push:均未执行 +V1–V44:未修改 +新增迁移:V45(唯一本轮新增;V44 为 D4-1 产物) +删除文件:无 +git diff --check:无空白错误 +``` + +验收数据已按精确主键清理(`users` 残留 0:8015/8016/8017/8018/8019 均删除;公司 33/34、岗位 50、投递 92/93、offer、简历版本、申诉及关联行全部清除)。`audit_logs` 中 actor 为本轮 5 个隔离用户的 **15 + 2 = 17 行**属 append-only 合规记录,保留并登记(关联 ID:`user_id ∈ {8015, 8016, 8017, 8018, 8019}`)。 + +唯一测试夹具(精确 INSERT,已登记并已清理):`user_roles` 为 8017/8018 各插入 1 行 `COMPANY`。 + +Docker 保持运行(三容器 healthy);仅停止本轮记录 PID 的实例;8080/8081 空闲。 + +--- + +## 9. 明确声明 + +- **未暂存、未提交、未推送。** +- 未进入其他 R01 工作。 +- 原始验收产物继续排除在后续提交之外。 +- 已按要求「先补失败测试 → 保存修复前失败结果 → 再改生产代码」。 + +--- + +> R01-D4-2 claim fence 定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。 diff --git a/docs/r01-d4-2-claim-token-final-verification-20260916.md b/docs/r01-d4-2-claim-token-final-verification-20260916.md new file mode 100644 index 0000000..0012277 --- /dev/null +++ b/docs/r01-d4-2-claim-token-final-verification-20260916.md @@ -0,0 +1,188 @@ +# R01-D4-2 claim token 最终独立复验报告(冻结前最后一次) + +日期:2026-09-16 +分支:`refactor/r01-structure-performance` +HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(复验全程未变) +复验对象:`docs/r01-d4-2-claim-fence-remediation-20260916.md` 的 claim token 整改 +性质:**仅复验** —— 未修改生产代码、未修改既有测试、未暂存、未提交、未推送 + +--- + +## 0. 最终结论 + +> **R01-D4-2独立代码复验和真实MySQL/Docker增量黑盒全部通过,GET评分快照写入迁移可以冻结并进入精确暂存检查点。** + +**FAIL = 0,SKIP = 0。** claim token 闸门在代码审查、自动化测试与真实 MySQL 100 轮黑盒三个层面均确证有效:旧 Worker 无法以任何形式修改新持有者。 + +--- + +## 1. 基线 + +| 检查项 | 结果 | +|---|---| +| 分支 | `refactor/r01-structure-performance` ✔ | +| HEAD | `b10c4a732740e42353ca826f63141c3dd2a49915` ✔(= b10c4a7) | +| 暂存区 | 空 ✔ | +| `git diff --check` | 无空白错误 ✔ | +| V1–V44 | 未修改 ✔ | +| 新增迁移 | **V45 为唯一本轮新增**(V44 为 D4-1 产物)✔ | +| JAR | `mydreamjob-server-0.0.1-SNAPSHOT.jar`,2026-09-16 **17:26:51**(最新 `clean verify` 产出)✔ | + +--- + +## 2. 独立代码审查 + +`CandidateScoringRefreshTaskExecutor.java`(153 行,逐行核对): + +| 核查项 | 结论 | 证据 | +|---|---|---| +| token 在领取 SQL 中原子生成并写入 | ✔ | `:53` `UUID.randomUUID().toString()`;`:56` `SET status='PROCESSING', processing_started_at=?, claim_token=?, ...` 同一 UPDATE | +| token 不由调用方复用 | ✔ | `claim(Long, LocalDateTime)` 无 token 形参,每次调用新生成 | +| COMPLETED 条件 = id + PROCESSING + claim_token + version | ✔ | `:92` `WHERE id=? AND status='PROCESSING' AND claim_token=? AND version=?` | +| PENDING 条件 = id + PROCESSING + claim_token | ✔ | `:104` | +| RETRY / FAILED 条件 = id + PROCESSING + claim_token | ✔ | `:135`(`recordFailure` 依 `attempts+1 >= maxAttempts` 落到 RETRY 或 FAILED) | +| 离开 PROCESSING 后 token 全部清空 | ✔ | `recoverStale` `:38`;COMPLETED `:89`;PENDING `:102`;RETRY/FAILED `:133` —— **四处全覆盖** | +| recoverStale 清空旧 token | ✔ | `:38` `claim_token=NULL` | +| 下一次领取生成全新 token | ✔ | `:53` 每次调用重新生成 | +| `processing_started_at` 不再出现在所有权判断 WHERE 中 | ✔ | 全文件唯一 WHERE 用法为 `:40` 的**超时扫描** `WHERE status='PROCESSING' AND processing_started_at < ?`;所有权判定全部使用 `claim_token` | +| 更新 0 行按 stale claim 处理 | ✔ | `claim` `:61-63` 返回 null;`materialize` PENDING 分支 `:106-110` 安静返回;`recordFailure` `:137-140` 安静返回 —— **均不抛异常、不误报成功** | +| 0 行时不覆盖现持有者 | ✔ | 三个更新语句的 WHERE 均含 `claim_token=?` | +| token 不进入日志/审计/响应/通知/消息/快照 JSON | ✔ | 全仓 grep:`claim_token`/`claimToken` 仅出现在实体映射(`CandidateScoringRefreshTask.java:73-74`)、Executor 的 SQL/注释/record、以及 `CandidateScoringRefreshWorker.java:91` 的传参;refresh 包内**无任何含 token 的日志语句** | + +--- + +## 3. 自动化复验 + +| 命令 | 实际 | 预期 | 结论 | +|---|---|---|---| +| `mvn -Dtest="*CandidateScoringRefresh*Test" test` | **49 / 49**,F=0 E=0 **S=0** | ≥ 49 | ✔ | +| 关联回归(9 组通配符) | **571 / 571**,F=0 E=0 **S=0**,BUILD SUCCESS | ≥ 571 | ✔ | +| `mvn clean verify` | **2176 / 2176**,F=0 E=0 **S=0**,BUILD SUCCESS | ≥ 2176 | ✔ | +| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | 176/214 | ✔ | + +日志:`fv-fence.log`、`fv-regression.log`、`fv-clean-verify.log` + +--- + +## 4. 真实 MySQL 最小黑盒 + +脚本 `fv-fence.py` —— 使用与生产 `claim`/`recoverStale`/`materialize`/`recordFailure` **逐字一致**的 SQL,**所有领取的 `processing_started_at` 固定为 `2026-09-16 12:00:00`**。 + +**结果:PASS** + +### A. 100 轮竞争 + +每轮:A 领取(token_A)→ 回收(token 清空)→ B 在**相同时间值**下重新领取(token_B)→ A 以 token_A 执行四种迟到写 → B 以 token_B 完成。 + +| 断言 | 结果 | +|---|---| +| A 的 **COMPLETED** 更新 0 行 | ✔ 100 轮均为 0 | +| A 的 **PENDING** 更新 0 行 | ✔ | +| A 的 **RETRY** 更新 0 行 | ✔ | +| A 的 **FAILED** 更新 0 行 | ✔ | +| B 的 token / status / version 未被 A 修改 | ✔ 逐轮比对 `status='PROCESSING'`、`claim_token=token_B`、`version=version_B` 均不变 | +| B 正常完成 | ✔ 每轮 B 完成后 `status='COMPLETED'` 且 token 清空 | +| 终态无永久 PROCESSING | ✔ | +| 无重复完成、无任务丢失 | ✔ 终态任务数 = 1 | + +### B. token 生命周期 + +- `recoverStale` 后 `claim_token` 为 **NULL** ✔ +- 再次领取后 token **非空且不同于此前所有 token** ✔ + +### C. 无效 token 不可更新 + +| 场景 | 行数 | +|---|---| +| 空 token(`claim_token=''`) | **0** ✔ | +| 伪造 token(随机新 UUID) | **0** ✔ | +| 旧 token(A 的 token_A) | **0** ✔ | +| NULL token | **0** ✔ | + +且四种尝试后任务状态**完全未变**(前后 `st()` 全等)✔ + +### D. version 推进 → PENDING → 下一轮新 token 完成 + +- 同一 claim 期间新请求推进 `version` → COMPLETED 更新 **0 行**(version 不匹配) +- 当前 Worker 走 PENDING 回退 → **1 行**,`status='PENDING'` 且 token 清空 ✔ +- 下一轮领取使用**新 token** ✔ → 完成 **1 行**,`status='COMPLETED'` ✔ + +--- + +## 5. 最小业务回归 + +实例:最新 JAR;物化实例 PID 11884(`SCORING_REFRESH_ENABLED=true`)、只读实例 PID 6988(`false`),均已停止。 + +| 项 | 结果 | +|---|---| +| **三个 GET 零写** | **PASS=24 / FAIL=0 / SKIP=0** —— 三个目标 GET ×3 + 趋势 GET ×3 全部 200,**22 张表逐字段零变化** ✔ | +| **无快照学生** | 新学生 8024:`before=0` → 3 次 GET(均 200)→ `after=0` ✔ | +| **五条写侧链路** | **PASS=44 / FAIL=0 / SKIP=0** —— Offer 自动过期、Feedback create/update/delete、简历版本创建;每条验证「业务变化 → 唯一任务 → 周期正确 → Worker COMPLETED → 无失败分类 → 八维齐备 → 不写 snapshot_history」;重放不推进 version ✔ | +| **消费者回归** | **PASS=33 / FAIL=0 / SKIP=0** —— 趋势 / 排名 / 企业看板 / 申诉 / 公平性监控;消费者 GET 逐字段零写 ✔ | +| **V45** | `success=1` 且 `v45_rows=1`(唯一);`failed_migrations=0` ✔ | +| **Hibernate validate** | 通过;日志中 `schema-validation`/`SchemaManagementException` 命中 **0** ✔ | +| **RabbitMQ** | 4 队列(`queue.sms.verification` / `queue.notification` / `queue.notification.dlq` / `queue.audit`)**ready=0、unacked=0** ✔ | +| **token 泄漏扫描** | **PASS** —— 日志非框架 UUID 命中 **0**;HTTP 响应 token 形态命中 **0**;数据库其它载体命中 **0**(当前无活跃 token,终态/PENDING 已全部清空)✔ | +| **扫描器探针** | 临时文件注入 UUID → 正则命中;向 `audit_logs` 插入探针 → DB 命中 1 → 清理后 0 —— **探针 PASS,证明扫描器非空转**;探针文件已删除 ✔ | + +--- + +## 6. 收尾 + +| 项 | 结果 | +|---|---| +| 本轮 Java 实例 | 仅停止本轮记录的两个 PID(11884、6988);`tasklist` java.exe 计数 = **0** ✔ | +| 8080 / 8081 | 空闲 ✔ | +| Docker | 保持运行,三容器 healthy ✔ | +| 数据清理 | `users` 残留 **0**(8020/8021/8022/8023/8024 及公司、岗位 51、投递 94/95、offer、简历版本全部按精确主键删除)✔ | +| append-only 审计 | `audit_logs` 中 actor 为本轮 5 个隔离用户的 **15 + 2 = 17 行**保留并登记(关联 ID:`user_id ∈ {8020, 8021, 8022, 8023, 8024}`)✔ | +| 测试夹具(精确 INSERT,已登记并已清理) | `user_roles` 为 8022/8023 各插入 1 行 `COMPANY` ✔ | +| Git | 分支未变、HEAD 未变、暂存区空、V1–V44 未改、V45 唯一新增、无删除文件、无空白错误 ✔ | +| 未暂存 / 未提交 / 未推送 | 是 ✔ | + +本轮新增产物(`docs/acceptance/r01-d4-2-20260916/`,未进入暂存区):`fv-fence.py` + `fv-fence-out.txt`、`fv-tokenscan.py`、`fv-fence.log`、`fv-regression.log`、`fv-clean-verify.log`、`v3-fv-out.txt`、`s1-fv-out.txt`、`s4-fv-out.txt`。 + +--- + +## 7. PASS / FAIL / SKIP 清单 + +**FAIL**:无。 + +**SKIP**:无。 + +**PASS**: + +1. 基线(分支/HEAD/暂存区/V1–V44/V45 唯一/最新 JAR) +2. 代码审查 12 项(token 原子生成、不复用、COMPLETED 四条件、PENDING/RETRY/FAILED 三条件、四处清空、recoverStale 清空、新 token、`processing_started_at` 退出所有权判定、0 行 stale 处理、不覆盖、不泄漏) +3. fence 定向 49/49 +4. 关联回归 571/571 +5. 全量 clean verify 2176/2176(S=0) +6. OpenAPI 176/214 +7. 真实 MySQL 100 轮竞争(四种迟到写全 0 行、B 未被修改、B 均完成、无永久 PROCESSING、无丢失) +8. token 生命周期(回收后 NULL、重领后新值) +9. 空/伪造/旧/NULL token 均无法更新 +10. version 推进 → PENDING → 新 token 完成 +11. 三个 GET 零写 24/24(22 表逐字段) +12. 无快照学生 0 行 +13. 五条写侧 44/44 +14. 消费者 33/33 +15. V45 成功且唯一 +16. Hibernate validate +17. RabbitMQ 四队列为 0 +18. token 泄漏扫描 0 + 探针有效 + +--- + +## 8. 复验过程中的自暴露问题(非产品缺陷) + +| # | 问题 | 处置 | +|---|---|---| +| 1 | `fv-fence.py` 的 `retry_sql`/`failed_sql` 存在两个 `%s` 占位符只传一个参数的 Python 格式化错误 | 改为 `% (TABLE, token)`;已修正 | +| 2 | `fv-tokenscan.py` 的 DB 探针使用 `messages` 表触发外键失败 | 改用无外键依赖的 `audit_logs`,并增加「插入前=0 / 插入后=1 / 清理后=0」三段验证;已修正 | +| 3 | 探针插入的 `audit_logs` 行 | 探针执行后立即删除,已确认清理后计数为 0 | + +以上均为**验收脚本自身**问题,与产品代码无关;修正后重跑全部通过。 + +--- + +> **冻结判定:R01-D4-2独立代码复验和真实MySQL/Docker增量黑盒全部通过,GET评分快照写入迁移可以冻结并进入精确暂存检查点。** diff --git a/docs/r01-d4-2-final-independent-incremental-verification-20260916.md b/docs/r01-d4-2-final-independent-incremental-verification-20260916.md new file mode 100644 index 0000000..b5a5420 --- /dev/null +++ b/docs/r01-d4-2-final-independent-incremental-verification-20260916.md @@ -0,0 +1,275 @@ +# R01-D4-2 最终独立增量复验报告(整改后) + +日期:2026-09-16 +分支:`refactor/r01-structure-performance` +HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(复验全程未变) +复验对象:`docs/r01-d4-2-blocker-remediation-20260916.md` 的 F1–F4 与两项次生缺陷整改 +性质:**只复验**,未修改生产代码与既有测试 + +--- + +## 0. 最终结论 + +> **已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。** + +判定 **FAIL**。唯一原因是任务书指定的硬性判据 —— **claim fence 的精度不足**: + +> 「特别核查 processing_started_at 的 Java/JDBC/MySQL 精度一致性。若新旧两次领取可能得到相同 fence,直接判 FAIL」 + +实测已证明**可能**(见 §3)。除此之外,**本轮全部核心项均通过**:五条写侧链路、简历事务故障、20 轮 fence 竞争、批次隔离、三个 GET 零写(22 表逐字段)、无快照学生零写、消费者全量回归、V44、RabbitMQ、日志脱敏。 + +--- + +## 1. 开始前基线 + +| 检查项 | 结果 | +|---|---| +| 分支 | `refactor/r01-structure-performance` ✔ | +| HEAD | `b10c4a732740e42353ca826f63141c3dd2a49915`(未变)✔ | +| 暂存区 | 空 ✔ | +| `git diff --check` | 无空白错误 ✔ | +| V1–V43 | 未修改 ✔ | +| V44 | 仍为唯一新增迁移 ✔ | +| 删除文件 | 无 ✔ | +| 生产代码改动 | 14 项(13 个 `M` + `refresh/` 目录),与整改后一致 ✔ | +| JAR | `mydreamjob-server-0.0.1-SNAPSHOT.jar`,**2026-09-16 16:43:14**(本轮 `mvn clean verify` 产出,未使用旧 JAR)✔ | + +--- + +## 2. 独立代码复验(六项重点) + +### 2.1 OfferExpiryWorker — **通过** + +- `OfferExpiryWorker.java:51` `saveAndFlush` → `:56` `requestRefresh(saved.getCandidateId(), LocalDateTime.now())` → `:57` `notifyExpired` → `:58` `log.info`,全部位于方法自身的 `@Transactional(REQUIRES_NEW)` 内 ✔ 状态变更与登记同事务。 +- 未变化时不登记:`:45-47` 的幂等早返回(状态非 `SENT`/`NEGOTIATING` 直接 `return`)位于 `:48` 状态改写**之前**,因此不会对已过期的 Offer 重复登记 ✔ +- 真实 MySQL 复验:`offer 17 -> EXPIRED`,任务唯一且 `COMPLETED` ✔ + +### 2.2 CandidateFeedbackService — **通过** + +- `createFeedback`(`:82` 后)、`updateFeedback`(`save` 后)、`deleteFeedback`(`delete` 后)三处均登记 ✔ +- `deleteFeedback` 在 `feedbackRepository.delete(fb)` **之前**取 `Long studentUserId = app.getStudentUserId()`,删除后仍用该局部变量登记 ✔ +- 失败路径不登记:越权(`assertCompanyOwner`)与 `FEEDBACK_NOT_FOUND` 均在任何登记语句之前抛出 ✔ 真实复验 `B3 失败路径不留孤立任务 tasks=[]` ✔ + +### 2.3 ResumeVersionWriter — **通过** + +- `ResumeVersionWriter.java` 的 `saveNewVersion` 为 `@Transactional(propagation = REQUIRES_NEW)`,`:38` `save` 与 `:40` `requestRefresh(userId, LocalDateTime.now())` 处于**同一事务** ✔ +- 外层不重复登记:`ResumeVersionService.createVersion` 中原 `:71` 的登记已移除,改为注释说明 ✔(`grep requestRefresh ResumeVersionService.java` 仅剩 `updateVersion`/`deleteVersion` 两处,二者与各自写操作同事务) +- 保留 `REQUIRES_NEW`:既有「单次唯一键冲突只回滚自身、外层可安全重试」设计未被破坏 ✔ + +### 2.4 ABILITY 脱敏测试 — **通过** + +`CandidateScoringRefreshInfrastructureTest#abilitySnapshotPayload_hasNoSensitiveFields_andKeepsWhitelistedKeys`: +- 经**真实 `CandidateScoringSnapshotWriter.upsertIfChanged`** 写入 → 从**真实数据库** `SELECT sub_scores_json` 读回 → `ObjectMapper.readTree` 后**递归**扫描 ✔ +- 正向:`source` / `sourceResultId` / `validUntil` / `questionSetVersion` / `assessmentRulesVersion` / `mappingRulesVersion` / `weights` / `dimensionScores` 全部存在且取值正确 ✔ +- 负向:`BANNED_KEYS` 共 **26 项**,逐项核对报告清单一致 —— `answer`, `answers`, `rawanswer`, `rawanswers`, `question`, `questions`, `questionid`, `questioncode`, `questionstem`, `option`, `options`, `optionid`, `selectedoption`, `selectedoptionid`, `answerkey`, `competencytag`, `stem`, `scoringrule`, `awardedscore`, `explanation`, `phone`, `email`, `mobile`, `token`, `password`, `accesstoken` ✔ +- 大小写:key 小写化后**精确匹配**(`questionSetVersion` 不会因含子串 `question` 误报);另有原始 JSON 文本层 `""` 字面量断言 ✔ +- 嵌套对象与数组:`assertNoBannedKeyDeep` 递归处理 `isObject` 与 `isArray` ✔ + +### 2.5 claim fence — **不通过(精度不足,见 §3)** + +- 语义正确性:完成(`CandidateScoringRefreshTaskExecutor.java`)校验 `id + status='PROCESSING' + processing_started_at + version`;失败与 PENDING 回退校验 `id + status='PROCESSING' + processing_started_at` ✔ +- **但** fence 字段本身精度不足,构成 FAIL。 + +### 2.6 批次隔离 — **通过** + +- `recordFailure` 在 `updated != 1` 时 `return`(安静退出),不再抛 `IllegalStateException` ✔ +- `processOne` 的 `catch` 内对 `recordFailure` 再包一层 `try/catch`,避免其异常外泄 ✔ +- `processDue` 的 `for` 循环体加 `try/catch` ✔ +- 真实数据库异常不被伪装成功:`CandidateScoringRefreshBatchIsolationTest#batchTerminalState_matchesRealOutcome` 断言批次结束为 2 个 `COMPLETED` + 1 个 `RETRY`,且无残留 `PROCESSING` ✔ +- 真实 MySQL:`CandidateScoringRefreshMySqlIntegrationTest#mysqlFailureRollsBackPartialSnapshotsAndDoesNotRollbackSibling` ✔ + +--- + +## 3. FAIL 项:claim fence 精度不足 + +### 3.1 事实链 + +| 环节 | 事实 | 证据 | +|---|---|---| +| 列定义 | `processing_started_at DATETIME NULL` —— **无小数秒** | `V44__candidate_scoring_refresh_tasks.sql:11` | +| 实际精度 | `DATETIME_PRECISION = 0` | `information_schema.columns` 查询 | +| 写入截断 | `'2026-09-16 12:00:00.123456'` 存入 `DATETIME` 后变 `'2026-09-16 12:00:00'`(与 `DATETIME(6)` 比较 `equal_after_store = 0`) | `iv-precision.sql` | +| Java 侧 | `LocalDateTime.now()`(纳秒)传入 JDBC | `CandidateScoringRefreshTaskExecutor.java` `claim(...)` | +| 读回 | `rs.getObject("processing_started_at", LocalDateTime.class)` —— 秒级 | 同上 | + +**结论:fence 的比较粒度是 1 秒。** + +### 3.2 最小复现(已在真实 MySQL 执行) + +脚本:`docs/acceptance/r01-d4-2-20260916/iv-fence2.sql`(与生产 `claim`/`recoverStale` SQL **逐字一致**) + +``` +after_claim_1 | 2026-09-16 16:36:09 | PROCESSING +after_recover(sub-second-timeout) | 2026-09-16 16:36:09 | PROCESSING ← 回收后立即被重领 +after_claim_2 | 2026-09-16 16:36:09 | PROCESSING +``` + +**两次领取的 fence 完全相同**(`16:36:09` == `16:36:09`),秒级精度无法区分新旧持有者。 + +同一脚本末段同时给出边界条件: +``` +earliest_new_fence_when_timeout_600s = 2026-09-16 12:10:00 +can_be_equal = 0 +``` + +### 3.3 触发条件 + +`recoverStale` 的条件是 `processing_started_at < now - timeout`,其中 `timeout = processingTimeoutMs`(`CandidateScoringRefreshWorker.java:55`:`processingTimeoutMs <= 0 ? 600_000L : processingTimeoutMs`)。 + +- **timeout ≥ 1000 ms**(含生产默认 600000):新领取时刻 ≥ 旧 fence + timeout > 旧 fence 所在秒 → **fence 必然不同**,机制可靠。 +- **timeout < 1000 ms**(运维显式配置 `SCORING_REFRESH_PROCESSING_TIMEOUT_MS` 为 1–999):回收与重领可落在同一秒 → **fence 相同**,闸门失效。 + +代码中**未对该配置做下界约束**,因此「可能得到相同 fence」成立。 + +### 3.4 影响范围(如实界定) + +即使 fence 相同,**COMPLETED 分支仍校验 `version`**,因此旧 Worker 不会把新 Worker 的 `PROCESSING` 误置为 `COMPLETED`。实际失效路径只有 PENDING 回退分支:旧 Worker 把新持有者的 `PROCESSING` 改成 `PENDING`,导致新 Worker 完成时更新 0 行而安静退出。后果是**多算一次**(任务回到 PENDING 待下轮重算),不产生数据损坏,也不会留下永久 `PROCESSING`。 + +但按任务书给定的判据 —— 「若新旧两次领取**可能**得到相同 fence,直接判 FAIL;不能用测试等待规避」—— 判定为 **FAIL**。 + +### 3.5 下一轮定向整改指令 + +**R-1(阻断冻结)**:让 claim fence 具备亚秒分辨力,二选一: + +- **(a) 提升列精度**:新增迁移 `V45` 将 `processing_started_at` 改为 `DATETIME(6)`(**不得修改 V44**),并把 Java 侧传入值统一到微秒;同时确认 `recoverStale` 的比较与索引仍有效。 +- **(b) 改用不依赖时间的 fence**:使用既有 `version` 列的**单调快照**作为所有权标识 —— 在 `claim` 时把 `version` 的当次结果记入 `ClaimedTask`,完成/失败/回退时要求 `version` 属于「本次领取之后、下一次领取之前」的区间。若选此路,需明确区分「同 claim 期间新请求推进 version」(应回退 PENDING)与「被重新领取推进 version」(应安静退出)—— 可借助 `attempts` 或 `processing_started_at` 的组合判定。 + +**R-2(同批处理)**:为 fence 增加**边界测试**:显式构造 `timeout < 1s` 场景,断言旧 Worker 不得覆盖新持有者;或对 `processing-timeout-ms` 增加「必须 ≥ 1000」的配置校验(若选 (a)/(b) 后仍有时间依赖)。 + +**R-3(建议)**:`iv-fence2.sql` 与 `iv-precision.sql` 可作为该轮复验的前置失败证据复用。 + +--- + +## 4. 自动化复验结果 + +| 命令 | 实际 | 任务书预期 | 结论 | +|---|---|---|---| +| 定向(5 组通配符) | **184 / 184**,F=0 E=0 S=0 | 104 | 数量差异见下 | +| 关联回归(9 组通配符) | **558 / 558**,F=0 E=0 S=0 | 558 | 一致 ✔ | +| `mvn clean verify` | **2163 / 2163**,F=0 E=0 **S=0**,BUILD SUCCESS | 2163 | 一致 ✔ | +| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | 176/214 | 一致 ✔ | + +**定向数量差异解释(184 vs 104)**:任务书给定的通配符 `*OfferExpiry*Test,*CandidateFeedback*Test,*ResumeVersion*Test,*CandidateScoringRefresh*Test,*CandidateScoringCalculatorTest` 比整改自测时使用的 12 个类名更宽,额外匹配了 `OfferControllerTest`(30)、`OfferServiceTest`(21)、`OfferPersistenceIntegrationTest`(16)、`CandidateFeedbackControllerTest`(8)、`ResumeExportServiceTest`(8)、`ResumeVersionPersistenceIntegrationTest` 等既有类。**差异全部为多覆盖,无遗漏**。 + +逐类(18 个类):30 / 4 / 6 / 3 / 14 / 6 / 3 / 8 / 7 / 16 / 7 / 7 / 8 / 21 / 6 / 8 / 4 / 26 = 184 ✔ + +--- + +## 5. 真实 MySQL / Docker 增量黑盒 + +实例:本轮 `clean verify` 产出的最新 JAR;物化实例 PID 12592(`SCORING_REFRESH_ENABLED=true`、`OFFER_EXPIRY_INTERVAL_MS=3000`、补偿 5s/limit 2),只读实例 PID 11060(`false`)。二者均已停止,8080/8081 空闲。 + +### 5.1 A. 五条写侧链路 — **PASS=44 / FAIL=0 / SKIP=0** + +每条均断言:业务数据变化 → 同学生同周期唯一任务 → 周期正确 → Worker `COMPLETED` → 无失败分类 → 八维齐备 → 不写 `snapshot_history`。 + +| 写侧 | 业务变化 | 任务 | Worker | 八维 | history | +|---|---|---|---|---|---| +| A1 Feedback create | `active=1` | 1 条 | `COMPLETED` 1.1s | 8 | 0→0 | +| A2 Feedback update | `rating=2` | 1 条 | `COMPLETED` 0.6s | 8 | 0→0 | +| A3 Feedback delete | `active=0` | 1 条 | `COMPLETED` 0.6s | 8 | 0→0 | +| A4 简历版本创建 | `versions=1` | 1 条 | `COMPLETED` 0.7s | 8 | 0→0 | +| A5 Offer 自动过期 | `offer 17 → EXPIRED` | 1 条 | `COMPLETED` 1.1s | 8 | 0→0 | + +**幂等**:完全相同输入重放 → 八维 version 完全一致 +`{ABILITY:0, ACTIVITY:1, COMPLIANCE:0, EDUCATION:0, GROWTH:1, INTEGRITY:4, INTERNSHIP:0, STABILITY:1}` 前后不变,且不写 `snapshot_history` ✔ + +### 5.2 B. 简历事务故障 — **通过** + +| 场景 | 结果 | +|---|---| +| B1 内层成功后外层回滚 → 版本与任务均存在 | `versions=1 tasks=1` ✔(精确断言见 §4 的 `ResumeVersionRefreshAtomicityTest`) | +| B2 版本写入失败(非法文件被 `ResumeFileValidator` 拒绝,st=400) | 版本行 `1→1` 未创建;**未登记任务** `tasks=[]` ✔ | +| B3 失败路径不留孤立任务(学生越权调用企业接口,st=403) | 学生 A `tasks=[]` ✔ | +| 文件补偿与旧文件保护语义 | 未改动 `ResumeVersionService` 的文件生命周期代码(仅移除一行登记),既有 `catch(RuntimeException)` 清理与 `TransactionSynchronization` 逻辑保持不变 ✔ | + +> 注:B 场景中「版本写入失败」与「任务登记失败」在真实 HTTP 下无法分别构造(`ResumeVersionWriter` 是内部组件),前者用非法文件触发校验失败,后者由 `ResumeVersionRefreshAtomicityTest` 的事务回滚用例覆盖。 + +### 5.3 C. Claim fence 竞争(20 轮) — **PASS** + +脚本 `iv-fence-loop.py`,每轮:A 领取 → 期间新请求推进 version → A 完成 → A 回退 PENDING → B 重新领取 → **延迟的 A 再尝试完成/失败/回退**。 + +20 轮全部满足: +- A 未覆盖 B(B 的 `status` 与 `processing_started_at` 均未被改动) +- 无新请求丢失(`requested_at` 已推进) +- 无永久 `PROCESSING` +- 无重复完成 +- B 均正确完成 +- A 的失败登记未污染 `last_error_code` 与 `attempts` + +> **重要限定**:该循环通过是因为运行中 A/B 两次领取**恰好跨秒**(每条 SQL 之间耗时累积)。它验证的是 fence **在值不同时**的语义正确性;**不构成** fence 精度足够的证据 —— 精度不足正是 §3 的 FAIL 项。 + +### 5.4 D. 批次失败隔离 — **通过** + +三个任务「正常 → 故障 → 正常」: +- `failingTaskInMiddle_doesNotBlockRemainingTasks` —— 前后两个正常任务均 `COMPLETED` +- 故障任务状态为 `RETRY`(未伪装成功),`last_error_code = COMPUTE_FAILED` +- Worker 未中断整批;`batchTerminalState_matchesRealOutcome` 断言无残留 `PROCESSING` +- 后续重试:`RETRY` + `next_attempt_at` 由 Worker 正常再处理 +- 真实 MySQL:`mysqlFailureRollsBackPartialSnapshotsAndDoesNotRollbackSibling` ✔ + +--- + +## 6. D4-2 核心回归 + +| 项 | 结果 | +|---|---| +| 三个目标 GET ×3 + 趋势 GET ×3 | 全部 **HTTP 200**,契约稳定,无敏感字段泄露 ✔ | +| **22 张表逐字段零变化** | `[OK] 全部 22 张表逐字段零变化`;`candidate_dimension_scores` 1480 行、`refresh_tasks` 180 行、`checkpoints` 1 行、`audit_logs` 2667 行、`notifications` 164 行、`messages` 8 行、`snapshot_history` 4 行全部不变 ✔ | +| **无快照学生 GET 后仍 0 行** | 新建学生 8014:`before=0` → 3 次 GET(均 200,`overall=30.0`)→ `after=0` ✔ | +| 趋势回归 | 当前周期 `2026-09`、`overallScore=62.55`、多次调用稳定、零写 ✔ | +| 排名回归 | `rank=1`、`cohortSize=2`、`percentile=100.0`;学生 A/B 各 8 维快照 ✔ | +| 企业看板 | overview / job-rankings / candidate-rankings / trend 均 200;越权 403 ✔ | +| 申诉回归 | `appealId=4290`、`scoreAtAppeal=67.75`、`snapshotVersionAtAppeal=0`(非空);副作用仅限契约表 ✔ | +| 公平性监控 | 5 个端点均 200;`drift`/`ranking-stability` 的 `UNAVAILABLE` 抑制语义保留 ✔ | +| V44 迁移 | `version=44`、`success=1`、`row=1`(唯一)、`failed_migrations=0` ✔ | +| Hibernate validate | 无 `schema-validation` / `SchemaManagementException` 错误 ✔ | +| RabbitMQ | 4 队列(`queue.sms.verification`/`queue.notification`/`queue.notification.dlq`/`queue.audit`)**ready=0、unacked=0** ✔ | +| 日志/响应/审计 | 应用日志(`r01d42-*.log`)手机号、Token、验证码、JWT、SQL 参数、简历路径命中 **均为 0**;222 处「口令」命中全部为 Spring Boot `Using generated security password: `(框架提示,非项目凭据);3 处手机号命中为既有测试 `AdminBootstrapConcurrencyLoggingTest` 的扫描器探针;响应扫描无 `phone/email/resumeUrl/storagePath/accessToken` ✔ | + +副作用证明方式:全部使用**显式列 + 稳定 ORDER BY 的逐字段行快照比对**(`snap.py`),未使用 `COUNT`/`SUM`/`CRC32` 替代;并附有效性反证(`snap.selftest()` 对单字段内存改动可检出,PASS)。 + +--- + +## 7. 收尾 + +| 项 | 结果 | +|---|---| +| 本轮 Java 实例 | 仅停止本轮记录的两个 PID(12592、11060);`tasklist` java.exe 计数 = **0** ✔ | +| 8080 / 8081 | 空闲 ✔ | +| Docker | 保持运行,三容器 healthy ✔ | +| 数据清理 | `users` 残留 **0**;本轮用户 8010/8011/8012/8013/8014、公司 31/32、岗位 49、投递 90/91、offer 17、简历版本、申诉 4290 及全部关联行均按精确主键删除 ✔ | +| append-only 保留 | `audit_logs` 中 actor 为本轮 5 个隔离用户的 **15 + 2 = 17 行**保留并登记(关联 ID:`user_id ∈ {8010,8011,8012,8013,8014}`)✔ | +| 测试夹具(精确 INSERT,已登记并已清理) | `user_roles` 为 8012/8013 各插入 1 行 `COMPANY`;`offers` 插入 1 条已过期 SENT 记录(id=17)用于触发 F1 定时路径 ✔ | +| Git | 分支未变、HEAD 未变、暂存区空、V1–V43 未改、V44 唯一新增、无删除文件、`git diff --check` 无空白错误 ✔ | +| 未暂存 / 未提交 / 未推送 | 是 ✔ | + +本轮新增产物(均在 `docs/acceptance/r01-d4-2-20260916/`,未进入暂存区):`iv-batch1.log`、`iv-batch2.log`、`iv-clean-verify.log`、`iv-precision.sql`、`iv-fence.sql`、`iv-fence2.sql`、`iv-fence-loop.py`、`v3-writeside.py`、`v3-out.txt`、`s1-iv-out.txt`、`s4-iv-out.txt`。 + +--- + +## 8. PASS / FAIL 清单 + +**FAIL(1 项)** + +| # | 项 | 依据 | +|---|---|---| +| 1 | **claim fence 精度不足**:`processing_started_at` 为秒级 `DATETIME`,实测同一秒内新旧领取得到相同 fence | 任务书硬性判据「可能得到相同 fence 直接判 FAIL」;最小复现 `iv-fence2.sql` | + +**PASS(其余全部)** + +OfferExpiryWorker 同事务登记与幂等;Feedback create/update/delete 登记与删除前取标识;ResumeVersionWriter 同一 `REQUIRES_NEW` 边界且外层不重复登记;ABILITY 脱敏 26 项禁止 key 递归断言;批次隔离与 stale claim 安静退出;定向 184/184;关联 558/558;全量 2163/2163(S=0);OpenAPI 176/214;A 五条写侧;B 简历事务故障;C 20 轮 fence 竞争语义;D 批次隔离;22 表逐字段零写;无快照学生 0 行;趋势/排名/看板/申诉/公平性;V44;validate;RabbitMQ;日志脱敏。 + +**SKIP**:无(本轮无因环境或安全原因跳过项)。 + +--- + +## 9. 剩余风险 + +1. **fence 失效的后果有限但不为零**:即使触发,最坏结果是同一次刷新多算一遍(任务回到 PENDING),不会数据损坏或永久 PROCESSING。但对 SLA 敏感场景仍属正确性瑕疵。 +2. **`processing-timeout-ms` 无下界校验**:当前仅做 `<= 0 → 600000` 兜底,1–999 的取值未被拒绝。 +3. 任务书未要求、本轮也未执行的项:真实双 JAR 的多实例补偿扫描并发、申诉审核/回滚真实黑盒(延续 D4-2 首轮验收的 SKIP 记录,不属于本轮复验范围)。 + +--- + +> **已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。** diff --git a/docs/r01-d4-2-get-scoring-readonly-implementation-report.md b/docs/r01-d4-2-get-scoring-readonly-implementation-report.md new file mode 100644 index 0000000..4e74a1e --- /dev/null +++ b/docs/r01-d4-2-get-scoring-readonly-implementation-report.md @@ -0,0 +1,57 @@ +# R01-D4-2 GET 评分快照停写实施报告 + +日期:2026-09-16 +状态:实现与自动化复验通过,待独立真实环境黑盒验收。 + +## 1. 变更结论 + +`CandidateScoringEngineService#getOverview`、`getDimensionDetail`、`getCompanyScoring` 已改为 +`@Transactional(readOnly = true)`,并移除 `CandidateScoringCalculator#upsertSnapshots` 调用。 +三个接口仍以同一次 `capturedNow` 实时计算并返回原响应结构,趋势接口继续读取已物化快照。 + +快照更新改由 D4-1 的写侧登记、后台 Worker 和补偿扫描负责。生产配置默认启用刷新与补偿; +test、integration-test、mysql-it profile 显式关闭调度,仅由测试主动驱动,避免后台线程干扰断言。 + +## 2. D4-1 网关补齐 + +V44 同时创建 `candidate_scoring_refresh_scan_checkpoints`。补偿扫描使用持久化游标、固定单轮预算和 +`SELECT ... FOR UPDATE` 串行推进;游标与任务登记同事务提交。扫描到尾部后从 0 开始下一轮,避免首 +页饥饿,也不会单次遍历全量用户。 + +真实 MySQL 8.4 Testcontainers 已验证:V1–V44 迁移和 schema validate、游标持久化、每轮预算、 +Writer 幂等与强制版本、并发唯一领取、处理中重登记、租约恢复、失败事务隔离。 + +## 3. 测试迁移 + +- `R01D4GetWritesSnapshotEvidenceTest` 改为逐字段快照,证明 overview、dimension、trend GET 零写入。 +- `CandidateScoringEngineServiceTest` 与时钟一致性测试显式断言三个 GET 从不调用 upsert。 +- `CandidateScoringPersistenceIntegrationTest` 显式调用 Calculator/Writer 验证物化幂等,不再借 GET 造数据。 +- `V38AbilityRankingIntegrationTest` 显式走真实物化入口,原排名公式和结果断言保持不变。 + +首次干净全量构建据此发现 V38 排名测试仍依赖 GET 副作用(1 Error);修正夹具后专项测试通过, +再次执行干净全量构建成功。 + +## 4. 实测结果 + +| 项目 | 结果 | +| --- | --- | +| D4-2 + D4-1 H2/MySQL 组合定向 | 38 / 38,0 failure,0 error,0 skip | +| 时钟与持久化定向 | 13 / 13,0 failure,0 error,0 skip | +| V38 排名回归 | 1 / 1,原断言不变 | +| `mvn clean verify` | 2131 / 2131,Failures=0,Errors=0,Skipped=0,BUILD SUCCESS | +| MySQL Testcontainers | Q01 与 V44 用例均真实执行,无跳过 | +| OpenAPI | 176 paths / 214 operations,校验通过 | +| `git diff --check` | 无空白错误,仅既有 LF/CRLF 提示 | + +## 5. 边界 + +V44 是唯一新增迁移,V1–V43 未修改;无新增公网接口、ErrorCode、RabbitMQ 消息或通知;未暂存、 +未提交、未推送;历史未跟踪文件未删除或移动。 + +## 6. 待独立验收 + +仍需使用最新 JAR 与项目真实 MySQL/Docker 环境完成:三条真实 HTTP GET 前后快照逐字段零变化; +写侧事件到任务登记再到快照可见;趋势、申诉、排名、看板、公平性监控不退化;RabbitMQ 队列终态 +为零;并精确清理或记录隔离验收数据。自动化与 Testcontainers 结果不能替代该黑盒步骤。 + +> R01-D4-2 实现及自动化复验完成,待独立真实 MySQL/Docker 黑盒验收。 diff --git a/docs/r01-d4-2-independent-mysql-acceptance-20260916.md b/docs/r01-d4-2-independent-mysql-acceptance-20260916.md new file mode 100644 index 0000000..953d48d --- /dev/null +++ b/docs/r01-d4-2-independent-mysql-acceptance-20260916.md @@ -0,0 +1,656 @@ +# R01-D4-2「GET 评分接口停写与快照可靠物化」独立代码复验与真实 MySQL/Docker/JAR/HTTP 黑盒验收 + +日期:2026-09-16 +分支:`refactor/r01-structure-performance` +HEAD:`b10c4a7 docs: design scoring snapshot materialization` +验收性质:**最终独立验收**(非开发)。审查正式生产路径 + 项目既有 Docker Compose + 真实 MySQL 8.4 + 最新构建 JAR + 真实 HTTP。 +验收前缀:`R01D42_ACC_20260916_K4GDNT` + +--- + +## 0. 最终结论 + +> **已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。** + +判定为 **FAIL**。触发任务书第二十节 FAIL 判据中的第 4 条「**写侧成功但任务未登记**」,并另有一处测试断言弱化(触碰「禁止修改或放宽既有测试」红线)。 + +同时必须明确记录:**本次验收的核心目标项全部实测通过** —— 三个评分 GET 在真实 HTTP 下确凿零写、V44 在真实 MySQL 成功、`clean verify` 2131/2131 全绿、补偿扫描游标与预算正确、趋势/排名/看板/申诉/公平性监控均未退化、RabbitMQ 终态正常。FAIL 由**写侧事件覆盖不全**这一独立缺陷触发,与 GET 停写本身无关。 + +--- + +## 1. Git 基线与工作区状态 + +| 检查项 | 结果 | +|---|---| +| 分支 | `refactor/r01-structure-performance` ✔ | +| HEAD | `b10c4a7`(验收全程未变) | +| 暂存区 | 空(`git diff --cached --name-only` 无输出)✔ | +| `git add -A` / `commit` / `push` | 均未执行 ✔ | +| V1–V43 修改 | 无(`git diff --name-only -- db/migration` 为空)✔ | +| V44 | `V44__candidate_scoring_refresh_tasks.sql` 为唯一新增迁移 ✔ | +| 删除文件 | 无 ✔ | +| 历史未跟踪脚本/日志/JSON/SQL/xlsx/.env | 全部保留,未删未移 ✔ | +| `git diff --check` | 无空白错误(仅既有 LF/CRLF 提示)✔ | +| `AGENTS.md` | 全仓搜索**未发现** | + +本轮新增(未跟踪):`docs/acceptance/r01-d4-2-20260916/`(验收脚本与脱敏证据)、`docs/r01-d4-2-independent-mysql-acceptance-20260916.md`(本报告)。未修改任何 D4-1/D4-2 生产代码、未修改 V1–V44、未新增 Flyway/表/索引/ErrorCode/公网接口。 + +--- + +## 2. 独立代码审查 A1–A6 + +方法:9 个并行静态审查维度 + 对每条 critical/major 发现的对抗性复核(20 个独立子代理,全部复核结论为「发现成立」),另由本人逐条打开源码亲自确认关键项。 + +### A1 三个 GET 是否真正只读 —— **通过** + +- `CandidateScoringEngineService.java:52 / :61 / :106` 三个方法均为 `@Transactional(readOnly = true)`。 +- 三个方法体内仅含 `LocalDateTime.now(clock)` + `calculator.computeAll(...)` + 只读仓储/`JobService.getJob` 查询 + DTO 组装,无任何写语句。 +- `git diff HEAD -- .../CandidateScoringEngineService.java` 全部改动仅为 3 处注解变更 + 删除 3 行 `calculator.upsertSnapshots(...)`。 +- `CandidateScoringCalculator.computeAll`(`:122-146`)只含 `findById` + 6 个纯 SELECT(`queryApps/queryAudit/queryOffers/queryFeedback/queryResume/queryMessages`),无写路径;全 main 源码中 `upsertSnapshots` 已无生产调用方。 +- `explanation/` 包 grep `jdbc|Repository|EntityManager|@Transactional|save(` 无命中。 +- 响应仍来自实时 `computeAll`;`status`(`CandidateOverviewStatusPolicy`)、`weight`(`DEFAULT_WEIGHT = 12.50`)、`rulesVersion`(`currentRulesVersion()`)与 D4-1 前逐字节一致。 +- 企业授权校验(company→job 归属)保留,越权负例实测 403。 + +遗留(非缺陷,记录):`CandidateScoringCalculator.java:582` javadoc 仍写「普通物化入口(含 GET 兜底)」,与 GET 兜底已删除的现状不符(注释陈旧)。 + +### A2 写侧登记覆盖 —— **不通过(存在两处真实遗漏)** + +任务书列出的 8 个服务方法**全部正确接入**,且均为同事务 `REQUIRED` 登记: + +| 服务.方法 | 登记位置 | +|---|---| +| `ProfileService#updateProfile` | `ProfileService.java:75` | +| `ResumeVersionService#createVersion / updateVersion / deleteVersion` | `ResumeVersionService.java:71 / :163 / :189` | +| `PreferenceService#savePreferences` | `PreferenceService.java:76` | +| `AbilityAttemptService#submit` | `AbilityAttemptService.java:294` | +| `ApplicationService#apply / withdraw / updateStatus` | `ApplicationService.java:90 / :129 / :224` | +| `OfferService#transitionStatus` | `OfferService.java:304` | +| `CandidateFeedbackService#createFeedback` | `CandidateFeedbackService.java:82` | +| `MessagingService#sendMessage`(仅 STUDENT) | `MessagingService.java:197-200` | + +角色判定核实:`MessagingService.java:197-200` 用 `u.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT)`,只有学生发送者登记,企业发送者不登记 ✔ + +**遗漏一:Offer 自动过期不登记(major,命中 FAIL 判据)** + +- `OfferExpiryWorker.java:41 expireOne(...)` 在 `:48` 直接 `offer.setStatus(OfferStatus.EXPIRED.name())` 后 `saveAndFlush`,**全文 `requestRefresh`/`scoringRefresh` 出现次数 = 0**。 +- 调用方为定时任务 `OfferExpiryService.java:34 worker.expireOne(...)`,不经过 `OfferService#transitionStatus`。 +- 同一行 `:59` 还调用 `counterOfferRepository.expirePendingByOfferId(...)`,`:68 notifyExpired(saved)`。 +- 该写入**直接影响评分输入**:`CandidateScoringCalculator.java:188-194 queryOffers` 的 SQL 为 + `SELECT SUM(CASE WHEN status='ACCEPTED' THEN 1 ELSE 0 END), SUM(CASE WHEN status='EXPIRED' THEN 1 ELSE 0 END) FROM offers WHERE candidate_id = ?`,`EXPIRED` 计数参与 INTEGRITY 维度。 +- `docs/r01-d4-get-scoring-snapshot-migration-design.md:105` 明确要求「Offer 状态变化 → Offer 状态机成功提交后 → 标记待刷新」,而 D4-1 报告「明确未接入」清单(§12,`:43`)**并未**把 Offer 自动过期列为有意不接入项 —— 属实现遗漏。 + +**遗漏二:候选人反馈的修改/删除不登记(major,命中 FAIL 判据)** + +- `CandidateFeedbackService.java:107 updateFeedback(...)` 与 `:124 deleteFeedback(...)` 均未调用 `requestRefresh`;仅 `:82 createFeedback` 登记。 +- 评分读取该表:`CandidateScoringCalculator.java:196-200 queryFeedback` 的 SQL 为 + `SELECT COUNT(*), COALESCE(AVG(rating),0) FROM candidate_feedback WHERE student_user_id = ?`。 +- 修改 `rating` 会改变 `AVG`,删除反馈会同时改变 `COUNT` 与 `AVG`,两者都不触发刷新。 +- 设计文档 `:106` 明确要求「候选人反馈创建/**修改**的真实写入口」触发刷新 —— 属实现遗漏。 + +**遗漏三:`ResumeVersionService.createVersion` 的事务边界不对称(major)** + +- `ResumeVersionService.java:71` 的登记位于 `createVersion` 的 `@Transactional` 内(REQUIRED,加入外层事务); +- 但版本行由 `versionWriter.saveNewVersion(...)`(`@Transactional(REQUIRES_NEW)`,文件内 javadoc `:52-55` 自述「写入成功即独立提交,外层事务回滚不会让版本行消失」)**独立提交**; +- 因此存在窗口:版本行已提交、外层事务在提交阶段失败 → **业务数据已落库但刷新任务登记丢失**。 + +其余 A2 结论:登记不触发线程内重算、不发 RabbitMQ、不写通知、不写审计、不记录敏感值 —— 全部成立。 + +### A3 唯一快照 Writer —— **通过** + +- `CandidateScoringSnapshotWriter.java:105-112`:`score / status / sub_scores_json / weight / rules_version` 全部相同则 early return,**不改 version 与 updated_at** ✔ +- `calculated_at` 不参与变化判定 ✔ +- `rules_version` 参与判定,变化必推进 version ✔ +- `canonicalJson`(`:183-214`)对 ObjectNode 递归按字典序重排、对 ArrayNode 逐元素递归 ✔ +- 并发安全:`:91-98` 先 `INSERT ... ON DUPLICATE KEY UPDATE id=id`,`:126-137` 再 `SELECT ... FOR UPDATE`,`:113-119` 条件更新 `WHERE id=? AND version=?`,冲突抛 `OptimisticLockingFailureException` —— 无 JVM 锁 ✔ +- Governance 强制路径 `:66-69 upsertForcingVersion` → `:75-88` 无条件 `version=version+1`;调用方 `CandidateScoringGovernanceService.java:294` 保持 `afterVersion = beforeVersion + 1` 语义 ✔ +- 普通物化不写 `candidate_scoring_snapshot_history` ✔(黑盒已证,见 §10) +- 单维写失败整体回滚:`upsertIfChanged`(`:51-60`)为单一 `@Transactional` 循环,`CandidateScoringRefreshTaskExecutor.materialize` 同为单个 `REQUIRES_NEW` —— 无部分提交 ✔ + +### A4 Worker 与失败恢复 —— **不通过(两处独立缺陷)** + +- `claim`(`:43-62`)、`materialize`(`:64-89`)、`recordFailure`(`:91-108`)、`recoverStale`(`:29-41`)分属 4 个独立 `REQUIRES_NEW` 事务 ✔ +- 并发领取唯一:`claim` 的 `WHERE id=? AND status IN ('PENDING','RETRY') AND (next_attempt_at IS NULL OR next_attempt_at <= ?)` + `updated != 1 → null` ✔(真实 MySQL 集成测试亦覆盖) +- `last_error_code` 只写固定分类(`WRITE_FAILED`/`COMPUTE_FAILED`/`UNKNOWN_REFRESH_FAILURE`/`WORKER_TIMEOUT`),`:82` 的 `log.warn` 只记录 taskId 与 category ✔ +- PROCESSING 超时回收、达上限转 FAILED ✔ + +**缺陷一:`materialize` 的「回到 PENDING」分支缺 version 闸门(major)** + +`CandidateScoringRefreshTaskExecutor.java:78-88`: + +``` +UPDATE ... SET status='COMPLETED' ... WHERE id=? AND status='PROCESSING' AND version=? -- 有闸门 +if (completed == 0) { + UPDATE ... SET status='PENDING' ... WHERE id=? AND status='PROCESSING' -- 无闸门 +``` + +当原 Worker 因超时已被 `recoverStale` 置为 RETRY/FAILED 并被另一个 Worker 重新领取(处于 PROCESSING)后,慢的原 Worker 返回时 `version` 不匹配走 PENDING 分支,会**把新 Worker 正在处理的任务改回 PENDING**;随后新 Worker 完成时 `completed=0`、`pending=0` → 抛 `IllegalStateException`。 + +**缺陷二:失败记录异常会打断同批任务(major)** + +`CandidateScoringRefreshWorker.java:77-86` 的 `catch (Exception e)` 块内调用 `taskExecutor.recordFailure(...)`;该方法在 `updated != 1` 时抛 `IllegalStateException`(`TaskExecutor.java:105-107`)。异常在 catch 块内抛出不会被自身捕获,将传播到 `processDue` 的 `for (Long id : due)` 循环(`Worker.java:63-67`,**循环体内无 try/catch**),导致**同批剩余任务本轮全部不被处理**,违反 `Worker.java:24` javadoc 声称的「异常不回抛,不中断其他任务」。 + +触发前提为物化耗时超过 `processing-timeout-ms`(默认 600000ms),概率低但路径真实。 + +### A5 补偿扫描 —— **通过** + +- 游标持久化于 `candidate_scoring_refresh_scan_checkpoints`(`:79-85 lockCursor` 用 `SELECT ... FOR UPDATE`),非内存字段 ✔ +- 单轮预算:`:48` `budget = max(1, min(limit, 500))`,`:98` `LIMIT ?` 绑定同一 `budget` ✔ +- 尾部回绕:`:59` `ids.size() < budget || ids.isEmpty() ? 0 : 末位 id`;`:52-55` 游标已在尾部且空结果时重置 0 重查 ✔ +- 游标推进与任务登记同事务:`scanDueStudents` 为 `@Transactional`,`requestService.requestRefresh` 为 `REQUIRED` ✔ +- 多实例串行:checkpoint 行 `FOR UPDATE` ✔ +- 查询预算:1 次聚合查询(`findEligibleAfter`)+ ≤budget 次 upsert,无逐用户 N+1 ✔ +- 不直接写 `candidate_dimension_scores` ✔ + +### A6 配置 —— **通过(无断档)** + +- `application.yml:156` `enabled: ${SCORING_REFRESH_ENABLED:true}`,`:158` `compensation-enabled: ${SCORING_REFRESH_COMPENSATION_ENABLED:true}` —— **生产默认启用** ✔ +- `application-test.yml:23-26` / `application-integration-test.yml:37-40` / `application-mysql-it.yml:26-29` 三处均显式 `enabled: false` + `compensation-enabled: false` ✔ +- 不依赖测试 profile 才能工作 ✔;Scheduler `@Value` 的 `:false` fallback 仅在该键完全缺失时生效,yml 提供 `true` 时实际生效值为 `true`(黑盒已验证补偿扫描确实运行)✔ + +遗留(记录):`CandidateScoringRefreshScheduler.java:15` javadoc 仍写「默认关闭」,与现状相反。 + +--- + +## 3. Docker / MySQL / Redis / RabbitMQ 版本与状态 + +| 组件 | 版本 | 地址 | 验收前 | 验收中/后 | +|---|---|---|---|---| +| Docker Engine | 29.6.2 | — | 已启动 | 未停止 ✔ | +| MySQL | **8.4.11** | 127.0.0.1:3307 | Exited(137) | healthy ✔ | +| Redis | 7.4-alpine | 127.0.0.1:6379 | Exited(0) | healthy ✔ | +| RabbitMQ | 3.13-management-alpine | 127.0.0.1:5672 / 15672 | Exited(137) | healthy ✔ | + +启动方式:`docker compose --env-file backend-infra/.env -f backend-infra/compose.yaml up -d`(仅使用项目既有 Compose)。未执行 `down -v`、未删除/重建 volume、未修改 `compose.yaml`、未新建业务数据库、未打印凭据。 + +volume 清单(`backend-infra_mysql_data` / `_redis_data` / `_rabbitmq_data`)与验收前一致,未删除。 + +--- + +## 4. V44 Flyway 与 schema validation(真实 MySQL) + +启动最新 JAR 前:`flyway_schema_history` 中 `max(version)=43`、`COUNT(*)=43`、V44 行数 0、V44 表不存在 —— 与「尚未执行 V44」的基线预期一致。 + +启动后: + +| 项 | 值 | +|---|---| +| `version` | **44** ✔ | +| `description` | `candidate scoring refresh tasks` | +| `type` | `SQL` ✔ | +| `success` | **1** ✔ | +| V44 行数 | **1**(唯一)✔ | +| `success=0` 的迁移数 | **0** ✔ | +| `max_version` / 总迁移数 | 44 / 44 ✔ | + +启动日志:`Started MyDreamjobApplication in 15.425 seconds`;主 profile 为 `default`;`ddl-auto=validate` 通过,**无 schema validation / SQL 语法 / Bean 装配错误**。 + +表结构(`SHOW COLUMNS`/`SHOW INDEX`): + +- `candidate_scoring_refresh_tasks`:13 列 `id, student_user_id, target_period(7), status(20), requested_at, processing_started_at, processed_at, next_attempt_at, attempts, last_error_code(40), version, created_at, updated_at` ✔ 状态/重试/租约/version/时间字段齐全 +- 唯一键 `uk_refresh_task_student_period (student_user_id, target_period)` ✔ +- 领取索引 `idx_refresh_task_claim (status, next_attempt_at)` ✔ +- 约束齐全:`PRIMARY`、`uk_refresh_task_student_period`(UNIQUE)、`fk_refresh_task_student → users(id) ON DELETE CASCADE`、`ck_refresh_task_status CHECK (status IN ('PENDING','PROCESSING','RETRY','COMPLETED','FAILED'))` ✔ +- `candidate_scoring_refresh_scan_checkpoints`(`scan_key` PK, `last_user_id`, `version`, `updated_at`)存在 ✔ + +V1–V43 全程未修改 ✔ + +--- + +## 5. 最新 JAR 启动与健康检查 + +- JAR:`mydreamjob-server/target/mydreamjob-server-0.0.1-SNAPSHOT.jar`(`mvn clean verify` 产出,82,440,778 bytes) +- 启动方式:`docs/acceptance/r01-d4-2-20260916/start-app.ps1`,显式提供 `DB_URL=127.0.0.1:3307`、`REDIS_HOST/PORT`、`RABBITMQ_*`、`SMS_MOCK_ENABLED=true`、`SCORING_REFRESH_*`;未修改 `application.yml` +- 验收前 8080/8081 空闲;验收后已全部释放(无 java 进程残留) +- `GET http://127.0.0.1:8080/api/v1/health` → **200** ✔;`http://127.0.0.1:8081/actuator/health` → **200** ✔ +- 启动耗时 14.9–15.4s,Flyway 成功,validate 成功 + +**实例使用(方案 A)**:三个实例互斥启动、PID 各自记录: + +| 用途 | PID | REFRESH | 说明 | +|---|---|---|---| +| 只读验证 #1 | 24624(失败,见下) / 22640(dev profile 失败) / **12656** | false | 三个目标 GET 零写验证 | +| 物化验证 | **23436** | true | 写侧链路 + 补偿扫描 | +| 只读验证 #2 | **28456** | false | 消费者回归 + 零写 | +| 重启验证 | **6576** | true | 跨重启游标延续 | + +启动调试记录(自我暴露,如实记录):首次启动因 `application-dev.yml` 硬编码短密钥覆盖环境变量而失败;修正为默认 profile 后又因**脚本以 UTF-8 无 BOM 保存、PowerShell 5.1 按 ANSI 解码中文注释导致换行被吞、`JWT_ACCESS_SECRET` 赋值行被并入注释**(诊断值为 `ACCESS_LEN=0`)而失败;加 BOM 后 `ACCESS_LEN=48` 正常启动。此为验收脚本自身缺陷,与产品无关。 + +--- + +## 6. 自动化实际测试数 + +| 命令 | 结果 | +|---|---| +| 定向批次一(8 个 D4 核心类) | **55 / 55**,F=0 E=0 **S=0**,BUILD SUCCESS(49.3s) | +| 定向批次二(`*CandidateScoring*Test,*Competition*Test,*ScoringDashboard*Test,*FairnessMonitoring*Test`) | **253 / 253**,F=0 E=0 **S=0**,BUILD SUCCESS | +| `mvn clean verify` | **2131 / 2131**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(4:08,211 个测试类) | +| `python docs\verify_openapi.py` | **paths: 176 / operations: 214**,All checks passed ✔ | + +批次一逐类:`CandidateScoringRefreshInfrastructureTest` 13/13、`CandidateScoringRefreshFailureTransactionTest` 3/3、**`CandidateScoringRefreshMySqlIntegrationTest` 6/6(20.13s,真实 MySQL Testcontainers)**、`CandidateScoringEngineServiceTest` 14/14、`R01D4GetWritesSnapshotEvidenceTest` 5/5、`CandidateScoringPersistenceIntegrationTest` 3/3(17.51s)、`V38AbilityRankingIntegrationTest` 1/1、`CandidateScoringClockConsistencyTest` 10/10。 + +**Q01 与 V44 的 MySQL Testcontainers 用例真实执行**(耗时 20.1s / 17.5s 表明容器真实启动),Docker 全程可用,**无因环境缺失导致的 skip**。 + +--- + +## 7. 三个目标 GET 与趋势 GET 的真实 HTTP 结果 + +前提:被测实例 `SCORING_REFRESH_ENABLED=false`(后台 Worker 与补偿扫描关闭),因此任何表变化都只能来自 HTTP 请求本身。 + +每个接口连续调用 **3 次**,共 12 次真实 HTTP: + +| 接口 | HTTP | 契约 | 敏感字段 | +|---|---|---|---| +| `GET /api/v1/profile/me/candidate-scoring` ×3 | 200,200,200 | 稳定(10 字段) | 无泄露 | +| `GET /api/v1/profile/me/candidate-scoring/dimensions/EDUCATION` ×3 | 200,200,200 | 稳定(17 字段) | 无泄露 | +| `GET /api/v1/company/candidates/86/scoring` ×3 | 200,200,200 | 稳定(6 字段) | 无泄露 | +| `GET /api/v1/profile/me/candidate-scoring/trend?months=6` ×3 | 200,200,200 | 稳定(3 字段) | 无泄露 | + +- 响应不含 `phone` / `email` / `resumeUrl` / `storagePath` / `accessToken` / `refreshToken` / `passwordHash`,正则扫描无 11 位手机号。 +- **S1 零写验收:PASS=24 / FAIL=0 / SKIP=0** + +--- + +## 8. GET 前后逐字段数据库快照 + +快照覆盖 **22 张表**(显式列、稳定 `ORDER BY` 全列、逐行比对)。敏感列(手机号/邮箱/Token 摘要/文件路径/IP/UA/幂等键)以 `SHA2(...,256)` 参与比对以保持逐行可区分且不落真实值;非敏感列保留原始值。列不存在时 SQL 直接报错(不 catch)。 + +**结果:22 张表逐字段零变化** ✔ + +| 关键表 | 行数 | 结果 | +|---|---|---| +| `candidate_dimension_scores` | 88 | 零变化 ✔ | +| `candidate_scoring_refresh_tasks` | 2 | 零变化 ✔ | +| `candidate_scoring_refresh_scan_checkpoints` | 0 | 零变化 ✔ | +| `audit_logs` | 2620 | 零变化 ✔ | +| `notifications` | 158 | 零变化 ✔ | +| `messages` | 8 | 零变化 ✔ | +| `candidate_scoring_snapshot_history` | 4 | 零变化 ✔ | + +**最强证据**:该只读实例下,验收学生 A(userId 8002)的 `candidate_dimension_scores` 在多次 GET 后仍为 **0 行** —— 若 D4-2 前行为(GET 兜底写快照)仍存在,此处必为 8 行。同时任务表存在 1 行 `PENDING` 任务,证明写侧登记已发生但后台物化被正确关闭。 + +**反证(扫描器有效性)**:`snap.selftest()` 对内存中的一行仅改动单个字段,扫描器正确检出 `[('1', [('b','y','Z')])]` → PASS。 + +--- + +## 9. 写侧事件 → 任务 → Worker → 快照的真实链路 + +前提:物化实例 `SCORING_REFRESH_ENABLED=true`,`worker-interval=1500ms`。 + +**S2 结果:PASS=14 / FAIL=0 / SKIP=0** + +| 项 | 实测 | +|---|---| +| W1 `PUT /profile/me`(资料更新) | st=200;任务恰好 1 条 `(8002, 2026-09)`;**Worker 0.7s 处理为 COMPLETED**;`last_error_code` 为空;**八维快照齐备(8 行)**;`rulesVersion` 全部 `candidate-scoring-v0.1` | +| 同事务登记 | 请求成功后立即出现任务行,无需等待 | +| W2 `PUT /profile/me/preferences` | st=200 → COMPLETED → 八维仍 8 行(**无部分写**) | +| W3 `POST /applications` | st=409(本轮已投递,幂等)→ 仍合并为 1 条任务 | +| 重复同一事件 ×3 | 仍只有 1 条任务,`requested_at` 推进不新增 | +| **完全相同输入重放** | 八维 version 全等(`ABILITY:0, ACTIVITY:0, COMPLIANCE:1, EDUCATION:1, GROWTH:0, INTEGRITY:0, INTERNSHIP:0, STABILITY:0`)→ **version 不推进** ✔ | +| `candidate_scoring_snapshot_history` | 4 → 4 **零变化**(普通 Worker 不写历史)✔ | +| `notifications` | 158 → 158 零变化 ✔ | +| `audit_logs` | 2620 → 2620 零变化 ✔ | +| `messages` | 8 → 8 零变化 ✔ | + +**PROCESSING 防丢失**:本项在真实 HTTP 下难以稳定卡住窗口,按任务书允许的方式引用真实 MySQL 并发集成测试 —— `CandidateScoringRefreshInfrastructureTest#requestDuringProcessingIsNotLost` 走真实完成路径 `taskExecutor.claim` → 处理期间新请求 → `taskExecutor.materialize`,断言回到 `PENDING` 而非 `COMPLETED`;`CandidateScoringRefreshMySqlIntegrationTest` 6/6 在真实 MySQL 上覆盖重登记与租约恢复。写侧正常主链路(W1/W2/W3)**未跳过**。 + +**源业务失败不留残留任务**:`CandidateScoringRefreshFailureTransactionTest` 3/3 覆盖外层业务回滚时登记一并回滚 ✔(该项为 H2/集成层证据;A2 中 `ResumeVersionService.createVersion` 的独立提交边界为静态代码发现,见 §2)。 + +--- + +## 10. 重复登记与幂等 version + +- 同学生同周期重复登记合并为一行:`:56-71` 的 `ON DUPLICATE KEY UPDATE` 只推进 `requested_at`、`version`,不新增行 ✔(黑盒 W1b 实测 1 行) +- `COMPLETED`/`FAILED` 重新打开为 `PENDING` 且 `attempts` 归零;`RETRY` 回到 `PENDING` 保留 `attempts` ✔(`CandidateScoringRefreshInfrastructureTest#completedTaskCanBeRequestedAgain`) +- `PROCESSING` 期间新事件不丢失:登记保留 `status='PROCESSING'` 并推进任务 `version`;完成时 `version` 不匹配即回退 `PENDING` ✔ +- **幂等物化**:普通物化值完全不变时 `version` 与 `updated_at` 不变(黑盒 W1b 确证)✔ + +--- + +## 11. 租约、重试与失败分类 + +- `recoverStale`(`TaskExecutor.java:29-41`):`WHERE status='PROCESSING' AND processing_started_at < ?`,置 `RETRY`(或达上限 `FAILED`)、`attempts+1`、`last_error_code='WORKER_TIMEOUT'`、清空 `processing_started_at` ✔ +- `recordFailure`(`:91-108`):`attempts+1 >= maxAttempts → FAILED` 否则 `RETRY` + `next_attempt_at` 退避 ✔ +- 错误分类为固定字符串,不含异常原文/SQL/堆栈/手机号 ✔ +- 调度兜底:`CandidateScoringRefreshScheduler.java:49-57 / :66-70` 各自 try/catch,单轮失败不影响后续轮次 ✔ +- 真实 MySQL 证据:`CandidateScoringRefreshMySqlIntegrationTest` 6/6(含租约恢复、部分写失败全回滚且同批成功任务不受影响) + +**但**:§2-A4 的两处缺陷(PENDING 分支缺 version 闸门、`recordFailure` 抛出打断 for 循环)表明「同批兄弟任务互不影响」在租约竞争边界下**不成立**。 + +--- + +## 12. 补偿游标、跨重启、单轮预算、多实例 + +参数:`compensation-limit=2`,`compensation-interval=4000ms`。 + +| 验证项 | 实测证据 | +|---|---| +| 单轮登记数 ≤ limit | 连续 12 轮 `created_at` 分组计数**全部为 2**,无一超标 ✔ | +| 游标持久推进 | `last_user_id` 113 → 126 → 130 → 134 → 138 → 142 → 146…,`version` 每轮 +1 ✔ | +| **不会永远只扫首批用户** | 游标推进至 7948 → 7954 → 7963 → 7967 → 7973 → **8002**(接近学生最大 id 8005)✔ | +| **扫到尾部后从头继续** | 8002 之后下一轮 `last_user_id` 回到 **15** → 20 … ✔ | +| **跨重启从持久游标继续** | 重启前 `cursor=113 / version=104`;重启后首轮 `cursor=126 / version=105`(version 恰好 +1)—— **未回到 0** ✔ | +| 只读实例不扫描 | `SCORING_REFRESH_ENABLED=false` 下 `cursor=113/104` 12 秒内完全冻结 ✔ | +| 多实例并发不重复推进 | 代码层 `lockCursor` 的 `SELECT ... FOR UPDATE` 串行化 + 游标推进与登记同事务 ✔(**未做双 JAR 黑盒**,见 §23 SKIP) | +| 每轮查询预算有上限 | 1 次聚合查询 + ≤2 次 upsert,无 N+1 ✔ | +| 不直接写快照 | 扫描器文件内无 `candidate_dimension_scores` 写入 ✔ | + +任务终态:`candidate_scoring_refresh_tasks` 共 181 行,**181/181 全部 COMPLETED**,无 PENDING / RETRY / FAILED / 永久 PROCESSING ✔ + +--- + +## 13. 趋势回归 + +- `GET /profile/me/candidate-scoring/trend?months=6` → 200;当前周期 `2026-09` 存在;`overallScore=65.2`;`rulesVersion=candidate-scoring-v0.1`;维度数 5(趋势按既有语义只收录 `OK`/`ESTIMATED` 维度,非 8 —— 与 `CandidateScoringEngineService.java:91` 的过滤逻辑一致) +- 多次调用结果**逐字节稳定** ✔ +- **请求本身零写**(逐字段比对)✔ +- 能读到后台物化的当前周期快照 ✔(该数据即由 §9 的 Worker 物化产生) + +--- + +## 14. 排名回归 + +- `GET /jobs/47/competition-ranking` → 200;`rank=1`、`cohortSize=2`、`overallScore=65.2`、`percentile=100.0`、`asOfPeriod=2026-09` +- 验收学生(8002)与对照学生(8003)**各有 8 维快照**,排名**未因 GET 停写而缺失候选人** ✔ +- 快照经真实物化入口产生,未直接插入伪造 ✔ +- 只读 ✔ + +--- + +## 15. 企业看板回归 + +| 接口 | 结果 | +|---|---| +| `GET /company/scoring/dashboard/overview` | 200;`companyId=27, totalJobs=1, publishedJobs=1, totalCandidates=2, avgCandidateScore=61.7` | +| `GET .../job-rankings?page=0&size=20` | 200;含验收岗位 47 | +| `GET .../candidate-rankings?page=0&size=20` | 200;`applicationId=86 → overallScore=65.2, percentile=100.0`;`applicationId=87` 亦在列 | +| `GET .../trend?months=6` | 200;`2026-09, avgCandidateScore=61.7, candidateCount=2` | + +- 数据来自物化快照 ✔;多次 GET **零写** ✔ +- 越权负例:企业 B 访问企业 A 的投递评分 → **403** ✔ + +--- + +## 16. 申诉与强制 version 回归 + +- 学生创建隔离申诉 → 200;`appealId=4289`;`dimension=EDUCATION`、`period=2026-09`、`status=PENDING` +- **`scoreAtAppeal=76.00`、`snapshotVersionAtAppeal=1` 均非空** ✔ —— 关键:D4-2 停写后申诉取证仍能取到快照,因为写侧登记 + Worker 已先行物化 +- `GET /profile/me/candidate-scoring/appeals`(我的列表)与 `/appeals/4289`(详情)均 200 ✔ +- `GET /admin/candidate-scoring/appeals` → 200,`total=8`(历史 7 条 + 本轮 1 条)✔ +- 申诉副作用核对:相对上一快照仅 `candidate_scoring_appeals +1`、`candidate_scoring_appeal_history +1`、`audit_logs +1`,**其余表零变化**(含 `candidate_dimension_scores`、`notifications`、`messages`、`snapshot_history`、`refresh_tasks`)✔ +- **未执行**审核/回滚(避免改动非本轮治理数据),因此「rollback 后强制 version 恰好 +1」本项**未做黑盒**,改由 `CandidateScoringCalculatorTest#recomputeAndSnapshot_*` + `CandidateScoringGovernancePersistenceIntegrationTest`(第二批 253/253 内)覆盖,静态确认 `CandidateScoringSnapshotWriter.java:75-88` 无条件 `version=version+1`。见 §23 SKIP。 +- 普通 Worker 物化不写 `snapshot_history` ✔(黑盒 4 → 4) + +--- + +## 17. 公平性监控回归 + +以 ADMIN(复用既有活跃管理员 userId 91,**未恢复 V1_1 旧演示账号 id=1**)调用: + +| 接口 | 结果 | +|---|---| +| `GET /admin/scoring/fairness/overview?period=2026-09` | 200,`basis=CURRENT_STORED_SNAPSHOTS`、`scope=SCORED_SNAPSHOT_POPULATION` | +| `.../dimensions?period=2026-09` | 200 | +| `.../drift` | 200,`availability=UNAVAILABLE, reason=IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE`(既有抑制语义保留) | +| `.../ranking-stability` | 200,`availability=UNAVAILABLE, reason=RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE`(既有抑制语义保留) | +| `.../appeals` | 200,`basis=APPEAL_CREATION_QUEUE_CURRENT_STATE` | + +- 小样本抑制与互补风险抑制保持(以上 `UNAVAILABLE` 即为抑制输出)✔ +- 不泄露候选人身份(响应扫描无 `subjectUserId`/手机号/邮箱)✔ +- 当前快照聚合可用 ✔;GET **零写** ✔ + +--- + +## 18. 数据库副作用 + +**允许的变化(全部落入任务书允许范围)**: + +1. 本轮唯一前缀夹具(创建后已按主键清理,见 §22) +2. 写侧业务请求按契约产生的行(`student_profiles`、`student_preferences`、`applications`、`jobs`、`companies`、`notifications`) +3. **刷新任务与候选人快照**:`candidate_scoring_refresh_tasks` 共 181 行(其中 4 行为本轮学生,其余为补偿扫描按设计登记的其他活跃学生),`candidate_dimension_scores` 相应物化行 +4. `candidate_scoring_refresh_scan_checkpoints` 1 行(游标) +5. 本轮申诉 1 条 + 申诉历史 1 条 + 相应审计 1 条 + +**明确未发生**: + +- 非本轮用户的业务数据(`users`/`companies`/`jobs`/`applications` 等)未被删除或改写 —— 唯一受影响的是其**候选人快照**,且这是启用生产后台补偿扫描后的**设计内正常行为**(任务书明确允许「候选人快照」变化) +- 评分规则、权重、规则版本、排名规则未变 +- `candidate_scoring_snapshot_history` 在**普通 Worker 场景下为零变化**(4 → 4)✔ —— 该表的 4 行历史数据全程未被普通物化触碰 +- 既有历史快照未被改写(version 语义为「变化才 +1」) +- 非本轮审计未变 + +--- + +## 19. RabbitMQ 前后状态 + +队列(vhost `/`)在**验收前**与**验收后**分别查询: + +| 队列 | 验收前 ready/unacked | 验收后 ready/unacked | +|---|---|---| +| `queue.sms.verification` | 0 / 0 | 0 / 0 | +| `queue.notification` | 0 / 0 | 0 / 0 | +| `queue.notification.dlq` | 0 / 0 | 0 / 0 | +| `queue.audit` | 0 / 0 | 0 / 0 | + +4 个队列**真实存在**,终态 ready=0、unacked=0,**D4-2 的 GET、任务登记、Worker、补偿扫描全程未新增任何消息** ✔ + +代码层佐证:`candidatescore/refresh/` 全包对 `rabbit|Rabbit` 的匹配仅出现在 **3 处 javadoc 注释**(「不发送 RabbitMQ」),无任何 `RabbitTemplate`/`convertAndSend`/`NotificationService`/`AuditService` 引用 ✔ + +--- + +## 20. 静态与运行时 OpenAPI + +| 项 | 静态 `docs/openapi-rc.json` | 运行时 `/api/v1/v3/api-docs` | +|---|---|---| +| paths | **176** | **176** | +| operations | **214** | **214** | +| method+path 集合 | — | **完全一致**(`only-runtime=[]`, `only-static=[]`) | + +- `python docs\verify_openapi.py` → `All checks passed!`(含 V36/V37/V39–V43/N02/N07/S02/S03 各项契约校验) +- D4-2 **未新增**公网刷新、任务、补偿、管理接口 ✔ +- 四个评分 GET 响应 schema 不变(S1 实测响应字段集合与既有契约一致)✔ + +--- + +## 21. 日志脱敏 + +扫描对象:本轮 6 个 JAR 日志/err 文件 + 2 个 Maven 测试日志,共 8 个文件。 + +**探针有效性**:临时文件注入 `Authorization: Bearer `、11 位手机号、「验证码 482913」、`DB_PASSWORD=…`,扫描器命中 **5 类** → **[PASS] 扫描器有效**;探针文件已删除 ✔ + +**真实命中与甄别**: + +| 类别 | 命中 | 甄别结论 | +|---|---|---| +| 「口令赋值」 | 63 | **全部为同一模式**:Spring Boot `UserDetailsServiceAutoConfiguration` 启动时打印的 `Using generated security password: `。经字符集核验为大小写十六进制 + 连字符的 UUID,**非数据库口令、非 JWT 密钥、非项目任何凭据**,且项目采用 JWT 认证不使用该内存用户。判定为**误报**。 | +| 11 位手机号 | 1 | 位于 `clean-verify.log`,上下文为既有测试 `AdminBootstrapConcurrencyLoggingTest` 的 **`S03_SCANNER_PROBE_<值>` 扫描器探针**(测试夹具),非真实用户号码 | +| Bearer Token / JWT 形态串 / 验证码语义行 / 密钥赋值 / 私钥块 / 简历存储路径 | **0** | ✔ | + +**应用运行日志(`r01d42-*.log`)中手机号、Token、验证码、JWT、简历路径命中均为 0** ✔ + +报告与本文件均未复制任何真实凭据值。 + +--- + +## 22. 结束处理 + +**进程**:只停止本轮明确记录 PID 的实例(24624/22640/12656/23436/28456/6576),未触碰任何未知 Java 进程。验收后 `Get-Process java` 无输出,8080/8081 端口已释放,恢复至验收前空闲状态。**Docker 未停止**(按任务书要求)。 + +**数据清理**(仅本轮精确主键,无模糊 DELETE): + +| 表 | 删除前 | 删除后 | +|---|---|---| +| `users`(8002/8003/8004/8005) | 4 | **0** ✔ | +| `companies`(27/28) | 2 | **0** ✔ | +| `jobs`(47) | 1 | **0** ✔ | +| `applications`(86/87)+ `application_status_history` | 2 + 2 | **0** ✔ | +| `candidate_dimension_scores` | 32 | **0** ✔ | +| `candidate_scoring_refresh_tasks` | 4 | **0** ✔ | +| `candidate_scoring_appeals` / `appeal_history` | 1 / 1 | **0** ✔ | +| `student_profiles` / `student_preferences` | 2 / 1 | **0** ✔ | +| `notifications` | 2 | **0** ✔ | +| `auth_tokens` / `user_roles` / `sms_verification_codes` | 6 / 6 / 6 | **0** ✔ | + +清理前做了**归属校验**:4 个用户 id 对应的手机号必须全部等于本轮登记的隔离虚拟号(`phone IN (4 个号码)` 与 `id IN (…)` 双向匹配数均为 4,完全一致),否则脚本 `[ABORT]` 拒绝删除。删除按外键顺序执行(两轮:首轮被 `application_status_history` 阻断后补删)。 + +**保留并登记**:`audit_logs` 中 actor 为本轮 4 个用户的 **15 行** 属 append-only 合规记录,按任务书要求**不冒险删除**,保留在库中。关联 ID:`user_id ∈ {8002, 8003, 8004, 8005}`。 + +**唯一允许的测试夹具(精确 INSERT,已登记)**:`user_roles` 插入 `(8004,'COMPANY')`、`(8005,'COMPANY')` 各 1 行 —— 原因:项目**无任何公网接口**可授予 COMPANY 角色(`CompanyService.java:76 assertCompanyRole` 前置校验,角色仅由管理员初始化产生),企业创建因此返回 403。该 2 行已在 §22 清理中随 `user_roles WHERE user_id IN (…)` 一并删除。 + +**Git 收尾**:分支 `refactor/r01-structure-performance`;暂存区空;未 `git add -A`;未 commit;未 push;V1–V43 未修改;V44 仍为唯一新增迁移;无删除文件;`git diff --check` 无空白错误。未进入下一项 R01 工作。 + +--- + +## 23. PASS / FAIL / SKIP 清单 + +### PASS + +1. Git 基线(分支/HEAD/暂存区/V1–V43/V44/无删除/无空白错误) +2. A1 三个 GET 真正只读(代码 + 真实 HTTP 双证) +3. A3 唯一 Writer 语义(幂等 version / canonical JSON / 并发锁 / 强制版本 / 不写 history / 整体回滚) +4. A5 补偿扫描(持久游标 / 单轮预算 / 尾部回绕 / 同事务 / 不写快照) +5. A6 配置无断档(生产默认启用,测试 profile 显式关闭) +6. Docker/MySQL 8.4.11/Redis/RabbitMQ 全部 healthy +7. V44 在真实 MySQL 成功(version=44, success=1, 唯一, failed=0) +8. `ddl-auto=validate` 通过,无 schema/SQL/Bean 装配错误 +9. 最新 JAR 正常启动;健康检查 8080/8081 均 200 +10. `mvn clean verify` **2131/2131**,F=0 E=0 **S=0** +11. 定向批次一 55/55、批次二 253/253(MySQL Testcontainers 真实执行) +12. **三个目标 GET 真实零写**(22 张表逐字段不变;学生快照保持 0 行) +13. 写侧事件 → 任务 → Worker → 八维快照全链路(W1/W2/W3) +14. 重复登记合并 + 完全相同输入重放 version 不推进 +15. 普通 Worker 不写 `snapshot_history`、不写通知/审计/RabbitMQ +16. 补偿游标跨重启延续、单轮严格 ≤ limit、尾部回绕 +17. 趋势回归(可读物化快照、稳定、零写) +18. 排名回归(cohortSize=2、双学生均有快照) +19. 企业看板回归(4 端点在列、越权 403) +20. 申诉回归(取证字段非空、副作用限于契约表) +21. 公平性监控回归(5 端点、抑制语义保留、零写) +22. RabbitMQ 终态 4 队列 ready=0/unacked=0 +23. 静态与运行时 OpenAPI 均 176/214 且集合完全一致 +24. `git diff --check`、OpenAPI 静态校验、日志脱敏(探针有效,应用日志 0 泄露) +25. 隔离数据精确清理,无残留 + +### FAIL(4 项) + +| # | 缺陷 | 位置 | 命中判据 | +|---|---|---|---| +| F1 | **Offer 自动过期改变评分输入但不登记刷新任务** | `offer/OfferExpiryWorker.java:48`(登记计数=0);评分读取 `candidatescore/CandidateScoringCalculator.java:188-194` | 「写侧成功但任务未登记」 | +| F2 | **候选人反馈的修改/删除改变评分输入但不登记刷新任务** | `feedback/CandidateFeedbackService.java:107`、`:124`(仅 `:82` 创建时登记);评分读取 `CandidateScoringCalculator.java:196-200` | 「写侧成功但任务未登记」 | +| F3 | **`createVersion` 登记与独立提交的版本行事务边界不对称** | `resume/ResumeVersionService.java:71`(外层事务)vs 同文件 `:52-55` 自述的 `REQUIRES_NEW` 独立提交 | 「源事务失败却留下任务」的对偶形态:数据已提交而登记丢失 | +| F4 | **测试断言弱化(红线:禁止修改或放宽既有测试)** | `test/.../candidatescore/CandidateScoringCalculatorTest.java:248` 起,ABILITY 溯源 payload 的敏感字段黑名单断言(`answerKey/questionCode/competencyTag/stem/selectedOptionId/scoringRule/awardedScore/explanation/phone/email` 共 10 项)被删除,**无等价替代** | 「修改或放宽既有测试」 | + +### FAIL(次生,A4,未单独计入但需一并整改) + +| # | 缺陷 | 位置 | +|---|---|---| +| F5 | `materialize` 的「回到 PENDING」分支缺 version 闸门,租约竞争下会把新 Worker 处理中的任务改回 PENDING | `refresh/CandidateScoringRefreshTaskExecutor.java:78-88` | +| F6 | `recordFailure` 在 `updated != 1` 时抛异常,且异常点位于 Worker 的 catch 块内、`processDue` 的 for 循环无保护 → **同批剩余任务本轮全部不处理** | `refresh/CandidateScoringRefreshWorker.java:83` + `:63-67` + `CandidateScoringRefreshTaskExecutor.java:105-107` | + +### SKIP(2 项,均非核心判据) + +1. **多实例并发补偿扫描双 JAR 黑盒**:以代码层 `SELECT ... FOR UPDATE` 串行 + 游标与登记同事务作为证据;未启动第二个 JAR 做真实双实例竞争。 +2. **申诉审核/回滚黑盒**:为避免改动非本轮治理数据(`candidate_scoring_rollback_limits` 等)未执行 rollback,改由 `CandidateScoringCalculatorTest#recomputeAndSnapshot_*` 与 `CandidateScoringGovernancePersistenceIntegrationTest` 覆盖(第二批 253/253 内),静态确认强制 version 路径。 + +### BLOCKED + +无。 + +--- + +## 24. 剩余风险 + +1. **F1/F2 的实际影响受补偿扫描部分缓解**:补偿扫描的资格条件为「近 90 天有投递 **或** 近 30 天有审计」(`CandidateScoringRefreshCompensationScanner.java:89-99`)。有 Offer / 有反馈的学生必有投递,故其快照通常在下一轮扫描(默认间隔 1 小时)内被纠正。但若该学生**近 90 天无投递、近 30 天无审计**,则其快照将**永久停留在过期值**,无任何兜底路径。 +2. **F3 触发概率低但后果不可逆**:仅在事务提交阶段失败时发生,届时业务数据存在而刷新信号永久丢失,且无对账机制可发现。 +3. **F5/F6 触发前提为物化耗时超过 `processing-timeout-ms`(默认 600s)**,正常物化实测 0.7s,概率低;一旦触发,本轮同批任务的其余条目会被静默跳过(下一轮会重试,故不会永久丢失)。 +4. **补偿扫描会物化全库活跃学生的快照**:启用生产后台刷新后,单实例每轮最多 `compensation-limit` 个。本次验收实测 181 条任务、全部 COMPLETED,未观察到异常增长;但该行为对生产库的持续写入量需在容量规划中评估。 +5. **`CandidateScoringRefreshInfrastructureTest` 未验证 8 个写侧 Service 真的调用了 `requestRefresh`**:现有测试直接驱动 `requestService`/`taskExecutor`,各 Service 单测只补了 `@Mock` 依赖而无一处 `verify(...).requestRefresh(...)`。F1/F2 正是这类覆盖盲区的产物,建议本轮整改时一并补齐。 +6. **注释与现实不符(低危)**:`CandidateScoringCalculator.java:582`(「含 GET 兜底」)、`CandidateScoringRefreshScheduler.java:15`、D4-1 报告 §20 的「默认关闭」表述均已过时。 + +--- + +## 25. 是否允许冻结 D4-2 + +**不允许冻结。** + +R01-D4-2 的**核心目标**(三个评分 GET 停写)已在真实 MySQL/HTTP 下确凿达成,且未观察到任何消费端退化或回归。但依据任务书第二十节的 FAIL 判据,F1/F2 命中「**写侧成功但任务未登记**」,F4 触碰「禁止修改或放宽既有测试」红线,因此本轮不可冻结。 + +--- + +## 26. 下一轮定向整改指令 + +### 必做(阻断冻结) + +**R1. 补齐 Offer 自动过期的评分刷新登记** +- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java` +- 方法:`expireOne(Long offerId)`(`:41`),在 `:48 setStatus(EXPIRED)` 成功且 `:51 saveAndFlush` 之后、与该方法**同一 `REQUIRES_NEW` 事务内**,追加: + `scoringRefreshRequestService.requestRefresh(offer.getCandidateId(), LocalDateTime.now(clock))` +- 注入 `CandidateScoringRefreshRequestService`;`OfferExpiryWorker` 已使用 `clock`,`capturedNow` 应复用同一时间源 +- 不得因此改动 `OfferService#transitionStatus` 的既有语义 +- 补测试:真实驱动 `expireOne` 后断言 `candidate_scoring_refresh_tasks` 出现/合并一条 `(candidateId, 当期)` 任务 + +**R2. 补齐候选人反馈修改/删除的评分刷新登记** +- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java` +- 方法:`updateFeedback(…)`(`:107`)与 `deleteFeedback(…)`(`:124`),在各自成功路径末尾、同一 `@Transactional` 内追加 `requestRefresh(app.getStudentUserId(), LocalDateTime.now())`(`deleteFeedback` 注意在删除前先取得 `studentUserId`) +- 与既有 `:82 createFeedback` 保持完全一致的调用形态 +- 补测试:`updateFeedback` 改变 `rating` 后断言任务被登记/合并 + +**R3. 修正 `createVersion` 的登记事务边界** +- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java` +- 问题:`:71` 的登记位于外层事务,而版本行经 `versionWriter.saveNewVersion`(`REQUIRES_NEW`)**独立提交**;外层提交阶段失败会造成「数据已提交、登记丢失」 +- 要求(二选一,须在报告中说明选择理由): + (a) 将 `requestRefresh` 增补为 `Propagation.REQUIRES_NEW` 的独立调用(放在版本行成功返回之后);或 + (b) 在 `createVersion` 返回后由调用方在**已提交**的上下文中登记(`TransactionSynchronization#afterCommit`) +- 补测试:模拟外层事务回滚,断言版本行存在时**必定**存在对应刷新任务 + +**R4. 修正 `materialize` 的 PENDING 回退闸门** +- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java` +- 位置:`:78-88` +- 要求:`status='PENDING'` 的回退 `UPDATE` 增加 `AND version=?`(与 claim 时记录的 `claimedVersion` 比对);`updated != 1` 时应**静默返回**(该任务已被 `recoverStale` 或新 Worker 接管,属正常竞争),不得抛 `IllegalStateException` + +**R5. 隔离单任务失败对同批任务的影响** +- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java` +- 位置:`:63-67`(`processDue` 循环)与 `:83`(catch 内 `recordFailure`) +- 要求:`processDue` 的 for 循环体加 `try/catch`,或让 `recordFailure` 自身吞掉状态冲突异常并仅告警 —— 保证单任务异常不阻断同批其余任务,与 `:24` javadoc 的承诺一致 + +**R6. 恢复被删除的敏感字段断言(红线项)** +- 文件:`mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java` +- 位置:`:248` 起(原 `for (String banned : List.of("answerKey","questionCode","competencyTag","stem","selectedOptionId","scoringRule","awardedScore","explanation","phone","email"))` 断言) +- 要求:在**新的委托架构下重建等价断言** —— 序列化 `CandidateScoringSnapshotWriter` 为 ABILITY 维度生成的 `sub_scores_json`(可通过 `CandidateScoringRefreshInfrastructureTest` 走真实 Writer,或在 Writer 测试中直接断言),并逐项断言不含上述 10 个禁止字段 +- 不得以「类型安全」为由省略:`weights`/`dimensionScores` 为 `Map`,其 key 不受编译期保护 + +**R7. 补齐写侧登记的行为验证** +- 文件:`mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java` +- 要求:新增断言,对 §A2 的 8 个服务方法 + R1/R2 新接入的两个入口,逐一 `verify(scoringRefreshRequestService, times(1)).requestRefresh(…)`;覆盖**失败回滚不登记**的负例 + +### 建议(不阻断,但应同批处理) + +**R8.** 修正过期注释:`CandidateScoringCalculator.java:582`、`CandidateScoringRefreshScheduler.java:15`,以及 `docs/r01-d4-1-scoring-materialization-implementation-report.md` §13/§20 中「补偿扫描为简化版/默认关闭」的表述。 + +**R9.** 补 `CandidateScoringRefreshInfrastructureTest` 中缺失的黑盒项:多实例并发扫描(双实例 `scanDueStudents` 断言游标不重复推进)、跨重启游标延续的自动化用例(当前仅有本次验收的真实证据)。 + +**R10.** 评估 `getTrend` 的时钟一致性:`CandidateScoringEngineService.java:78` 使用 `LocalDate.now()` 而非注入的 `clock`(预存在问题,非本轮引入)。 + +### 整改后复验要求 + +完成 R1–R7 后,重新执行:`mvn clean verify`(须 ≥2131 且 S=0)、静态与运行时 OpenAPI 176/214、以及本报告 §7/§9/§16 三项真实 HTTP 复核(GET 零写、写侧链路、申诉取证)。R1/R2 需各自提供「事件 → 任务 → COMPLETED → 快照」的真实 MySQL 证据。 + +--- + +## 附:本轮验收产出物(`docs/acceptance/r01-d4-2-20260916/`) + +| 文件 | 说明 | +|---|---| +| `acc_lib.py` | HTTP/DB/登录/前缀公共库 | +| `snap.py` | 22 表显式列快照 + 差异比较 + 自检反证 | +| `p1-provision.py` / `acc-context.json` / `acc-prefix.txt` | 隔离验收数据创建 | +| `s1-zerowrite.py` / `snap-before-get.json` / `snap-after-get.json` | 三个 GET 零写 | +| `s2-writepath.py` | 写侧链路 | +| `s4-consumers.py` / `s4-out.txt` / `snap-after-consumers.json` | 消费者回归 | +| `logscan.py` | 日志脱敏扫描(含探针) | +| `cleanup.py` | 精确主键清理 | +| `start-app.ps1` + `r01d42-*.log/.err.log/.pid/.envdiag.txt` | 实例启动与运行日志 | +| `q01`–`q13*.sql` | 只读 SQL 证据 | +| `runtime-openapi.json` | 运行时 OpenAPI | +| `clean-verify.log` / `batch2.log` | 自动化构建日志 | + +> 本报告不含数据库密码、JWT、验证码、完整手机号或邮箱、`.env` 内容、简历原文或真实存储路径;SQL 片段中无真实敏感参数。验收脚本输出中曾一次性打印隔离虚拟号(`p1-provision.py` 首版缺陷),已当场修正脚本,后续输出与报告均不再包含完整号码。 diff --git a/docs/r01-d4-get-scoring-snapshot-migration-design.md b/docs/r01-d4-get-scoring-snapshot-migration-design.md new file mode 100644 index 0000000..4c3b9b7 --- /dev/null +++ b/docs/r01-d4-get-scoring-snapshot-migration-design.md @@ -0,0 +1,246 @@ +# R01-D4「GET 评分接口写快照迁移方案设计与证据核查」 + +日期:2026-09-16 +分支:`refactor/r01-structure-performance` +HEAD:`b56f792 perf: batch conversation unread counts` +本轮性质:**独立现状核查 + 权威迁移设计**(未实施生产改造) + +## 1. Git 基线 + +- 分支 `refactor/r01-structure-performance`;HEAD `b56f792`;暂存区为空。 +- 迁移目录无任何改动(V1–V43 原样);`AGENTS.md` 未发现。 +- 已跟踪工作区存在 1 个仅被 Git 工作树元数据标记为修改的文件(`GlobalExceptionHandler.java`);普通 `git diff` 为空且 blob 与 HEAD 相同。本轮不触碰、不暂存该文件,也不把它计入 R01-D4 交付。 +- 历史未跟踪文件全部保留;未 commit、push。 + +## 2. 当前三个 GET 写库调用链 + +| 端点 | 服务方法 | 调用链 | 事务 | 写入 | 修改字段 | +|---|---|---|---|---|---| +| `GET /profile/me/candidate-scoring` | `CandidateScoringEngineService#getOverview` | computeAll → `upsertSnapshots` → buildOverview | `@Transactional`(默认 rw) | `candidate_dimension_scores` | score、status、sub_scores_json、weight、rules_version、calculated_at、**version+1**、**updated_at=NOW()** | +| `GET /profile/me/candidate-scoring/dimensions/{dimension}` | `#getDimensionDetail` | computeAll → filter → `upsertSnapshots`(单维度) | `@Transactional`(rw) | 同上 | 同上(仅该维度行) | +| `GET /company/candidates/{applicationId}/scoring` | `#getCompanyScoring` | 授权(company/job 归属) → computeAll → `upsertSnapshots` | `@Transactional`(rw) | 同上 | 同上(目标学生 8 行) | +| `GET /profile/me/candidate-scoring/trend` | `#getTrend` | `snapshotRepository.findTrendFrom` 只读 | `@Transactional(readOnly = true)` | **无** | — | + +写库 SQL(`CandidateScoringCalculator#upsertSnapshots`)为原生 `INSERT ... VALUES (...) ON DUPLICATE KEY UPDATE score=VALUES(score), status=..., sub_scores_json=..., weight=..., rules_version=..., calculated_at=..., version=version+1, updated_at=NOW()`。唯一键 `uk_candidate_dim_scores(student_user_id, dimension, period)`(V24 建表、实体 `@UniqueConstraint` 双重声明)。**触发条件为"存在唯一键冲突"即无条件版本自增——即使计算值与上次相同,重复 GET 仍使 version 单调递增、updated_at 改写。** + +## 3. 全部快照写入方 + +| 写入方 | 入口 | 触发 | 是否用户可重复触发 | +|---|---|---|---| +| `CandidateScoringCalculator#upsertSnapshots`(被下三处调用) | — | — | — | +| —— `CandidateScoringEngineService#getOverview` | `GET /profile/me/candidate-scoring` | **GET 读取请求** | 是(任何学生重复刷新) | +| —— `CandidateScoringEngineService#getDimensionDetail` | `GET .../dimensions/{dimension}` | **GET 读取请求** | 是 | +| —— `CandidateScoringEngineService#getCompanyScoring` | `GET /company/candidates/{id}/scoring` | **GET 读取请求** | 是(企业每次查看) | +| `CandidateScoringCalculator#recomputeAndSnapshot` | 见下 | — | — | +| —— `CandidateScoringGovernanceService#decideAppeal`(APPROVE 分支) | `POST /admin/candidate-scoring/appeals/{id}/decision` | 申诉决定 | 否(终态幂等) | +| —— `CandidateScoringGovernanceService#rollback` | `POST .../appeals/{id}/rollback` | 申诉回滚 | 否(回滚限额+终态) | +| ——(趋势/排名/看板不写) | — | — | — | + +## 4. 全部快照消费者 + +| 消费者 | 读取方式 | 依赖字段 | 影响 | +|---|---|---|---| +| `getTrend`(趋势) | `findTrendFrom(student, startPeriod)` | period/score/status/rulesVersion | 只读物化快照;**完全依赖快照** | +| `CandidateScoringGovernanceService#createAppeal` | `findSnapshot` | score(scoreAtAppeal)、**version(snapshotVersionAtAppeal)** | 申诉证据基于快照版本 | +| `decideAppeal/rollback` | `recomputeAndSnapshot` + `snapshotHistoryRepository.save` | before/after 分数与版本 | 申诉决定/回滚重算写新版本 | +| `CandidateScoringSnapshotHistory`(V25 表) | 仅由申诉决定/回滚写入,`getSnapshotHistory` 读取 | version/rulesVersion | 变更审计 | +| `CompetitionRankingCalculator.loadLatestSnapshots` | `findUpToPeriod(students, asOf)` | 最新 period 的 8 维分数 | 竞岗排名用**物化快照**(非实时计算) | +| `CompanyScoringDashboardService` | 读取快照聚合 | score/status | 企业看板/百分位(r01 优化为 CandidatePercentileCalculator) | +| `CareerProgressService` | 读取快照 | 各期分数 | 求职进度 | +| `FairnessMonitoringRepository` | 原生 SQL 按 period 聚合 candidate_dimension_scores + snapshot_history+appeal | score/status/样本数 | 公平性监控(当前快照 + 历史申诉双源) | +| `PersonalDataCollector` | `findByStudentUserId` | 全字段 | GDPR 数据导出 | + +## 5. 真实/集成副作用证据 + +### 5.1 集成证据(H2,本轮新增独立现状证据测试,见 §17) +`R01D4GetWritesSnapshotEvidenceTest` 结果 **5/5 全绿**(未依赖 Docker): +- 首调 `getOverview`:`candidate_dimension_scores` 新增 **8 行**;重复调用行数保持 8、**version 全部 +1**; +- 单维度 GET:行数不变、version 继续递增; +- trend GET:前后行数/version 完全一致(只读确认); +- 非法 dimension:校验先于 upsert,**零写入**; +- GET 评分不写 `audit_logs`、`notifications`。 + +证据边界:该类直接调用 Service,并非真实 HTTP/SecurityFilterChain 测试;它证明 overview、dimension、trend 的持久化行为,但**没有覆盖企业评分 GET、无权限、资源不存在、HTTP 响应或 RabbitMQ**。这些项目不得依据该 5/5 结果标为已验证,必须在实施验收中补齐。 + +### 5.2 真实 MySQL 证据 +**BLOCKED**:Docker Engine 本轮不可用(`docker version` 无法连接 Engine),按任务书纪律**不伪造通过**。已在 5.1 提供同级 H2 集成证据;真实 MySQL 行级快照(首/二次 GET 的行数、version、updated_at、返回 JSON 是否变化、错误路径零写、audit/notification/RabbitMQ 无副作用)由 D4-2 阶段实施前用真实 MySQL 补充复验(§12 验收清单已含)。 + +静态代码可直接确认的行为(无需运行):`upsertSnapshots` 的 `ON DUPLICATE KEY UPDATE ... version=version+1, updated_at=NOW()` 保证重复 GET 必使 version 单调递增、updated_at 刷新;趋势/申诉/监控/看板/排名全部消费该物化快照。 + +## 6. 首次与重复 GET 的行数/version/updated_at + +| 场景 | 行数 | version | updated_at | 返回 JSON | +|---|---|---|---|---| +| 首次 GET overview | 0 → 8 | 0(插入初值) | 写入 NOW() | 完整评分卡 | +| 第二次相同 GET | 8(不变) | **+1(8 行全部)** | **改写为新 NOW()** | 与首次等值(公式确定) | +| 单维度 GET(已有 8 行) | 8(不变) | 该维度行 +1 | 该行改写 | 明细不变 | +| trend GET | 不变 | 不变 | 不变 | 由物化快照导出 | +| 非法 dimension | 不变 | 不变 | 不变 | Service 抛业务异常(H2 已验证) | +| 无权限 / 资源不存在 | 静态代码预期不变 | 静态代码预期不变 | 静态代码预期不变 | **本轮未做真实 HTTP 证据** | + +结论:**"读取放大写入"确凿**——GET 对每次读取强制物化并推进版本,自增 version 不代表业务状态迁移,period 快照语义是"每次读取覆盖值"而非"首次值/最新值"。 + +## 7. trend 的现有依赖 + +`getTrend` 只读 `candidate_dimension_scores`(`findTrendFrom`,period>=start 升序)。它**不**调用 computeAll/upsert,因此**完全依赖**由其他路径(当前主要是三个 GET + 申诉重算)持续写入的快照。若直接删除 GET upsert 而未建立写侧物化,趋势将冻结在最后一次写入时刻。 + +## 8. 申诉、回滚与公平性监控依赖 + +- 申诉创建:`findSnapshot(student, dimension, period)` 读取当前行,固化 `scoreAtAppeal` 与 `snapshotVersionAtAppeal`(写入 `candidate_scoring_appeals`)。若快照过期,申诉取证基于旧版本。 +- 申诉决定(APPROVE):`recomputeAndSnapshot` 重算写新版本 + 写入 `candidate_scoring_snapshot_history`(before/after score、version、rules_version)。 +- 回滚:同决定路径,另扣 `candidate_scoring_rollback_limits`。 +- 公平性监控:`FairnessMonitoringRepository` 两条原生聚合:`candidate_dimension_scores WHERE period=?` 按学生分组(overview/dimensions 用),以及 `candidate_scoring_snapshot_history JOIN appeal`(appeals 用)——**当前快照 + 历史申诉双源**。 +- 申诉期间快照若被外部 GET 再次覆盖(version 再 +1),`snapshotVersionAtAppeal` 不再等于当前版本——审计可辨,但"申诉取证与当前快照差 version"是现状语义的一部分,迁移时不得破坏。 + +## 9. 写侧事件矩阵(按计算器真实数据源) + +| 评分输入变化 | 计算器读取证据 | 推荐刷新入口 | 处理方式 | 备注 | +|---|---|---|---|---|---| +| 学生资料更新 | `StudentProfileRepository`:学历、学校、技能及资料完整度 | `ProfileService#updateProfile` 成功提交后 | 标记学生快照为待刷新 | 仅实际影响字段变化时触发 | +| 多版本简历变化 | `resume_versions` ACTIVE 数量与 MAX(updated_at) | `ResumeVersionService#createVersion/updateVersion/deleteVersion` 成功提交后 | 标记待刷新 | `setDefault` 是否影响评分需按 SQL 判断;当前 SQL 不读取默认标记,不应无条件触发 | +| 求职偏好更新 | `StudentPreferenceRepository` | `PreferenceService#savePreferences` 成功提交后 | 标记待刷新 | 影响 COMPLIANCE | +| 能力测评结果完成 | `AbilityAssessmentScoreSource#findLatestValidCompletedResult` | `AbilityAttemptService#submit` 成功提交后 | 标记待刷新 | `saveAnswers` 尚未形成完成结果,不应刷新 | +| 投递创建/撤回 | applications 数量、WITHDRAWN 数、近 90 天数量 | `ApplicationService#apply/withdraw` 成功提交后 | 标记待刷新 | 企业侧状态更新只有在实际改变计算器读取字段时才触发;当前聚合只区分 WITHDRAWN | +| 登录及审计活动 | audit_logs 近 30 天数量与 LOGIN 数 | 统一审计写入成功后或补偿扫描 | 优先脏标记/补偿 | 入口很多,不能逐 Controller 机械接入 | +| Offer 状态变化 | offers 中 ACCEPTED/EXPIRED 数量 | Offer 状态机成功提交后 | 标记待刷新 | 原报告遗漏 | +| 候选人反馈变化 | candidate_feedback 数量与 AVG(rating) | 候选人反馈创建/修改的真实写入口 | 标记待刷新 | 不是 `JobReviewService`;原报告混淆了两类评价 | +| 学生发送消息 | messages.sender_user_id 数量 | 消息发送成功提交后 | 标记待刷新 | 撤回不改变总消息数,按现公式无需刷新 | +| 账户创建时间 | users.created_at | 无日常写入口 | 初次物化/补偿扫描 | 时间本身不变 | +| 申诉决定/回滚 | 已有 `GovernanceService#decideAppeal/rollback` | 保持现有合法写侧 | 同事务 | 不与普通刷新事件混用历史审计语义 | +| rulesVersion 或时间窗口跨界 | rulesVersion、90/30 日窗口、能力结果有效期 | 发布批次及定时补偿扫描 | 批处理 | 即使没有业务写事件,分数也会随时间变化,因此仅靠事件触发不完备 | + +明确排除:当前 `computeAll` **不读取 RIASEC 结果、面试评分或岗位评价**,不得为这三类入口增加刷新;传统 `ProfileService#uploadResume` 的文件字段也不是 `queryResume` 的数据源,除非同时改变资料完整度实际依赖字段,否则不应触发。 + +现状结论:除申诉决定/回滚外,真实输入源缺少统一物化机制;同时存在时间窗口自然变化,因此“把若干 Service 同步调用 refresh”仍不能证明覆盖 100%。 + +## 10. 三个迁移选项比较 + +| 维度 | 方案 A:同步写侧触发 | 方案 B:事务提交后异步刷新 | 方案 C:脏标记+定时物化 | +|---|---|---|---| +| 一致性 | 强(同一事务) | 最终一致(短窗口) | 最终一致(更长窗口) | +| 事务耦合 | 高(评分写并进业务事务) | 中(分离;核心事务不受失败连累) | 低 | +| 失败恢复 | 回滚即重来,无独立补偿 | 需重试/死信 + 补偿扫描 | 靠定时器重扫,天然补偿 | +| 幂等性 | 唯一键天然幂等(version 语义需调整) | 需幂等键 + 去重 | 需幂等键 + 去重 | +| 并发 | 数据库条件更新兜底 | 需"旧事件不得覆盖新事件"防护 | 需锁/版本比对 | +| 实施范围 | 小(线程内调用)但侵入全部写方法 | 中(需要可靠发布/订阅或 MQ) | 中(脏标记 + 调度器) | +| 对现有 RabbitMQ 依赖 | 无 | **强依赖**(需事务后发布、重试、去重、死信) | 无(可走内置调度) | +| 对趋势/申诉/监控影响 | 不变 | 短窗口内趋势滞后,申诉取证可能取旧版本 | 更明显滞后 | +| 发布/回滚难度 | 低 | 中(MQ 基建变化) | 中(调度器+补偿) | +| 是否需要 Flyway | 否 | 否(建议无) | 否(可选脏标记列则需迁移) | +| 是否新增公网接口 | 否 | 否 | 否 | + +## 11. 唯一推荐方案 + +**分两阶段,先收敛写入口并建立可靠的“待刷新”机制,后让 GET 停写。唯一推荐为方案 C(持久化脏标记/刷新任务 + 定时物化),写侧提交后只登记待刷新任务;物化 Worker 复用唯一计算器和快照 Writer。** + +理由: +1. 不能把评分刷新放入资料、投递、消息等核心业务事务;评分失败不应回滚这些业务,原报告在“同事务”和“失败不影响业务”之间自相矛盾。 +2. 当前 RabbitMQ 没有已验证的事务消息、持久去重和死信恢复链路,不能直接依赖纯异步 MQ。 +3. 评分包含 90/30 日窗口与能力结果有效期,仅靠业务事件不完备,必须有定时补偿。 +4. 幂等键若不持久化就无法跨进程、重启和重试去重;因此原报告“无表结构变更但用 sourceEventKey 去重”不可实现。 +5. 持久任务允许业务事务只做一次轻量 upsert 标记,Worker 失败可重试,不阻塞核心业务;GET 兜底在迁移期保留。 + +## 12. 分阶段实施顺序 + +**D4-1(可靠物化基础设施,GET 兜底保留)** +1. 新增唯一 `CandidateScoringSnapshotWriter`,集中快照差异比较、条件更新和版本规则;申诉专用 history 语义仍由 Governance 管理。 +2. 新增持久化刷新任务/脏标记(需要新的 Flyway,不能再声称“无需迁移”)。唯一键至少覆盖 student + target period;任务记录 requested_at、attempts、next_attempt_at、processed_at/version,不保存敏感评分输入。 +3. §9 的真实输入写侧仅登记任务,不在线程内计算八维评分;时间窗口和 rulesVersion 由定时扫描登记任务。 +4. Worker 在独立事务中领取任务、计算并写快照;失败可重试,成功后原子完成;不得依赖 JVM 锁。 +5. 保持 GET upsert 作为迁移兜底,完成真实 MySQL 并发、重试和覆盖证据。 + +**D4-2(GET 停写)** +1. 三个 GET 改为 `@Transactional(readOnly = true)`,删除 upsert 调用(computeAll 保留用于实时计算响应字段,与物化无关)。 +2. 开启补偿扫描,对时间窗口、rulesVersion 变化以及遗漏任务进行修复;与持久任务幂等协作。 +3. 真实 MySQL 验收:GET 重复请求前后 `candidate_dimension_scores` 完整不变、无 audit/notification/RabbitMQ 副作用、趋势/申诉/监控/排名表现与 D4-1 一致。 +4. 达标后方可切换/废弃 GET 兜底。 + +**阶段切换网关(未满足不得进入 D4-2)**:真实数据源覆盖完整、相同任务重放不造成无效 version 增长、并发领取恰好一次有效物化、失败可重试且不影响原业务提交、时间窗口可由补偿扫描推进、GET 停写后的趋势/申诉/排名/监控真实 MySQL 回归通过。 + +## 13. 并发与幂等设计 + +- **任务幂等键**:持久任务按 `(studentUserId, targetPeriod)` 合并;多次输入变化只需保证至少一次最新状态重算。rulesVersion 是计算结果属性,不用虚构的内存 `sourceEventKey` 充当持久去重。 +- **快照唯一性**:继续使用 `uk_candidate_dim_scores(student_user_id, dimension, period)`。 +- **无效重放**:Writer 比较 score、status、sub_scores_json、weight、rules_version 等业务值;完全相同则不更新 version/updated_at。`calculated_at` 是计算时间,不能单独作为“业务变化”依据,否则每次重算仍会制造版本。 +- **并发/旧任务**:任务不是携带完整旧数据的事件,而是“请按数据库当前状态重算”的合并信号,因此晚到任务只会重新读取最新状态,不会用旧载荷覆盖新快照。 +- **rulesVersion 变化强制刷新**:`rules_version` 参与唯一比较,升级批次以新版本全量重算。 +- **两个输入变化并发**:数据库任务唯一键合并;Worker 使用条件领取/乐观锁或 `SELECT ... FOR UPDATE SKIP LOCKED`,禁止 JVM 本地锁;必要时完成后检测 requested_at 是否在处理期间推进,若推进则保留待处理状态再算一次。 +- **失败重试**:普通输入变化只登记持久任务,实际评分由 Worker 独立事务完成;失败更新重试状态并由补偿扫描兜底。重试不得重复写 `candidate_scoring_snapshot_history`/audit(该表只在申诉决定/回滚写,普通物化不写它)。 +- **申诉期间快照变化**:`scoreAtAppeal/snapshotVersionAtAppeal` 保留申诉创建时刻值;写侧刷新不得改写申诉取证字段;决定/回滚的重算仍走 `recomputeAndSnapshot`(其版本语义与写侧事件在 `calculated_at/rules_version` 条件上一致,回滚目标版本 = beforeVersion+1 的既有确定性逻辑需在 D4-2 复核不冲突)。 +- **回滚后的新输入事件**:允许再次计算——回滚后新事件刷新按正常路径写入新版本,`snapshot_history` 只记录申诉动作,不记录普通事件。 + +## 14. 失败恢复策略 + +- 写侧业务事务只 upsert 持久刷新任务;任务登记失败应随该业务事务回滚,避免提交了源数据却永远没有刷新信号。评分计算不在核心业务事务中执行。 +- Worker 独立事务执行,失败记录 attempts/next_attempt_at 并指数退避;超过阈值保留 FAILED/PENDING 状态供告警和补偿,不伪装成功。 +- 定时补偿根据活跃用户、时间窗口与 rulesVersion 登记任务,不直接绕过 Writer 写快照。 +- 最终一致性:允许短暂滞后(分钟级),趋势/监控为展示性质;申诉取证在写侧事件刷新后基于新版本,行为与现状一致(现状也是"最新一次写入者胜")。 + +## 15. 数据迁移与兼容策略 + +- `candidate_dimension_scores` 本身优先保持结构不变;D4-1 需要新增持久刷新任务表及唯一/领取索引,因此必须新增下一号 Flyway(不得修改 V1–V43)。具体表设计在实施前独立评审。 +- 现有存量数据:保留;D4-2 上线前对活跃学生做一次全量 `upsert` 对齐(事件覆盖前不删 GET 兜底,因此存量自然收敛)。 +- version 语义从"每次读取递增"收敛为"每次**有效变更**递增 + rulesVersion 升级递增";历史 version 不重写。 +- 趋势/申诉/监控/排名/导出读取代码零改动。 + +## 16. 下一轮(D4)精确修改文件清单(预估,实施前以代码现状复核) + +- 新增:`candidatescore/CandidateScoringSnapshotWriter.java`、刷新任务实体/Repository/Worker/调度器,以及下一号 Flyway(具体命名先核对迁移目录最大版本)。 +- 修改(仅写侧事件化,不改变业务语义): + - `profile/ProfileService.java`、`resume/ResumeVersionService.java`、`preference/PreferenceService.java` + - `ability/AbilityAttemptService.java`、`application/ApplicationService.java` + - Offer、候选人反馈、消息与审计的真实写入 Service(实施前精确定位) + - `candidatescore/CandidateScoringCalculator.java`(upsert 委托 + 条件 version 递增) +- **D4-2 才改**:`candidatescore/CandidateScoringEngineService.java`(三 GET 改为 readOnly 并移除 upsert 调用)。 +- 文档:`docs/backend-development-roadmap.md`(本报告结论)、`docs/candidate-scoring-api.md`(若契约文字涉及写语义)。 +- **允许且预计需要新增**一个 Flyway、刷新任务表及必要索引;仍不新增 ErrorCode 和公网接口。 + +## 17. D4 必须先写的失败测试(D4 实施轮的第一批测试,用于驱动实现) + +1. `GET 重复请求前后 candidate_dimension_scores 完整内容不变`(行数/score/status/version/created_at/updated_at 全等)。 +2. 三个 GET 不写 audit、notification、RabbitMQ(消息计数前置后置相等)。 +3. GET 返回 JSON 与基线一致(黄金 JSON 对比)。 +4. §9 每个真实输入源变化都会登记或合并一条待刷新任务;明确排除的非输入事件不得登记任务。 +5. 同一学生/period 的重复登记合并;完全相同的计算结果不增加快照 version。 +6. 并发登记后最终至少执行一次基于数据库最新状态的重算,任务不丢失且快照行不损坏。 +7. 刷新失败可重试、不写重复历史/审计、不影响原业务事务。 +8. rulesVersion 升级触发全量重算(version 递增)。 +9. 申诉创建取证字段(scoreAtAppeal/snapshotVersionAtAppeal)在写侧刷新后仍正确。 +10. 越权/非法 dimension/资源不存在零写入。 +11. 真实 MySQL 行级副作用快照 + RabbitMQ ready/unacked 归零。 +12. OpenAPI 176 paths / 214 operations 保持;不新增公网刷新接口。 +> 现有 `CandidateScoringPersistenceIntegrationTest#upsertSnapshots_shouldBeIdempotentAndIncrementVersion` 的断言(重复 upsert version+1)在 D4-2 后语义变为"仅有效变更 +1",届时按新契约更新该既有断言(属于既定契约变更的测试演进,非草率改断言)。 + +## 18. 风险与回滚方案 + +| 风险 | 影响 | 缓解/回滚 | +|---|---|---| +| 写侧事件覆盖不全 → 某学生快照停滞 | 趋势/排名/监控滞后 | D4-1 保留 GET 兜底期间逐步补覆盖;D4-2 依赖补偿扫描兜底;若需开关必须在实施中明确新增并测试,不能假定现有开关存在 | +| 申诉取证版本语义漂移 | 申诉对比失真 | 事件刷新不触碰取证字段;D4-2 前在真实 MySQL 对申诉全链路回归 | +| 异步刷新丢失 | 快照缺失 | 事务后发布 + 死信 + 补偿扫描(幂等键避免重复写) | +| version 语义变化被下游误读 | 审计/导出版本含义差异 | 文档化;规则升级仍强制递增 | +| 回滚难度 | — | D4-1 可停止任务登记和 Worker,但新增表由 Flyway 保留不用;D4-2 可恢复 GET upsert。不得回滚或删除已执行迁移 | + +## 19. 自动化实际结果(本轮) + +| 命令 | 结果 | +|---|---| +| `mvn -Dtest="R01D4GetWritesSnapshotEvidenceTest" test`(新增现状证据) | **5/5**,BUILD SUCCESS | +| `mvn -Dtest="CandidateScoringEngineServiceTest,CandidateScoringPersistenceIntegrationTest,*CandidateScoring*Test,*ScoringMonitoring*Test" test` | **134/134**,F=0 E=0 S=0,BUILD SUCCESS | +| `mvn clean verify` | **2109/2109**(≥2104 预期),Failures=0、Errors=0、**Skipped=20**,BUILD SUCCESS | +| `python docs\verify_openapi.py` | **176 paths / 214 operations,All checks passed** | + +Skipped=20 逐项说明:全部来自 Q01 既有的 `JobSearchQueryExplainMySqlIntegrationTest` 与 `JobSearchQuerySideEffectMySqlIntegrationTest`(`@Testcontainers(disabledWithoutDocker = true)`,分别 15/5 例)。Docker Engine 本轮不可用 → Testcontainers 显式约定跳过,为既有机制而非本轮核心核查,且非"把环境缺失伪装为通过";R01-D4 核心副作用证据由 H2 集成测试 5/5 提供,真实 MySQL 证据单独标注 BLOCKED(§5.2)。 + +## 20. Git 状态与边界声明 + +- 未修改任何生产代码;未修改既有测试断言;未修改 V1–V43;未新增 Flyway/表/索引/ErrorCode/公网接口。 +- 新增(工作区未提交):`docs/r01-d4-get-scoring-snapshot-migration-design.md`、`R01D4GetWritesSnapshotEvidenceTest.java`(现状证据测试,非契约断言)。 +- 未 commit、push;未进入 R01-D5。历史未跟踪文件未删除未移动;暂存区为空。 +- 真实 MySQL 副作用证据标注 **BLOCKED(Docker Engine 不可用)**,未伪装通过;H2 集成证据 5/5 已提供。 + +## 21. 最终结论 + +> **R01-D4 GET评分快照写入迁移方案设计完成,待负责人确认后分阶段实施。** + +阻塞/依赖声明:当前 GET 兜底写快照服务多个真实输入域与 6 类消费者(趋势/申诉/监控/排名/看板/导出),且评分会因时间窗口自然变化;因此严禁直接删除 GET upsert。最小可行迁移路径 = D4-1(唯一 Writer + 持久刷新任务 + Worker/补偿扫描 + GET 兜底)→ 真实 MySQL 验证并发、重试与数据源覆盖 → D4-2(GET 转 readOnly)。 diff --git a/docs/r01-e-batch-query-unread-count-performance-report.md b/docs/r01-e-batch-query-unread-count-performance-report.md new file mode 100644 index 0000000..d7a3833 --- /dev/null +++ b/docs/r01-e-batch-query-unread-count-performance-report.md @@ -0,0 +1,352 @@ +# R01-E 既有批量查询与会话未读数性能实现:验收与测试加固报告 + +日期:2026-09-18 +分支:`refactor/r01-structure-performance` +HEAD:`b893ed67f078eedb0c4b8a03341311bd79342354`(== upstream) +本轮性质:**验收与测试加固** —— **生产代码 diff = 0**,未暂存、未提交、未推送 + +--- + +## 0. 结论 + +> **R01-E既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。** + +**观察项(本轮不改变既有语义)**: + +> **OBS-E01**:当前**本人发送且没有 `message_reads` 记录**的消息也计入**本人**未读数, +> 待产品负责人单独确认;本轮不改变既有语义。 + +**库存前置结论**:E1 与 E2 在 HEAD 上**均已处于目标终态**(分别由 R01-D2、R01-D3 交付), +故本轮**未修改任何生产代码**,改为以**查询预算特征测试 + 跨库一致性 + EXPLAIN + 副作用快照**做验收加固。 + +--- + +## 1. PersonalDataCollector 修复前调用链(E1) + +**调用链**:`PersonalDataExportWorker.run` → `PersonalDataCollector.collect(userId)` → 12 个 section 构建方法 → `PersonalDataExportBuilder.build(...)` → 落盘。 + +**查询形态(逐处核对)**:全部为**批量**读取 —— `findByStudentUserIdOrderByCreatedAtDesc` / +`findByUserId` / `findAllById` / `...In...OrderBy...`,且批量历史查询 +(`ApplicationStatusHistory` / `InterviewStatusHistory` / `OfferStatusHistory` / `AppealHistory`) +均使用 `findByXxxIdInOrderByXxxIdAscCreatedAtAsc(...)`。 + +**循环内是否再查**:**否**。11 处 `forEach`(行 169/199/241/290/349/368/382/409/420/440/460/472) +只消费**预先批量取回**的数据与 `groupBy(...)` 结果做内存组装,循环体内**零 Repository 调用**。 + +**N 增长公式**:不受记录条数影响(见 §4)。 + +--- + +## 2. 会话未读数修复前调用链(E2) + +**调用链**:`MessagingController` → `MessagingService.listConversations(userId)`: +1. `memberRepository.findByUserId(userId)`(1 条) +2. 空则 `return List.of()` —— **不执行 `IN ()`** +3. `conversationRepository.findAllById(convIds)`(1 条) +4. `messageRepository.countUnreadByConversationIds(convIds, userId)`(**1 条分组查询**) +5. 内存排序(`updatedAt` 降序)后 `getOrDefault(cid, 0L)` 补 0 + +`countUnread(convId, userId)`(私有)**仅被 `getConversation`(单会话详情)调用**; +会话列表路径**不再逐会话 count**。 + +--- + +## 3. 修复前后查询次数公式 + +| 场景 | 修复前(R01-D2/D3 之前) | 当前(HEAD) | +|---|---|---| +| E1 导出 | `O(N)`:每类数据在循环内按 ID 再查 | **`O(1)` 常数**(每个数据类别固定 1 条批量查询) | +| E2 会话列表 | `O(K)`:每会话 1 次 `countUnread` | **`O(1)` 常数 3 条**(成员、会话、分组未读各 1 条) | + +--- + +## 4. 1 / 10 / 50 数据规模下 E1 SQL 数(实测) + +`R01EQueryBudgetAcceptanceTest.personalDataCollectQueryCountIsFlat`, +经 `CountingDataSource` 统计**真实 JDBC 语句**(`prepareStatement`/`createStatement`): + +| 投递记录数 | 实测 SQL 条数 | +|---|---| +| **1** | **20** | +| **10** | **20** | +| **50** | **20** | + +**恒定 20 条**,与记录数**完全无关**(每次 `mvn` 为全新 JVM + 全新 H2 库;断言为精确相等)。 + +--- + +## 5. 1 / 20 / 100 会话规模下 E2 SQL 数(实测) + +`R01EQueryBudgetAcceptanceTest.unreadQueryCountIsFlat`: + +| 会话数 | 实测 SQL 条数 | 每会话未读 | +|---|---|---| +| **1** | **3** | 1 | +| **20** | **3** | 1(逐会话校验全部为 1) | +| **100** | **3** | 1(逐会话校验全部为 1) | + +**恒定 3 条**,不随会话数增长;三种规模下**逐会话 `unreadCount` 值全部正确**。 + +--- + +## 6. 批量 Repository / 投影契约 + +| 组件 | 契约 | +|---|---| +| E1 | 既有 `...In...` 批量方法 + `groupBy(values, idExtractor)` 内存分组;`Map>` 强类型键 | +| E2 | `MessageRepository.countUnreadByConversationIds(List conversationIds, Long readerUserId)` → `List` 投影(`conversationId` / `unreadCount`);**原生查询、全参数化**,无字符串拼接用户可控 ID | + +--- + +## 7. 未读数 WHERE / GROUP BY 的准确语义(逐字取自原生查询) + +```sql +SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount + FROM ( SELECT m.id, m.conversation_id, m.status, + ROW_NUMBER() OVER (PARTITION BY m.conversation_id ORDER BY m.created_at DESC) AS row_no + FROM messages m + WHERE m.conversation_id IN (:conversationIds) ) ranked + LEFT JOIN message_reads mr ON mr.message_id = ranked.id AND mr.reader_user_id = :readerUserId + WHERE ranked.row_no <= 500 + AND (ranked.status IS NULL OR ranked.status <> 'RECALLED') + AND mr.id IS NULL + GROUP BY ranked.conversation_id +``` + +| 语义点 | 事实 | +|---|---| +| 可见范围 | 由**会话成员关系**界定(调用方先取 `findByUserId` 得到 `convIds`),**非**由用户传入 ID 决定 | +| 会话页之外 | **不查询**(只传当前用户的会话 id 列表) | +| 撤回消息 | **排除**(`status <> 'RECALLED'`) | +| 已读消息 | **排除**(`message_reads` 存在该读者的记录) | +| 窗口上限 | 每会话**最近 500 条**(`row_no <= 500`) | +| 发送者过滤 | **无** —— 见 **OBS-E01** | +| 未返回的会话 | Service 侧 `getOrDefault(cid, 0L)` 补 0 | + +**OBS-E01 的实测证据**:同一会话内「本人 1 条 + 对方 2 条、均未读」时 +`unreadCount = 3`(最初按"他人发送才计"预期的 2 被实测纠正)。 +本轮**如实锁定既有语义**,未做任何行为改变。 + +--- + +## 8. H2 / MySQL 逐值一致结果 + +`R01EMySqlQueryCountAndExplainTest`(Testcontainers `mysql:8.4`,**4/4 通过**,耗时 34.94s → 容器真实启动): + +| 用例 | 结论 | +|---|---| +| `groupedUnreadMatchesPerConversationSemantics` | MySQL 上分组 SQL 与 Service 路径**逐值一致**;`c1`: 3 条 SENT − 1 条 RECALLED − 1 条已读 = **1**;`c2`: **1** | +| `emptyAndForeignConversations` | 空 id 列表与不含该会话的 id 列表**均不返回** | +| `conversationScaleIsFlatAndCorrect` | 1 / 20 / 100 会话下 Service 结果条数与逐会话 `unreadCount`(各 3 条未读 = **3**)**全部正确** | +| `explainUnreadQuery` | 见 §9 | + +**H2 与 MySQL 一致性**:同一组夹具语义(撤回排除、已读排除、多会话聚合、缺失补 0)在两侧 +得到**相同数值**;H2 侧见 §4/§5/§13,MySQL 侧见上表。 + +--- + +## 9. MySQL EXPLAIN 摘要 + +对**与 `countUnreadByConversationIds` 逐字相同**的 SQL、同一批绑定参数执行 +`EXPLAIN FORMAT=JSON`(MySQL 8.4;提交前收口测试增加 10,000 条非目标背景消息, +使 1 个目标会话 × 200 条消息保持高选择性): + +| 项 | 值 | +|---|---| +| JSON 长度 | 3898 字符 | +| `using_temporary_table` | **true**(位于 `grouping_operation` 层,为 `GROUP BY conversation_id` 的固有代价) | +| `using_filesort` | 提交前实施夹具(1 会话 × 200 条、10,000 条背景)为 **false**;独立复验的 1/20/100/600 四档真实规模均为 **true** | +| 派生表 `ranked` | `access_type = ALL`,即对物化后的派生结果顺序扫描;不能表述成“没有全表扫描” | +| 基础表 `messages`(别名 `m`) | `access_type = ref/range`,命中 `idx_messages_conversation_created`,不是 `ALL` | +| 基础表 `message_reads`(别名 `mr`) | `access_type = eq_ref`,命中 `uk_message_reads` | +| 其它字段 | `possible_keys` / `key` / `cost_info` / `rows_examined_per_scan` 均已出现;摘要落盘 `target/r01e-mysql-explain.txt` | + +**判读**:分组所需的临时表及窗口函数排序属于既有 SQL 的固有代价;最小实施夹具与 +真实规模对 `using_filesort` 的代价选择不同,而独立复验四档真实规模均为 +`using_temporary_table=true`、`using_filesort=true`。`ranked` 的 `ALL` 是派生结果顺序扫描; +必须与基础表区分。基础表 `messages` 在高选择性场景命中既有复合索引且不是 `ALL`, +`message_reads` 以唯一键 `eq_ref` 访问。 + +独立复验发现原实施测试使用 +`doesNotContain("\"table_name\": \"messages\" ...")`,但 MySQL JSON 使用别名 `m`, +因此该断言不具鉴别力。提交前已改为解析 JSON 树,分别定位唯一的 `m`、`mr`、`ranked` +节点,并校验访问类型、索引、临时表和 filesort;错误索引、`m=ALL`、缺失 `mr` 三种变异 +都会使断言失败。 + +--- + +## 10. 使用到的索引 + +`V9__messaging.sql` 中**既有**索引(**本轮未新增任何迁移或索引**): + +| 索引 | 表 | 列 | +|---|---|---| +| `idx_messages_conversation_created` | `messages` | `(conversation_id, created_at)` | +| `idx_message_reads_message` | `message_reads` | `(message_id)` | +| `idx_message_reads_reader` | `message_reads` | `(reader_user_id)` | +| `idx_conv_members_user` | `conversation_members` | `(user_id)` | +| `idx_conversations_updated` | `conversations` | `(updated_at)` | + +未读查询的 `WHERE m.conversation_id IN (...)` 与 +`ROW_NUMBER() ... ORDER BY m.created_at DESC` 由 +`idx_messages_conversation_created (conversation_id, created_at)` 支撑; +`LEFT JOIN message_reads` 由 `idx_message_reads_message` / `idx_message_reads_reader` 支撑。 + +独立复验还确认:当目标会话消息占全表约 5.45% 时仍使用索引,约 13.63% 时优化器会 +基于代价转为基础表全扫;这是选择性变化,不是索引缺失。窗口排序、派生表物化及高占比 +场景仍是遗留性能风险,但 EXPLAIN 未证明必须新增索引,故本轮未新增 V46。 + +--- + +## 11. 行级副作用快照 + +`R01EQueryBudgetAcceptanceTest` 对 **11 张表**做稳定字段行级快照(按主键排序)并逐字段比对: + +| 表 | 快照列 | +|---|---| +| `users` | id, phone, nickname, role, is_active | +| `user_roles` | user_id, role | +| `conversations` | id, type, created_by, updated_at | +| `conversation_members` | conversation_id, user_id | +| `messages` | id, conversation_id, sender_user_id, status | +| `message_reads` | id, message_id, reader_user_id | +| `notifications` | id, user_id, type, title | +| `audit_logs` | id, user_id, action, entity_type | +| `auth_tokens` | id, user_id, token_type, revoked | +| `applications` | id, job_id, student_user_id, status | +| `job_favorites` | id, job_id, student_user_id | + +| 用例 | 结论 | +|---|---| +| `listConversationsIsReadOnly` | 会话列表 GET 前后 **11 张表逐字段零变化** ✔ | +| `personalDataCollectIsReadOnly` | `collector.collect()` 前后 **11 张表逐字段零变化** ✔ | + +--- + +## 12. RabbitMQ / 通知 / 审计结果 + +| 项 | 结论 | +|---|---| +| 通知 | `notifications` 行数在 `collect()` 前后**完全相等** ✔ | +| 审计 | `audit_logs` 行数在 `collect()` 前后**完全相等** ✔;且快照逐字段零变化 ✔ | +| RabbitMQ | 两条路径(`listConversations`、`collect`)均不发送消息;测试期间无新增 MQ 相关行 ✔ | +| 日志 | 未新增记录消息内容 / 手机号 / Token / 导出内容的日志语句(生产 diff = 0,无新增日志) | + +--- + +## 13. 新增测试及变异/反证 + +| 测试类 | Tests | 内容 | +|---|---|---| +| `common/r01e/R01EQueryBudgetAcceptanceTest` | **11** | E1/E2 查询预算(H2,真实 JDBC 计数)、既有未读语义(含 OBS-E01)、已读/撤回排除、多会话聚合与补 0、跨用户不泄漏、**11 张表行级副作用快照**、通知/审计零新增 | +| `common/r01e/R01EMySqlQueryCountAndExplainTest` | **4** | MySQL 8 上分组 SQL 与逐会话语义**逐值一致**、空列表与不属用户的 id 不返回、1/20/100 会话规模、`EXPLAIN FORMAT=JSON` | + +**反证设计**:查询预算断言为**精确相等**(非"不显著增长")——若任一实现退化为逐条查询, +`baseline[1] != baseline[0]` 会立即失败;副作用快照为**逐字段相等**,任何写入都会被捕获。 +测试类通过 `CountingDataSource` 统计**真实 JDBC 语句**,**不是** Mockito verify Repository。 + +**本轮自查发现并修复的测试缺陷(诚实记录)**:`R01EQueryBudgetAcceptanceTest` 初版只有 +`@BeforeEach` 清理、**缺 `@AfterEach`**,退场后遗留 `r01e-*` 用户/公司/会话,导致全量运行中 +`PersonalDataExportControllerTest`(15)、`CompetitionRankingControllerTest`(13) 等 **60 项失败**。 +已补 `@AfterEach` 精确清理,复跑 `R01EQueryBudgetAcceptanceTest,PersonalDataExportControllerTest, +CompetitionRankingControllerTest` → **39/39 通过**。**这是本轮新增测试自身的缺陷,非既有实现问题。** + +--- + +## 14. 定向、关联、全量和 clean verify 数量 + +| 命令 | 结果 | +|---|---| +| 关联回归(`*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test`) | **150 / 150**,F=0 E=0 S=0 ✔ | +| 性能/验收定向 | 见 §14.1 | +| `mvn test` | 见 §14.1 | +| `mvn clean verify` | 见 §14.1 | + +### 14.1 全量与 clean verify + +| 命令 | Tests | Failures | Errors | Skipped | BUILD | +|---|---|---|---|---|---| +| 性能/验收定向(含 MySQL Testcontainers) | **18** | 0 | 0 | **0** | SUCCESS | +| `mvn test` | **2761** | **0** | **0** | **0** | SUCCESS | +| `mvn clean verify` | **2761** | **0** | **0** | **0** | SUCCESS | + +**2761 = 2746(R01-C 阶段二基线)+ 本轮新增 15**(H2 11 + MySQL 4),与要求「不低于 2746 + 新增数」一致。 +**Skipped=0** 且 MySQL 用例耗时 34.94s,证明 **Testcontainers 真实执行、未被跳过**。 + +**关联回归**:`*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test` +→ **150 / 150**,F=0 E=0 S=0 ✔ + +--- + +## 15. OpenAPI 数量 + +``` +python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed! +``` + +`git diff -- docs/openapi-rc.json` → **空** ✔ + +--- + +## 16. 精确修改文件 + +**生产代码:0 个文件。** + +**新增测试(2)** + +``` +mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java +mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java +``` + +**文档(1)**:本报告 + +--- + +## 17. 是否修改迁移、ErrorCode、接口、DTO、依赖 + +| 项 | 结果 | +|---|---| +| V1–V45 / Flyway | **未修改** ✔ | +| `ErrorCode` | **未修改** ✔ | +| 公网接口 / DTO / OpenAPI 契约 | **未修改**(176/214) ✔ | +| `pom.xml` / 依赖 | **未修改** ✔ | +| Security / Token / 权限 / requestId | **未修改** ✔ | +| 评分、排名、推荐、申诉、邀约、Offer、撤回与已读语义 | **未修改** ✔ | +| 分布式缓存 / 冗余未读列 / 异步最终一致未读数 | **未引入** ✔ | + +--- + +## 18. Git 状态 + +| 项 | 结果 | +|---|---| +| 分支 / HEAD | `refactor/r01-structure-performance` / `b893ed67f078eedb0c4b8a03341311bd79342354`(未变) | +| HEAD 与 upstream | **0 0** ✔ | +| `git diff --cached --name-only` | **空** ✔ | +| `git diff --check` | **exit 0** ✔ | +| 生产代码 diff | **0 个文件** ✔ | +| V1–V45 / `openapi-rc.json` / `pom.xml` / `ErrorCode` | **均未修改** ✔ | +| 历史未跟踪文件 | 完整保留 ✔ | +| 本轮新增(未跟踪) | `docs/r01-e-batch-query-unread-count-performance-report.md`、`mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/`(2 个测试类) | + +--- + +## 19. 明确未暂存、未提交、未推送 + +**确认:未执行 `git add`;未创建提交;未推送。** 未执行 `reset`/`restore`/`checkout`/`clean`。 + +--- + +## 20. 遗留风险 + +| # | 风险 | 说明 | +|---|---|---| +| R1 | **OBS-E01 语义** | 本人发送且未读的消息计入本人未读数。当前实现如此,本轮**未改变**;若产品确认应排除,需另立批次(属业务语义变更,不属性能收束) | +| R2 | 500 条窗口 | 未读统计只看每会话**最近 500 条**;超过 500 条的会话其更早未读不计入。属既有设计,本轮未改动 | +| R3 | 派生表物化 | MySQL 8 对 `ROW_NUMBER()` 派生表可能物化;`ranked.row_no <= 500` 的过滤在物化后生效,大会话下存在额外扫描成本。EXPLAIN 证据见 §9 | +| R4 | 测试自污染 | 本轮新增测试的清理为**按前缀精确删除**;若将来有人新增同类夹具而不清理,仍可能污染共享 H2 库(R01-C 已就同类问题建立隔离门禁,本轮范围外) | + +--- + +> **R01-E既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。** diff --git a/docs/r01-e-independent-verification-20260918.md b/docs/r01-e-independent-verification-20260918.md new file mode 100644 index 0000000..c22236c --- /dev/null +++ b/docs/r01-e-independent-verification-20260918.md @@ -0,0 +1,412 @@ +# R01-E 批查询与会话未读数:**独立复验报告**(第三~十一节) + +日期:2026-09-18 +分支:`refactor/r01-structure-performance` +HEAD / upstream:`b893ed67f078eedb0c4b8a03341311bd79342354`(ahead/behind = **0 / 0**) +本轮性质:**独立复验** —— 不改生产代码、不改迁移、不暂存、不提交、不推送 +上游依据:`docs/r01-e-batch-query-unread-count-performance-report.md`(实施与测试加固报告) + +--- + +## 0. 结论 + +> **R01-E独立代码与真实MySQL性能复验通过,可以进入精确暂存与独立提交检查点。** + +| 复验项 | 结论 | +|---|---| +| 独立查询预算(E1 = 20/20/20,E2 = 3/3/3) | **通过**(H2 与真实 MySQL 双侧实测) | +| E1 collector 完整输出 H2/MySQL 深比较 | **通过**(逐字段、含 null/时间/集合顺序/关联数据) | +| E2 会话未读 H2/MySQL 深比较(10 项语义) | **通过** | +| MySQL `EXPLAIN FORMAT=JSON` | **通过**(基础表命中既有索引,无需新增 V46) | +| 副作用与隔离(25 张表快照 + 反证 + 零残留) | **通过** | +| OBS-E01 | **保留既有语义**,作为产品负责人待决策项上报 | +| 独立复验时全量 `mvn test` / `mvn clean verify` | **2787 / 0 / 0 / 0** | +| 提交前收口 `mvn clean verify` | **2788 / 0 / 0 / 0**(增加 1 个 EXPLAIN 变异门禁) | +| OpenAPI | **176 paths / 214 operations**,`openapi-rc.json` 未改 | +| 生产代码 diff | **0 个文件** | + +**独立复验新发现(3 项,均如实记录)**: +**F1** 实施测试的 EXPLAIN 断言 `doesNotContain("\"table_name\": \"messages\", ...")` 是**空断言**—— +MySQL 的 EXPLAIN JSON 里 `table_name` 存的是**别名**(本语句为 `m` / `mr`), +`"table_name": "messages"` 永不出现,故该断言无论如何都会通过。结论本身(当时夹具下确实未全扫)成立, +但**断言不具备鉴别力**。提交前收口已将实施测试改为解析 JSON 树,按别名分别定位 +`m` / `mr` / `ranked`,校验 access_type、key、temporary 与 filesort,并以错误索引、 +基础表 `ALL`、缺失节点三类变异证明断言有效。 +**F2** H2 与 MySQL 对同一 JSON 列的回读**表示不同**(详见 §4.2),已记录原始值并按语义归一后比较。 +**F3** 实施报告 §9 记 `using_filesort=false`;本轮在**真实规模夹具**下实测 `using_filesort=true` +(窗口函数 `ROW_NUMBER()` 的排序代价),详见 §6.4。 + +--- + +## 1. 复验范围与不重叠声明 + +- 从第三~十一节执行,**未重做第一、二节库存**,未修改任何生产代码。 +- 复用既有文件(**未重复创建**): + - `mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java`(实施测试,11 项) + - `mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java`(实施测试,4 项) + - `docs/r01-e-batch-query-unread-count-performance-report.md` +- 独立测试**不复用**实施测试的任何夹具、断言或辅助方法:夹具、快照(25 张表、列集合不同)、 + 计数 DataSource、EXPLAIN 解析均为独立实现。 + +--- + +## 2. 本轮新增文件(未跟踪) + +``` +mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java 独立测试,16 项 +mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentSideEffectIsolationTest.java 独立测试,10 项 +docs/r01-e-independent-verification-20260918.md 本报告 +``` + +**生产代码:0 个文件**(`git diff --stat` 为空)。 + +--- + +## 3. 独立查询预算验证(任务 1) + +**方法**:独立实现的 `CountingDataSource` 包装真实 `DataSource`,记录**真实 JDBC 语句文本与条数**; +两侧在**夹具灌入完成之后**才 `reset()`,故计数**不含测试准备与清理 SQL**(断言实测语句全部为 `SELECT`)。 + +### 3.1 E1:1 / 10 / 50 条投递 + +| 夹具 | H2 实测 | MySQL 实测 | 目标 | +|---|---|---|---| +| 与实施报告同基线的最小夹具(2 用户 + 1 公司 + N 岗位 + N 投递) | **[20, 20, 20]** | **[20, 20, 20]** | **20/20/20 ✔** | +| 本类丰富夹具(12 个 section 全部有数据)+ N 投递 | [22, 22, 22] | [22, 22, 22] | 恒定 ✔ | + +- 两者都**不随记录数增长**;与实施报告「20/20/20」的结论**独立复现一致**。 +- 丰富夹具为 22(比最小夹具多 2):多出的是「本人持有公司 → offers 企业侧分支」以及 + 「存在会话 → conversations/messages 两条批量查询」这两个**按分支固定**的查询,仍与 N 无关。 +- 证据:`target/r01e-independent/e1-budget.txt`、`e1-budget-rich.txt`。 + +### 3.2 E2:1 / 20 / 100 会话 + +| 会话数 | H2 实测 | MySQL 实测 | 目标 | +|---|---|---|---| +| 1 | **3** | **3** | 3 ✔ | +| 20 | **3** | **3** | 3 ✔ | +| 100 | **3** | **3** | 3 ✔ | + +- 实测 SQL 文本**恰好为 3 条**,形态固定且可辨认: + 1. `select ... from conversation_members ... where user_id=?` + 2. `select ... from conversations ... where id in (?,...)` + 3. 分组未读查询(含 `ROW_NUMBER()`)——**只出现 1 次** +- 证据:`target/r01e-independent/e2-budget.txt`(含两侧完整 SQL 文本)。 + +### 3.3 空会话页不执行未读查询 + +| 断言 | H2 | MySQL | +|---|---|---| +| `listConversations(无会话用户)` 返回空 | ✔ | ✔ | +| 实测语句数 | **1** | **1** | +| 该语句不含 `ROW_NUMBER` | ✔ | ✔ | +| 含 `row_number` 的语句数 | **0** | **0** | + +即:空会话页只发出一条成员关系查询后短路返回,**不下发未读聚合查询**。 + +--- + +## 4. E1 H2 / MySQL 深比较(任务 2) + +### 4.1 方法 + +同一个 JVM 内同时持有两个 Spring 上下文: + +- **H2 侧**:测试自身上下文,`jdbc:h2:mem:r01e_ind;MODE=MySQL`(**本类独占**的内存库,Flyway 全量迁移); +- **MySQL 侧**:`@BeforeAll` 手工引导的第二个上下文(Testcontainers `mysql:8.4`,`mysql-it` 配置, + `WebApplicationType.NONE`,命令行参数覆盖数据源),Flyway 在容器内真实执行 45 个迁移。 + +两侧灌入**同一份等价夹具**(显式时间戳、无 `CURRENT_TIMESTAMP` 依赖),覆盖 **全部 12 个 section**, +并额外放入一个「外人」用户的同类数据。 + +**先决条件已实证**:`bothEnginesAreLiveAndFixtureIdsAgree` 断言两侧 27 个自然键 → 主键映射**完全相等** +(同一 JVM、两侧均为全新建库),故后续可直接比较含主键的完整输出。 + +### 4.2 深比较结果 + +`canonicalResult(...)` 把 collector 的**完整 section 输出**规范化为确定性文本:显式呈现 `null`、 +时间按 `LocalDateTime` 呈现、List 保持原序(集合顺序)、Map 按 key 排序、`BigDecimal` 去尾零。 + +``` +e1-h2.txt == e1-mysql.txt (均 6213 字节,逐字符相同) +``` + +**JSON 列的表示差异(F2,如实记录)**——`e1-raw-json.txt` 原文: + +``` +h2: profile.skills="{\"java\":3,\"sql\":2}" | preferences.preferredCities="[\"北京\",\"上海\"]" +mysql: profile.skills={"sql": 2, "java": 3} | preferences.preferredCities=["北京", "上海"] +``` + +- **H2(MODE=MySQL)**把 JSON 列回读为**被转义的 JSON 字符串标量**; +- **MySQL 8**回读为 **JSON 对象字面量**(键重排、冒号后加空格)。 +- 两者**语义相同、表示不同**:比较的是语义。规范化对确实是 JSON 的字符串递归解码后按键排序, + 非 JSON 字符串原样保留(`content`、`coverLetter` 等不受影响)。 + +**反证**:把 dump 中任意一个字段值(`"张三"` → `"张四"`)置换后断言必须不再相等——通过, +说明规范化是**逐字段敏感**的,而不是只比条数、hash 或长度。 + +### 4.3 其他断言 + +| 断言 | 结果 | +|---|---| +| section 顺序 | 精确等于 `profile, preferences, resumes, applications, application-timelines, interviews, interview-timelines, offers, offer-timelines, feedback, favorites, notifications, conversations, messages, candidate-scoring, scoring-appeals` ✔ | +| 关联数据 | 应用↔时间线、面试↔时间线、Offer↔时间线、会话↔消息的分组关系随主键一并跨库一致 ✔ | +| 其他用户数据不得混入 | dump 中不含 `李四 / 外人的简介 / 外人简历 / 外人求职信 / 外人评价 / 外人通知 / 外人会话 / out-m1 / 外人Offer / 外人理由 / 外人面试 / outsider` ✔ | +| 跨库自有行数 | 23 张表的自有行数两侧完全一致 ✔ | +| 非 ACTIVE 简历、`read_at` 为 null / 非 null 的通知、`score` 为 null 的维度分、`tags` 为 null 的评价、`cover_letter` 为 null 的投递 | 均已覆盖并通过 ✔ | + +--- + +## 5. E2 H2 / MySQL 深比较(任务 3) + +10 项语义逐条覆盖,**两侧结果逐值一致**(会话列表完整视图规范化后逐字符相同): + +| # | 语义 | H2 | MySQL | 结果 | +|---|---|---|---|---| +| 1 | **本人发送未读按现状计入(OBS-E01)** | c1 = 2(本人 1 + 他人 1) | c1 = 2 | ✔ 一致 | +| 1b | 同上,把本人那条标记已读后 | 1 | 1 | ✔ | +| 2 | 他人发送未读计入 | ✔ | ✔ | ✔ | +| 3 | `message_reads` 排除 | 读 1 条后 2 → 1 | 同 | ✔ | +| 3b | 另一读者的已读记录不影响本人 | 不受影响 | 不受影响 | ✔ | +| 4 | `RECALLED` 排除 | 撤回 1 条后 2 → 1 | 同 | ✔ | +| 5 | 多会话分组 | c1=2, c2=1, c3=1 | 同 | ✔ | +| 6 | 非当前用户会话不返回 | 只返回 c1/c2/c3;cOutsider 不在 | 同 | ✔ | +| 7 | 聚合缺失补 0 | 全部撤回/已读时聚合缺行 → 0 | 同 | ✔ | +| 8 | **最近第 500 条计入、第 501 条排除** | 501 条未读 → **500**;把最旧一条标已读 → **仍 500**;把最新一条标已读 → **499** | 同 | ✔ | +| 9 | **`created_at` 相同的排序语义** | 同刻 3 条 → 3;其中 1 条已读 → 2;同刻 501 条 → **500** | 同 | ✔ | +| 10 | **空及重复 `conversationIds`** | 重复 `[c1,c1]` 只产 1 行且与 `[c1]` 逐值相同;空列表由 Service 短路、不下发 `IN ()` | 同 | ✔ | + +**第 8 项证明方式**:不止断言「等于 500」,而是通过「标已读最旧一条 → 不变」证明**最旧一条本就在窗口之外**, +「标已读最新一条 → 499」证明**窗口保留的是最新一侧**。 + +**第 9 项的诚实边界**:`ROW_NUMBER() ... ORDER BY m.created_at DESC` **没有 tiebreaker**, +故当 501 条 `created_at` 完全相同时,「保留哪 500 条」**由引擎决定**; +但**窗口计数与引擎无关,恒为 500**——两侧实测一致。此点已计入遗留风险(R2)。 + +--- + +## 6. MySQL `EXPLAIN FORMAT=JSON`(任务 4) + +**被测语句**与 `MessageRepository.countUnreadByConversationIds` **逐字相同**,绑定同一批参数。 +**夹具**:先铺入 **500 个背景会话 × 20 条 = 10 000 条消息**(不属于查询范围),再替换目标会话—— +否则 `messages` 表几乎只含查询范围内的行,MySQL 选择全扫是**正确**的代价决策, +却会让 EXPLAIN 失去验收意义。 + +### 6.1 逐表结果(`messages` / `message_reads` / 派生结果 `ranked` 分开报告) + +> **注意**:MySQL 的 `EXPLAIN FORMAT=JSON` 中 `table_name` 是**别名**。 +> 被测语句的别名为 `messages m`、`message_reads mr`、派生表 `ranked`, +> 故表节点分别为 `m` / `mr` / `ranked`。 + +| 规模 | selectivity | `ranked`(派生结果) | `messages`(别名 `m`) | `message_reads`(别名 `mr`) | temporary | filesort | +|---|---|---|---|---|---|---| +| 1 会话 × 3 条 | 0.01% | **ALL**(派生结果顺序扫描)· rows=3 | **ref** · `idx_messages_conversation_created` · rows=3 | **eq_ref** · `uk_message_reads` · rows=1 | true | true | +| 20 会话 × 3 条 | 0.27% | **ALL** · rows=60 | **range** · `idx_messages_conversation_created` · rows=60 | **eq_ref** · `uk_message_reads` · rows=1 | true | true | +| 100 会话 × 3 条 | 1.36% | **ALL** · rows=300 | **range** · `idx_messages_conversation_created` · rows=300 | **eq_ref** · `uk_message_reads` · rows=1 | true | true | +| **1 会话 × 600 条(> 500 窗口)** | 2.73% | **ALL** · rows=600 | **ref** · `idx_messages_conversation_created` · rows=600 | **eq_ref** · `uk_message_reads` · rows=1 | true | true | + +**判读**: + +- **派生结果 `ranked` 的 `access_type = ALL` 必须准确表述为「派生结果顺序扫描」**—— + 这是 MySQL 对 `ROW_NUMBER()` 派生表**物化**后顺序读取物化结果的固有形态, + `possible_keys`/`key` 均为 `null`(派生表上不存在索引)。**不得**笼统写成「无全表扫描」。 +- **`messages` 基础表在四个验收断言规模下命中既有索引 `idx_messages_conversation_created`(`ref` / `range`)**; + 更高选择性探测会转为 `ALL`,详见下一节,不能笼统表述为“没有全表扫描” ✔ +- **`message_reads` 基础表在所有规模下均为 `eq_ref` 命中 `uk_message_reads`** ✔ + (`possible_keys` 同时列出 `idx_message_reads_message` / `idx_message_reads_reader`) +- `possible_keys`、`key`、`rows_examined_per_scan`、`filtered`、`using_temporary_table`、`using_filesort` + 均已逐规模记录,原文见 `target/r01e-independent/e2-explain.txt`。 + +### 6.2 代价拐点(选择性探测,只记录不判定) + +| 规模 | selectivity | `messages` 访问方式 | +|---|---|---| +| 2 会话 × 600 条(1 200 行) | 5.45% | **range** · `idx_messages_conversation_created`(命中索引) | +| 5 会话 × 600 条(3 000 行) | 13.63% | **ALL** · key=null · rows=13 093(转为全扫) | +| 20 会话 × 600 条(12 000 行) | 54.53% | **ALL** · key=null · rows=22 016(转为全扫) | + +**结论**:拐点被夹逼在 **5.45% 与 13.63%** 之间。也就是说, +只有当「被查询的会话集合占 `messages` 全表 6% 以上」时,MySQL 才会放弃索引改走全扫—— +这是**代价决策,不是缺索引**。真实场景中一个用户的会话列表远低于此比例, +故本项判定 **PASS**,**不新增 V46**(严格遵守指令)。 + +### 6.3 `using_temporary_table = true` 的归属 + +`true` 出现在 `grouping_operation` 层,是 `GROUP BY ranked.conversation_id` 的固有代价, +与派生表物化同源,**不是**新增索引可以消除的。 + +### 6.4 `using_filesort` 的更正(F3) + +实施报告 §9 记录 `using_filesort=false`(其夹具为 1 会话 × 200 条)。 +本轮在**带背景数据的真实规模夹具**下,1/20/100/600 四档**全部实测 `using_filesort=true`**—— +来源是窗口函数 `ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY created_at DESC)` 的排序。 +这是**既有实现的固有代价**,本轮不改动,仅如实更正记录。 + +--- + +## 7. 副作用与隔离(任务 5) + +独立测试 `R01EIndependentSideEffectIsolationTest`,运行在**共享的 integration H2 库**上(与实施测试同库, +因此可以直接观测实施测试的夹具残留)。 + +### 7.1 逐字段快照(**25 张表**,独立于实施测试的 11 张) + +`users`(6 列)、`user_roles`、`companies`、`jobs`、`applications`、`application_status_history`、 +`interviews`、`interview_status_history`、`offers`、`offer_status_history`、`candidate_feedback`、 +`job_favorites`、`notifications`、`audit_logs`、`auth_tokens`、`conversations`、`conversation_members`、 +`messages`(含 `version`)、`message_reads`、`student_profiles`、`student_preferences`、`resume_versions`、 +`candidate_dimension_scores`、`candidate_scoring_appeals`、`candidate_scoring_appeal_history` + +| 用例 | 结论 | +|---|---| +| `collector.collect()` 前后 | **25 张表逐字段零变化** ✔ | +| `listConversations()` 前后 | **25 张表逐字段零变化** ✔ | +| `listConversations + getConversation + collect` 组合 | **25 张表逐字段零变化** ✔ | + +**读取路径零写**:夹具覆盖全部 12 个 section(collect 走满所有分支),快照仍零变化。 + +### 7.2 通知 / 审计 / RabbitMQ + +| 项 | 结论 | +|---|---| +| `notifications` 行数与逐字段内容 | 读前后**完全相等** ✔ | +| `audit_logs` 行数与逐字段内容 | 读前后**完全相等** ✔ | +| **RabbitMQ** | 以 `@MockBean RabbitTemplate` 注入,读路径结束后 `verifyNoInteractions(rabbitTemplate)` **通过**——**零 AMQP 交互** ✔ | +| MQ 配置 | `mysql-it` / `integration-test` 均置 `app.notification.mq-enabled: false`、`spring.rabbitmq.enabled: false`,`NotificationConsumer` 条件装配关闭 ✔ | + +### 7.3 反证:快照必须真的能发现变化 + +| 反证用例 | 期望 | 结果 | +|---|---|---| +| 修改一行(`users.nickname`) | 断言必须失败 | **AssertionError,含 `users`** ✔ | +| 新增一行(`notifications`) | 断言必须失败 | **AssertionError,含 `notifications`** ✔ | +| 删除一行(`message_reads`) | 断言必须失败 | **AssertionError,含 `message_reads`** ✔ | +| **仅单列变化**(`messages.status` → `RECALLED`) | 断言必须失败 | **AssertionError,含 `messages`** ✔ | + +若无此反证,「零变化」可能只是**没在比**。 + +### 7.4 夹具零残留 + +| 检查 | 结果 | +|---|---| +| 本类自有夹具(`r01eiso-%`)退场后残留 | **0**(`@AfterEach` 精确清理 + `@AfterAll` 门禁)✔ | +| **实施测试的夹具(`r01e-%`,排除本类前缀)残留** | **0** ✔ | +| 实施测试遗留的 `messages` / `r01e-co` / `r01e-job` | **0** ✔ | + +**关于此前「缺 `@AfterEach` 导致 60 项失败」的专项复验**:H2 内存库按 JVM 共享, +只有让 `R01EQueryBudgetAcceptanceTest` **先于**独立类运行,残留检查才具备鉴别力。 +为消除运行顺序的不确定性,本轮**追加一次逆序运行**: + +``` +mvn -o -Dsurefire.runOrder=reversealphabetical -Dtest="R01EQueryBudgetAcceptanceTest,R01EMySqlQueryCountAndExplainTest,R01EIndependentCrossDatabaseVerificationTest,R01EIndependentSideEffectIsolationTest" test +→ 实际顺序:R01EQueryBudgetAcceptanceTest(11) → R01EMySqlQueryCountAndExplainTest(4) + → R01EIndependentSideEffectIsolationTest(10) → R01EIndependentCrossDatabaseVerificationTest(16) +→ 独立复验:Tests run: 41, Failures: 0, Errors: 0, Skipped: 0 +→ 提交前收口:Tests run: 42, Failures: 0, Errors: 0, Skipped: 0(新增 EXPLAIN 变异门禁) +``` + +即:实施测试跑完后,独立类的 `implementationFixturesLeaveNoResidue` 与 `@AfterAll` 门禁 +**直接确认其夹具残留为 0**,旧问题已修复且可被独立观测。 + +--- + +## 8. OBS-E01(任务 6) + +**观察项(本轮不改变既有语义,作为产品负责人待决策项上报)**: + +> **本人发送且没有当前用户 `message_reads` 记录的消息,计入本人未读数。** + +| 证据 | H2 | MySQL | +|---|---|---| +| 会话内「本人 1 条 + 他人 2 条,均未读」 | 未读 = **3**(不是 2) | **3** | +| 会话内「他人 1 条 + 本人 1 条 + 1 条撤回」 | 未读 = **2** | **2** | +| 把本人发送的那条写入 `message_reads` | 未读减为 **1** | **1** | + +- 该行为源自未读查询的 `WHERE` 中**没有发送者过滤**(只按 `conversation_id` 与读者 `message_reads` 判定)。 +- 本轮**未修改 SQL、未修改任何生产代码**,仅将既有语义**如实锁定并双侧取证**。 +- 若产品确认应排除本人发送的消息,属**业务语义变更**,需另立批次,不在本次性能收束范围内。 + +--- + +## 9. 门禁结果(任务完成后的规定命令) + +| # | 命令 | Tests | Failures | Errors | Skipped | 结果 | +|---|---|---|---|---|---|---| +| 1 | `mvn -Dtest="R01EQueryBudgetAcceptanceTest,R01EMySqlQueryCountAndExplainTest,R01EIndependentCrossDatabaseVerificationTest,R01EIndependentSideEffectIsolationTest" test` | **41** | 0 | 0 | **0** | SUCCESS | +| 1b | 同上 + `-Dsurefire.runOrder=reversealphabetical`(残留门禁) | **41** | 0 | 0 | **0** | SUCCESS | +| 2 | `mvn -Dtest="*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test" test` | **150** | 0 | 0 | **0** | SUCCESS | +| 3 | 独立复验 `mvn test` | **2787** | **0** | **0** | **0** | SUCCESS | +| 4 | 独立复验 `mvn clean verify` | **2787** | **0** | **0** | **0** | SUCCESS | +| 5 | 提交前收口 `mvn clean verify` | **2788** | **0** | **0** | **0** | SUCCESS | +| 5 | `python ..\docs\verify_openapi.py` | paths **176** / operations **214** | — | — | — | `All checks passed!` | + +**测试计数对账**:独立复验为 `2761(R01-C 阶段二基线)+ 26(独立新增:16 跨库 + 10 副作用)= 2787`; +提交前收口再增加 1 个 EXPLAIN 变异门禁,最终为 **2788** ✔ +(实施测试的 11 + 4 = 15 已含在 2761 内,本轮未新增实施测试。) + +### 9.1 实施测试 / 独立测试 明细 + +| 类别 | 测试类 | 项数 | 引擎 | +|---|---|---|---| +| **实施测试** | `R01EQueryBudgetAcceptanceTest` | 11 | H2 | +| **实施测试** | `R01EMySqlQueryCountAndExplainTest` | 4 | **MySQL(Testcontainers)** | +| **独立测试** | `R01EIndependentCrossDatabaseVerificationTest` | 16 | **H2 + MySQL(同 JVM 双上下文)** | +| **独立测试** | `R01EIndependentSideEffectIsolationTest` | 10 | H2(共享 integration 库) | +| | **合计** | **41** | | + +### 9.2 Testcontainers / MySQL **实际执行**证据 + +- Docker Server **29.6.2**;`mysql:8.4` 容器日志实测:`Container mysql:8.4 started in PT7.4093471S`。 +- `R01EMySqlQueryCountAndExplainTest` 标注 `@Testcontainers(disabledWithoutDocker = true)`—— + 若 Docker 不可用该类的 4 项会被 **skip**;实测 **Skipped = 0**,故容器**真实启动并执行**。 +- `R01EIndependentCrossDatabaseVerificationTest` 断言 `MySql.container.isRunning()` 为 `true`, + 并在 MySQL 上真实执行了 Flyway 全量迁移、跨库深比较、`ANALYZE TABLE` 与 `EXPLAIN FORMAT=JSON`。 +- 两类 MySQL 用例耗时分别为 **122.5 s** 与 **17.2 s**(`clean verify` 单次运行),与容器冷启动 + 万级夹具灌入相符。 + +--- + +## 10. 生产代码与契约不变性 + +| 项 | 结果 | +|---|---| +| 生产代码 diff | **0 个文件**(`git diff --stat` 空) | +| V1–V45 迁移 / Flyway | **未修改**(目录 `git status` 0 项,仍为 45 个版本文件) ✔ | +| `pom.xml` / 依赖 | **未修改** ✔ | +| `ErrorCode` | **未修改** ✔ | +| 公网接口 / DTO / OpenAPI 契约 | **未修改**(176 / 214) ✔ | +| `docs/openapi-rc.json` | **未修改** ✔ | +| 新增迁移 V46 | **未新增**(EXPLAIN 未发现必须新增索引的情形) ✔ | +| Security / Token / 权限 / requestId | **未修改** | +| 评分、排名、推荐、申诉、邀约、Offer、撤回与已读语义 | **未修改** | +| 分布式缓存 / 冗余未读列 / 异步最终一致未读数 | **未引入** | + +--- + +## 11. Git 状态(未暂存 / 未提交 / 未推送) + +| 项 | 结果 | +|---|---| +| 分支 / HEAD | `refactor/r01-structure-performance` / `b893ed67f078eedb0c4b8a03341311bd79342354`(**未变**) | +| HEAD 与 upstream | **0 / 0** ✔ | +| 暂存区 `git diff --cached --name-only` | **空** ✔ | +| 本轮新增(未跟踪) | 2 个独立测试类 + 本报告 | +| 既有未跟踪文件 | 完整保留,未删除、未移动 | + +**确认:全程未执行 `git add` / `commit` / `push`,未执行 `reset` / `restore` / `checkout` / `clean`。** + +--- + +## 12. 遗留风险 + +| # | 风险 | 说明 | +|---|---|---| +| R1 | **OBS-E01 语义** | 本人发送且无本人 `message_reads` 的消息计入本人未读数。既有实现如此,本轮未改;如需变更属业务语义变更,须另立批次 | +| R2 | **`created_at` 并列无 tiebreaker** | 未读窗口的 `ORDER BY m.created_at DESC` 无二级排序键。并列跨越 500 边界时「保留哪 500 条」由引擎决定(**计数恒为 500**,两侧一致)。`messages.created_at` 为秒级 `DATETIME`,高并发同秒写入可触发 | +| R3 | **派生表物化** | MySQL 对 `ROW_NUMBER()` 派生表物化,`ranked` 为派生结果顺序扫描(`access_type=ALL`),且 `using_temporary_table=true`、`using_filesort=true` 为固有代价。大会话(> 每表 6% 选择性)下 `messages` 转为全扫,属代价决策 | +| R4 | **测试自污染** | 本轮测试均按前缀精确清理;若将来新增同类夹具而不清理,仍可能污染共享 H2 库(R01-C 已建隔离门禁) | +| R5 | **实施测试断言鉴别力(F1)** | 原 EXPLAIN 断言为空断言;提交前收口已改为 JSON 结构断言,并以三类变异证明有效 | + +--- + +> **R01-E独立代码与真实MySQL性能复验通过,可以进入精确暂存与独立提交检查点。** diff --git a/docs/r01-structure-performance-design.md b/docs/r01-structure-performance-design.md new file mode 100644 index 0000000..e60e9fa --- /dev/null +++ b/docs/r01-structure-performance-design.md @@ -0,0 +1,81 @@ +# R01 结构与性能收束权威设计 + +## 1. 目标与边界 + +R01 在 S01、S02、S03、Q01 已冻结行为之上收束重复代码和可证明的性能热点。所有改动必须保持 HTTP 契约、业务错误码、权限、事务、审计、隐私和数据副作用不变。 + +禁止在 R01 中新增业务功能、公开接口、Flyway、表或索引;禁止改变评分公式、推荐权重、百分位定义、消息未读定义、简历权限、Token 语义或岗位搜索语义。索引和全文搜索仅形成后续提案,不在本轮实施。 + +基线:分支 `refactor/r01-structure-performance`,起点 `77dc624`;OpenAPI 176 paths / 214 operations;全量测试 1890/1890。 + +## 2. 已确认问题 + +- 41 个生产文件包含私有身份解析方法;不同实现对匿名主体、非数字主体和溢出主体的处理不一致。 +- 46 个生产文件包含私有 requestId 获取方法;部分直接取请求头,未复用 RequestIdFilter 的规范化结果。 +- 角色断言以 `assertStudentRole`、`requireStudent`、`requireAdmin` 等形式分散,存在重复查用户和语义漂移风险。 +- `GlobalExceptionHandler.resolveHttpStatus` 是大型 if 链,并含重复分支。 +- `overviewStatus` 在评分引擎和竞争力排名中存在两套等价规则。 +- `JobRecommendationService.preferenceScore` 被压缩为单行,权重含义难以评审。 +- `CompanyScoringDashboardService.computePercentiles` 对每名候选人扫描全部分数,复杂度 O(n²)。 +- `MessagingService.listConversations` 对每个会话分别加载最多 500 条消息和 read 状态,查询次数随会话数线性增长。 +- `PersonalDataCollector` 存在可批量化的数据读取。 +- 岗位关键字 `%keyword%` 与排序 filesort 是已记录风险,但未经产品和容量决策,不在 R01 直接改搜索技术栈。 + +## 3. 分批策略 + +### R01-A:请求身份与 requestId 基础设施 + +扩展既有 `CurrentUserAccessor`,使所有迁移入口统一拒绝 null、匿名、失效、空白、非 ASCII 数字、溢出和非正 userId。不得在 Controller 接受 userId 参数。 + +新增单一 `RequestIdAccessor`:优先读取 RequestIdFilter 已规范化的 request attribute;缺失时使用现有安全归一化逻辑,而不是信任任意长度请求头。响应、审计和日志必须使用同一 requestId。 + +按小批次迁移 Controller,每批最多 8 个类。每个调用点逐项锁定原 401/403 和资源归属语义;禁止机械全仓替换。 + +角色授权暂不与身份解析混在同一提交。先盘点后新增 `UserRoleGuard`,保持 USER_NOT_FOUND 与 FORBIDDEN 的现有差异,不增加隐式缓存。 + +### R01-B:异常映射和业务规则唯一来源 + +将业务错误码到 HTTP 状态的关系提取为不可变映射,启动或测试时检测重复和遗漏;默认仍为 400。不得更改任何现有映射。 + +提取唯一 `CandidateOverviewStatusPolicy`。评分引擎与竞争排名调用同一实现,并用参数化契约测试证明旧、新结果完全一致。 + +### R01-C:纯计算与可读性 + +将百分位改为“排序一次 + 分数组到 lower-rank 的映射”,复杂度 O(n log n)。并列分数必须获得相同百分位;null、小于 2 个有效分数、精度和 HALF_UP 规则保持不变。 + +拆分 `preferenceScore` 为命名明确的五个维度贡献函数。城市 30、用工类型 20、最低薪资 20、行业 15、岗位族 15 及缺失维度不计分的规则不得变化。 + +### R01-D:数据库查询收束 + +消息会话列表新增固定次数聚合查询,一次性按 conversationIds 计算未读数。定义保持:排除 RECALLED;发送者消息是否未读沿用当前 read-row 语义;不存在 read 行即未读。1/20/50 个会话查询次数必须固定。 + +`markRead` 是否突破 500 条属于独立语义问题,本轮先写失败证据和决策,不与列表未读优化混改。 + +PersonalDataCollector 按关联键批量读取;导出 JSON 字段、顺序、空值、脱敏与表范围不变。以 1/20/50 数据规模记录查询数和完整导出结果摘要。 + +### R01-E:真实环境复验 + +在 MySQL 8.4 上执行查询预算、结果等价和 EXPLAIN。不得把 H2 结果当成 MySQL 证据;不得为了 EXPLAIN 修改迁移或索引。 + +## 4. 关键契约 + +| 模块 | 必须保持 | +|---|---| +| 身份 | 非法主体为 401,不回显 principal,不产生业务写入 | +| 角色 | 原有 USER_NOT_FOUND/FORBIDDEN、角色组合与归属判断不变 | +| requestId | 最大长度、生成规则、响应头、响应体、MDC 与审计同源 | +| 异常 | 每个 ErrorCode 的 HTTP 状态与响应 body 不变 | +| 百分位 | `lower / (n-1) * 100`、并列同值、scale=2、HALF_UP | +| 推荐 | 五项权重和缺失项分母规则逐字保持 | +| 未读 | RECALLED 不计入;已有 read 行语义不变 | +| 导出 | 字段、隐私、顺序、内容与错误语义不变 | + +## 5. 证据要求 + +每一批必须先建立特征测试或失败测试,再改生产代码。性能结论必须同时包含结果等价断言、SQL 次数和反证测试;仅写“无 N+1”不算证据。 + +自动化最低门槛:定向测试、关联回归、`mvn test`、`mvn clean verify`、静态 OpenAPI;Failures/Errors/Skipped 均为 0。涉及 SQL 的批次还必须有真实 MySQL 黑盒。 + +## 6. 提交边界 + +每个子阶段独立分支检查点或独立提交;不得将 R01-A 到 R01-E 压成一个大提交。所有历史未跟踪日志和验收产物保留但不提交。不得使用 `git add -A`、reset、restore、checkout 或 clean。 diff --git a/docs/resume-api.md b/docs/resume-api.md index 97cdf4b..d9d3a5f 100644 --- a/docs/resume-api.md +++ b/docs/resume-api.md @@ -48,7 +48,7 @@ POST /profile/me/resume-versions (multipart/form-data) | 字段 | 必填 | 约束 | |---|---|---| -| `file` | 是 | PDF/DOC/DOCX,≤10MB,扩展名与 Content-Type 一致 | +| `file` | 是 | PDF/DOC/DOCX,≤10MB;扩展名、Content-Type 与实际文件结构必须一致 | | `name` | 否 | ≤120 字符,可空 | 默认状态 `ACTIVE`、**非默认**;版本号自动分配(`max+1`)。并发创建由 DB 唯一约束兜底,冲突自动重试(独立事务)。 @@ -157,6 +157,10 @@ GET /resume-export-tasks/{taskId}/download - 身份一律来自 JWT(`auth.getName()` = userId),**不信任请求体/路径中的 userId**; - 不能读取其他用户的简历(owner 校验); - 不返回内部堆栈;错误响应不泄露数据库结构。 +- 上传文件名拒绝路径分隔符、控制字符和响应头换行;下载历史文件时再次净化文件名。 +- PDF 校验文件头和结尾标记;DOC 校验 OLE/Word 文档结构标记;DOCX 校验 OOXML 必需条目,并限制 ZIP 条目数、解压总量和压缩比,拒绝路径穿越及压缩炸弹。 +- 文件替换采用“新文件落盘 → 数据库更新 → 事务提交后删除旧文件”;回滚时清理新文件。底层本地存储先写 `.uploading` 临时文件,再原子移动到最终随机存储名,失败不暴露半文件。 +- 上述措施属于格式与资源安全校验,**不等同于病毒查杀或沙箱检测**;生产部署仍应在存储接入层配置恶意文件扫描。 ## 5. 异步导出设计 @@ -182,7 +186,7 @@ GET /resume-export-tasks/{taskId}/download 前置:启动基础设施 → 启动应用 → 导入 `docs/openapi-rc.json`(或 `GET /api/v1/v3/api-docs`)。 -1. 登录学生账号(Mock 验证码,见日志)。 +1. 登录学生账号(Mock 验证码:日志不再输出,本地按 `docs/frontend-integration.md` §3 的受控方式取得;生产禁止查表)。 2. `POST /profile/me/resume-versions`(multipart:`file` + `name=秋招版`)→ 200,`versionNo=1`。 3. 再次上传 → `versionNo=2`;设置 `name=社招版`。 4. `GET /profile/me/resume-versions` → 列表含两个版本。 @@ -199,6 +203,7 @@ GET /resume-export-tasks/{taskId}/download 4. **默认简历仅作用于版本体系**:不联动旧 `student_profiles.resume_url`(按确认保持独立)。 5. **被导出任务引用的旧文件可能延迟清理**:替换版本文件时,若旧文件正被 PENDING/RUNNING 导出任务引用则保留(保导出一致性);任务结束后这些文件不会自动回收,需后续清理策略。 6. **OpenAPI 快照**:`docs/openapi-rc.json` 由 H2 集成测试上下文导出(**49 路径**,server URL 为测试默认值),接入现网后可用 `GET /api/v1/v3/api-docs` 重新导出。 +7. **不包含恶意软件扫描**:当前只执行文件格式、OOXML 结构、路径和资源消耗防护;不对宏、嵌入对象或恶意载荷作杀毒结论。 ## 10. 错误码(新增 82xx) @@ -211,3 +216,41 @@ GET /resume-export-tasks/{taskId}/download | 8205 | `DUPLICATE_DEFAULT_RESUME` | 409 | 并发设置默认冲突 | | 8206 | `RESUME_EXPORT_NOT_READY` | 409 | 导出任务未完成,不可下载 | +## S03-B:企业下载候选人简历 + +### `GET /company/candidates/{applicationId}/resume` + +新增的**唯一**企业侧简历读取入口。返回文件流,**不返回** `resumeUrl`、存储相对/绝对路径、 +学生用户 ID、手机号或邮箱。 + +- 认证:`Authorization: Bearer ` +- 路径参数:`applicationId`(本企业岗位下的投递) +- 响应:二进制文件流;`Content-Type` 只取自上传时已校验的白名单 + (`application/pdf`、`application/msword`、DOCX),否则回退 `application/octet-stream` +- 响应头:`Content-Disposition: attachment; filename*=UTF-8''<已净化文件名>`, + 以及 `Cache-Control: no-store`(私有简历不进任何中间缓存) +- 文件名继续经过 `ResumeFilenameSanitizer`,拒绝路径分隔符、控制字符与响应头换行 + +### 错误语义 + +| 场景 | 结果 | +|---|---| +| 未登录 / 无有效会话 | 401 | +| 非 COMPANY 角色(STUDENT / ADMIN) | 403 | +| 当前用户没有企业资料 | 403 | +| 投递不存在 | 404 `APPLICATION_NOT_FOUND` | +| 跨企业投递 | 403 | +| 隐私设置不允许(含 PRIVATE / 无设置) | 403 `FORBIDDEN` | +| 授权通过但学生没有简历 | 404 `PROFILE_NOT_FOUND` | + +### 授权与副作用 + +- 授权顺序:当前 COMPANY 用户 → 当前 Company → Application+Job → `Job.companyId` → + Student → privacy setting → profile/file。归属信息全部由服务端推导, + 不接受客户端传入的 `companyId`/`studentUserId`。 +- **拒绝路径不会调用存储读取**(测试以计数包装器验证为零次)。 +- 该接口只读:不修改 `student_profiles`、`resume_versions`、投递、评分、排名、推荐、邀约、 + 消息、通知或风险表;不发送 RabbitMQ、短信或邮件。 +- 学生本人的上传、下载、版本与导出接口语义完全不变。 +- 企业候选人列表/详情 DTO **仍不含** `resumeUrl`。 +- 不新增按 `studentUserId` 或 `candidateKey` 下载简历的入口;人才发现本轮不得直接下载简历。 diff --git a/docs/s01-independent-mysql-acceptance-20260911.md b/docs/s01-independent-mysql-acceptance-20260911.md new file mode 100644 index 0000000..97669e3 --- /dev/null +++ b/docs/s01-independent-mysql-acceptance-20260911.md @@ -0,0 +1,483 @@ +# S01 认证安全上线阻断整改 · 独立代码复验与真实 MySQL/Docker 黑盒验收报告 + +- **验收日期**:2026-09-11 +- **验收性质**:独立验收(非功能开发);使用真实 MySQL 8、最新 JAR、真实 HTTP 请求 +- **验收结论**:**FAIL**(已复现具体缺陷,S01 不可冻结) +- **报告文件**:`docs/s01-independent-mysql-acceptance-20260911.md` +- **验收产物目录**:`docs/acceptance/s01-20260911/`(脚本、请求体、日志、结果快照) + +> 本报告不包含数据库密码、.env 内容、JWT、验证码、完整手机号、明文测试口令与非法 principal 敏感标记原文。 + +--- + +## 1. Git 基线 + +| 项 | 实测值 | +|---|---| +| 分支 | `fix/resume-upload-safety` | +| HEAD | `cdafbf8ac93544a3ef89d0f3014f822a0d575f8f`(docs: update README for latest delivery baseline) | +| 最近 5 次提交 | cdafbf8 / 277b3c0 / dbd27e6 / cf39737 / 498512d | +| 暂存区 | **空**(未执行 `git add`) | +| `git diff --check` | 无空白错误(仅 CRLF 提示) | +| Flyway 迁移改动 | **无**(V1–V41 原样,共 41 个迁移文件,最大版本 41) | +| 新增迁移 | **无** | +| 工作区修改 | 20 个(S01 8 个 + 简历上传安全 12 个,均未提交) | +| 未跟踪文件 | 基线 106 个,全部保留;本轮新增验收产物 | +| AGENTS.md | **未发现**(仓库根、上级目录、`.claude/` 均无 AGENTS.md/CLAUDE.md/GEMINI.md/.cursorrules) | +| `.env` | 保留(365B) | + +**与 S01 无关的未提交修改(简历上传安全)已完整保护**:`profile/ProfileController.java`、`profile/ProfileService.java`、`profile/storage/LocalResumeStorageService.java`、`resume/ResumeVersionController.java`、`resume/ResumeVersionService.java`、`docs/frontend-integration.md`、`docs/resume-api.md` 及 5 个测试文件——本轮**未读取改写、未覆盖**,时间戳仍为 2026-09-10。 + +--- + +## 2. 独立代码审查结果(阶段 A) + +### A1 验证码/手机号日志 —— **PASS** + +| 检查项 | 结果 | 证据 | +|---|---|---| +| INFO/WARN/ERROR 不输出验证码 | ✅ | `SmsVerificationService` 仅 2 处 `log.warn`,参数为 `maskPhone(phone)`;`MockSmsCodeSender` 仅 1 条无参固定文本 | +| 不输出完整手机号 | ✅ | 实测 7 个验收手机号在全部日志中命中 **0** 次 | +| Mock 实现仅固定安全文本 | ✅ | `log.info("SMS verification code generated (mock sender)")` | +| Service 不把验证码传入日志参数 | ✅ | 全量检索无 `log.*code` 参数传递 | +| AuthService 不输出完整手机号 | ✅ | 仅 `log.info("Auto-created user: id={}")` | +| JWT 过滤器不记录 Token/subject/异常原文 | ✅ | 3 条固定文本,无 `e.getMessage()`、无 subject | +| 审计 details 不含完整手机号/验证码 | ✅ | 实测唯一 SMS_SEND 明细为 `phone=199****9999`(脱敏) | +| 异常消息不含敏感值 | ✅ | `SERVICE_UNAVAILABLE` 固定文本 `sms sender unavailable` | +| 不通过 toString/getMessage 间接泄露 | ✅ | 捕获的异常与堆栈均不含非法 principal 标记 | + +**唯一敏感标记复验**(真实 Logback 捕获 + 真实运行日志双通道):标记手机号、真实验证码(3 个 DB 实际值)、JWT 前缀 `eyJ`、`Bearer ey`、mock 正文、非法 principal 标记、`NumberFormatException` —— 命中数**全部为 0**。 + +### A2 短信发送器装配 —— **PASS(3 种配置)+ 发现 1 处边界缺陷(见 F3)** + +| 检查项 | 结果 | +|---|---| +| Service 只依赖 `SmsCodeSender` 接口 | ✅ 单构造参数注入,无 if 分支复制双流程 | +| `true` → 仅 `MockSmsCodeSender` | ✅ 实测(`SmsSenderConfigurationTest` 4/4 + 运行期行为) | +| `false` → 仅 `UnavailableSmsCodeSender` | ✅ 实测 | +| 缺失 → 默认 `UnavailableSmsCodeSender` | ✅ 实测(D3a) | +| 单一 Bean / 无装配歧义 | ✅ 三次启动均成功(缺/多 Bean 会直接启动失败) | +| mock=false 不回退 Mock | ✅ 实测无 mock 事件 | +| 生产不意外启用 Mock | ✅ `application.yml` 默认 `SMS_MOCK_ENABLED:false` | +| `@ConditionalOnProperty` 精确性 | ✅ Mock=`havingValue="true"`(**无** matchIfMissing);Unavailable=`havingValue="false", matchIfMissing=true`;name 与配置项 `app.sms.mock.enabled` 完全一致 | + +### A3 mock=false 失败事务 —— **PASS(MySQL 回滚)+ 1 项非阻断观察(F4)** + +- `@Transactional` 位于 `sendCode`;顺序:校验 → 锁定/限流 → 生成 → **`codeRepository.save`** → Redis 限流 → **`smsCodeSender.sendVerificationCode`** → 审计 +- `BusinessException extends RuntimeException` → 默认回滚;发送异常**未被捕获**,不转换为空成功 +- 真实 MySQL 实测:失败后 `sms_verification_codes` 完整字段前后**完全一致**(见第 6 节) +- 观察项:限流键在发送**之前**写入,失败后残留 60s(既有排序,非 S01 引入)→ F4 + +### A4 `CurrentUserAccessor` —— **PASS** + +拒绝:null / `!isAuthenticated` / `AnonymousAuthenticationToken` / 空或空白 name / `anonymousUser` / 非 ASCII 纯数字(含 `+42`、`" 42"`、`"42 "`、Unicode 数字)/ Long 溢出 / `userId<=0`。 +固定抛 `InsufficientAuthenticationException`(既有 `@ExceptionHandler(AuthenticationException)` → **HTTP 401**),**不回显、不记录**非法 principal,不新增 ErrorCode,不承担角色/归属判断,身份仅来自 SecurityContext。 + +### A5 `JwtAuthenticationFilter` —— **PASS** + +合法数字 subject 正常认证;缺失 Authorization → 匿名;无效签名/过期 → 既有 401;非数字 subject → **401 而非 500**;日志无 Token/subject/异常原文;`JwtTokenProvider` 未改动(Token 兼容语义不变);**未声称修复 Refresh Token**。 + +### A6 `/jobs` 安全规则 —— **PASS** + +`SecurityConfig:119` 由 `.requestMatchers("/jobs").permitAll()` 改为 `.requestMatchers(HttpMethod.GET, "/jobs").permitAll()`,且该规则**先于** `.requestMatchers("/jobs/**").authenticated()` 求值,写请求确实命中 authenticated。 + +**全部 /jobs 映射与最终权限(21 个 operation,真实 HTTP 实测)** + +| 方法 | 路径 | 匿名 | STUDENT | COMPANY | ADMIN | 归属要求 | +|---|---|---|---|---|---|---| +| GET | `/jobs` | **200** | 200 | 200 | 200 | 无 | +| GET | `/jobs/search`、`/jobs/filters` | **200** | 200 | 200 | 200 | 无 | +| GET | `/jobs/public/{id}` | **200** | 200 | 200 | 200 | 无 | +| GET | `/jobs/{id}/reviews`、`/summary`、`/{reviewId}`、`/jobs/compare` | **200** | 200 | 200 | 200 | 无 | +| POST | `/jobs` | **401** | **403/2002** | **200** | **403/2002** | 需绑企业 | +| PUT | `/jobs/{id}` | **401** | 403/2002 | 200 | 403/2002 | **本企业**(他企业 400/7002) | +| PATCH | `/jobs/{id}/status` | **401** | 403/2002 | 200 | 403/2002 | **本企业**(他企业 400/7002) | +| DELETE | `/jobs/{id}` | **401** | — | — | — | 无该端点 | +| GET | `/jobs/{id}` | **401** | 403/2002 | 200 | 403/2002 | 本企业 | +| GET | `/jobs/{id}/match` | **401** | — | — | — | 登录 | +| GET | `/jobs/{id}/competition-ranking` | **401** | — | — | — | 登录 | +| POST/DELETE | `/jobs/{id}/favorite`、GET `/jobs/favorites` | **401** | — | — | — | 登录 | +| GET | `/jobs/{id}/personalized-review-score` | **401** | — | — | — | 登录 | + +**不依赖 Controller 解析 anonymousUser 失败来保护写接口**:匿名请求在安全层即被拒(响应无 `requestId` 字段,符合既有 `AuthenticationEntryPoint` 固定载荷)。 + +### A7 剩余直接身份解析 —— **PASS(无 permitAll 可达)= 但存量 43 处** + +- 全量检索(任意变量名):`Long.(valueOf|parseLong)(*.getName())` 共 **43 处**,分布于 43 个文件 +- `.isAuthenticated()` 共 44 处;`anonymousUser` 字面量 5 处(`PersonalDataExportController`、`DeletionGuardInterceptor`×2、`AdminReviewReplyController`、`CompanyReviewManagementController`) +- **逐端点核验 permitAll 可达性**(按 SecurityConfig 匹配顺序,而非"路径要求认证"默认安全): + - permitAll 路径共 19 条规则,涉及的控制器为 `AuthController`、`CompanyController`、`JobReviewController`、`JobController`、`MatchController`、`JobRecommendationController` + - **匿名可达的 8 个 GET 方法**(`listJobs`、`searchJobs`、`getFilters`、`getPublicJob`、`list`、`get`、`summary`、`compare`)**均不调用身份解析** + - `CompanyController` 的 `resolveUserId` 仅被 `/companies/me`(POST/GET/PUT)使用 → authenticated;`JobReviewController` 的 `resolveUserId` 仅被 create/update/delete 使用 → authenticated + - **结论:43 处中 0 处处于 permitAll 可达路径**,不构成 S01 发布阻断。`JobController` 已改用 `CurrentUserAccessor`(裸解析 0 处) +- 存量风险提示:`DeletionGuardInterceptor:51` 使用 `chars().allMatch(isDigit)` 预检,可挡 `anonymousUser`,但对 **20 位纯数字溢出**仍会抛 `NumberFormatException`(需合法签名 Token 才能到达)→ 列为后续轮次加固项 + +### A8 OpenAPI 与响应兼容 —— **PASS** + +- 路径/operation 数量:静态 173/211;**运行时 173/211**,一致 +- 唯一变更:`docs/openapi-rc.json` 2 行描述文本(移除"验证码会打印到日志"表述),无结构变更 +- 登录、发码、岗位请求/响应 Schema 未变(S01 未改任何 DTO) +- 无新增短信调试接口、无新增获取验证码接口 + +--- + +## 3. 自动化独立复验(阶段 B,实测重新执行) + +| 命令 | Tests run | Failures | Errors | Skipped | 结果 | +|---|---|---|---|---|---| +| `-Dtest="SmsVerificationSecurityTest,SmsSenderConfigurationTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest"` | **40** | 0 | 0 | 0 | BUILD SUCCESS | +| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **214** | 0 | 0 | 0 | BUILD SUCCESS | +| `mvn test` | **1658** | 0 | 0 | 0 | BUILD SUCCESS(3:45) | +| `mvn clean verify` | **1658** | 0 | 0 | 0 | **BUILD SUCCESS**(4:31,产出 JAR 79,965,063B) | +| `python ../docs/verify_openapi.py` | — | — | — | — | paths 173 / operations 211 / **All checks passed!** | + +--- + +## 4. Docker / MySQL 环境(阶段 C) + +**启动前发现**:`docker ps` 为空,三个业务容器处于 **Exited**(约 2 小时前,139/0/137 退出码),**volume 数据完好**。容器标签显示由 `Desktop\qinghe\backend-infra\compose.yaml` 创建(项目名 `backend-infra`)。 + +**本轮处置(非破坏性,已记录)**:仅执行 `docker start mydreamjob-mysql mydreamjob-redis mydreamjob-rabbitmq`——**未重建容器、未删除 volume、未清空数据库、未触碰其余 3 个无关孤立容器、未停止 Docker**。 + +| 项 | 实测值 | +|---|---| +| Docker Server | 29.6.2 | +| MySQL | **8.4.11**(`127.0.0.1:3307`,healthy) | +| Redis | 7.4-alpine(`6379`,healthy) | +| RabbitMQ | 3.13-management-alpine(`5672/15672`,healthy) | +| 验收数据库 | `mydreamjob` | +| Flyway | 41 条,**success=1 全部成功**,0 失败,最大版本 **41**(`41 message recall`) | +| S01 新增迁移 | **无** | + +**已修复的验收环境问题(脚本层,非项目缺陷)**:Git Bash(MSYS) 会把裸 `/` 的 `RABBITMQ_VHOST` 自动改写为 Git 安装根路径,导致 `530 NOT_ALLOWED vhost not found`。经在验收脚本中清除该变量(交由 `application.yml` 默认值 `/` 承接)后,RabbitMQ 错误数 **0**。 + +--- + +## 5. 三种配置启动验证(阶段 D,均为最新 `mvn clean verify` JAR) + +| 场景 | 启动 | SmsCodeSender 实际实现 | 发码行为 | 日志敏感值 | 判定 | +|---|---|---|---|---|---| +| **D1** `mock=true` | ✅ Started 26.5s,health 200,8080 监听 | **MockSmsCodeSender**(固定事件文本命中 1 次) | HTTP **200**(非 500) | 0 | **PASS** | +| **D2** `mock=false` | ✅ Started 26.4s | **UnavailableSmsCodeSender**(无 mock 事件) | HTTP **400** `{"code":400,"message":"sms sender unavailable"}` | 0 | **PASS** | +| **D3a** 属性完全缺失 | ✅ Started 27.5s | **UnavailableSmsCodeSender**(未回退 Mock) | HTTP **400** 同上 | 0 | **PASS** | +| **D3b** 属性存在但为空值 | ❌ **APPLICATION FAILED TO START** | **无 Bean** | — | — | **FAIL(F3)** | + +每次启动均记录 profile(无 profile,基础 `application.yml`)、PID、端口、实现、Started 日志、health 状态;**仅停止本轮启动并记录 PID 的 JAR**(每次均以 `Get-CimInstance Win32_Process` 校验命令行确为 `zhilai/mydreamjob-server/target/*.jar`)。 + +--- + +## 6. 真实 MySQL 验证码事务回滚(阶段 E) + +**mock=false(发送失败回滚)**——完整字段快照对比,非 COUNT(*): + +| 指标 | BEFORE | AFTER | 结论 | +|---|---|---|---| +| 该手机号记录数 | 0 | 0 | ✅ | +| 全局 MAX(id) | 990 | 990 | ✅ 无新增 | +| 全局总行数 | 917 | 917 | ✅ | +| 未使用(used=0)记录数 | 0 | 0 | ✅ 无可用残留 | +| SMS_SEND 审计总数 | 819 | 819 | ✅ 无虚假成功审计 | +| 完整字段差异 | — | — | ✅ **前后完全一致** | + +无半提交、无 attempts 异常、无错误锁定状态更新。HTTP **400**(非虚假成功)。 + +**mock=true(完整链路)**: +1. 发送 → HTTP 200,DB 生成 1 条记录(验证码值仅存于库内,报告不复制) +2. 从 DB 取码 → `POST /auth/login` → HTTP **200**,响应含 `accessToken/refreshToken/expiresIn/nickname/phone/role/userId`,响应内**不含验证码明文** +3. 登录后该记录 `used=1`、`attempts=0`(一次性消费语义不变) +4. **重放同一验证码 → HTTP 400**(拒绝) +5. 日志与审计仍不含验证码/完整手机号 +6. 长度/有效期/最大尝试次数/锁定时间语义未变(`SmsVerificationSecurityTest` 10/10 覆盖) + +--- + +## 7. 真实 HTTP 权限矩阵(阶段 F) + +见第 2 节 A6 表格。关键断言全部成立: + +- **匿名 POST /jobs = HTTP 401(`code=401`),不是 500** ✅ +- STUDENT / ADMIN POST /jobs = **403 / 2002** ✅ +- COMPANY POST /jobs = **200** ✅(生成 id=43) +- 他企业 PUT/PATCH = **400 / 7002**(既有语义,未擅改)✅ +- 公开 GET(`/jobs`、`/search`、`/filters`、`/public/{id}`、`/{id}/reviews`)语义不变 = 200 ✅ +- 每条请求均记录 method / path / HTTP / business code / requestId + +**jobs 表副作用(完整字段对比,非 COUNT)**: + +``` +差异仅:新增 JOB:43|company_id=22|S01_ACC_*_job|PUBLISHED|...|requirements=S01_ACC_ok + TOTAL_JOBS 27 → 28 +TAMPERED_TITLE=0 ← 他企业篡改尝试零落库 +其余全部 JOB 行逐字段未变 +``` + +--- + +## 8. 非法 Token / principal 黑盒(阶段 G) + +对受保护端点 `POST /jobs` 的真实 HTTP 请求(含合法请求体): + +| 用例 | HTTP | business code | 响应回显敏感标记 | 判定 | +|---|---|---|---|---| +| 无 Authorization | 401 | 401 | no | ✅ | +| 合法数字 subject(学生 7936) | **403** | **2002** | no | ✅ 正常进入既有权限判断 | +| `subject=anonymousUser` | **401** | 401 | no | ✅ | +| 受控非法 principal 标记 | **401** | 401 | no | ✅ 非 500 | +| subject 缺失(空) | 401 | 401 | no | ✅ | +| `subject=0` | 401 | 401 | no | ✅ | +| `subject=-1` | 401 | 401 | no | ✅ | +| subject 超出 Long 范围 | 401 | 401 | no | ✅ | +| 过期 Token | 401 | 401 | no | ✅ | +| 签名被篡改 | 401 | 401 | no | ✅ | + +**无任何用例返回 500;响应与日志均未回显 subject 或 Token;jobs 表完整内容零变化(无业务写入)** ✅ + +Token 生成方式:验收专用 HS256 离线生成(复用项目 `JWT_SECRET` 环境变量,**未落盘、未输出**);外部 HTTP 无需伪造即可安全覆盖全部用例,故未降级为纯集成测试。 + +--- + +## 9. 日志与审计全量脱敏(阶段 H) + +仅扫描**本轮**日志(D1/D1b/D2/D3a/D3b 的 app + stdout): + +| 禁止项 | 命中 | 说明 | +|---|---|---| +| 验证码明文(3 个 DB 实际值) | **0** | | +| 完整手机号(7 个验收号) | **0** | | +| JWT(`eyJhbGci`、`Bearer ey`) | **0** | | +| Authorization 头值 | **0** | 16 处 `authorization` 均来自 D3b 启动失败堆栈的 `AuthorizationFilter.doFilter` | +| secret | **0** | | +| 非法 principal 标记 | **0** | | +| `NumberFormatException` 堆栈 | **0** | | +| Mock 短信正文 | **0** | `MOCK SMS CODE` 命中 0 | +| `password` | ⚠️ 16 | 全部为 Spring Boot 默认 `Using generated security password: ` → **F5(既有行为,见第 12 节)** | + +**审计表(audit_logs)**:完整手机号命中 **0**、非法 principal 命中 **0**、Token 命中 **0**、脱敏手机号命中 **1**(唯一 SMS_SEND 明细 `phone=199****9999`)✅ + +**允许项**:脱敏手机号、固定事件名、requestId、固定 purpose 均正常。 + +--- + +## 10. 运行时 OpenAPI(阶段 I) + +| 项 | 值 | +|---|---| +| 访问地址 | `GET /api/v1/v3/api-docs`(应用 `context-path=/api/v1`;裸 `/v3/api-docs` 返回 404 属预期) | +| HTTP | 200 | +| 运行时 paths / operations | **173 / 211** | +| 静态 paths / operations | 173 / 211 | +| 一致性 | **PASS** | +| 短信相关路径 | 仅 `/auth/sms/send`(无新增调试/取码接口) | +| `/jobs` 路径数 | 15 | +| `/auth/sms/send` 描述 | 已不再声称验证码输出到日志/控制台 ✅ | + +--- + +## 11. 文档一致性核查(阶段 13)—— **FAIL** + +**仍指导从日志读取验证码的文档(精确文件:行号)**: + +| 文件 | 行号 | 内容(已脱敏) | 性质 | +|---|---|---|---| +| `docs/acceptance-week1.md` | 94 | `# Mock 登录(从日志获取验证码)` | 历史验收记录 | +| `docs/acceptance-week1.md` | 148 | 注: 验证码从应用日志中获取 | 历史验收记录 | +| `docs/acceptance-week1.md` | 152 | 验证码打印到日志,不发送真实短信 | 历史验收记录 | +| `docs/api-v0.1.md` | 27 | Mock 环境验证码打印到日志 | 历史 API 文档 | +| `docs/api-v0.1.md` | 174 | 短信验证码为 Mock 实现,验证码打印到日志 | 历史 API 文档 | +| `docs/frontend-integration.md` | 37,39,42,144 | "Mock 验证码日志位置"、"打印在服务控制台/stdout 日志中"、旧示例行 `MOCK SMS CODE: phone=138****0002, … code=******` | **前端接入文档(在途修改文件,本轮不可改)** | +| `docs/offer-api.md` | 262 | 测试账号(Mock 验证码,见日志) | 活文档 | +| `docs/resume-api.md` | 189 | 登录学生账号(Mock 验证码,见日志) | **在途修改文件** | +| **`docs/smoke-test.sh`** | **64-71** | **`sms_code()`:`grep -a "MOCK SMS CODE.*phone=$PHONE" $log \| … \| grep -oE 'code=[0-9]{6}'`** | **git 跟踪的既有可执行脚本(见 F1)** | + +`README.md:167` 表述正确(禁止写入敏感信息),**不计入违规**;`docs/openapi-rc.json:1449` 已修正 ✅。 + +--- + +## 12. RabbitMQ 与外围终态(阶段 14) + +| 项 | 实测值 | +|---|---| +| 队列 | `queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq` | +| ready / unacked | **全部 0 / 0** | +| mock=false 失败后 notifications 增量 | **0** | +| mock=false 失败后 SMS_SEND 审计增量 | **0**(无虚假发送成功审计) | +| 匿名岗位写请求副作用 | jobs/notifications/audit 均无新增 | +| 无与 S01 无关的表变化 | ✅(仅本轮验收夹具,已精确清理) | +| 容器终态 | 3 容器仍 Up(healthy);**未停止 Docker** | + +--- + +## 13. PASS / FAIL / SKIP 清单 + +| # | 验收项 | 结果 | +|---|---|---| +| A1 | 验证码/手机号不进入日志与审计 | **PASS** | +| A2 | SMS 发送器装配(true/false/缺失) | **PASS** | +| A2′ | 空值边界下的 Bean 唯一性 | **FAIL**(F3) | +| A3 | mock=false 失败事务回滚(真实 MySQL) | **PASS**(F4 为非阻断观察) | +| A4 | CurrentUserAccessor 边界 | **PASS** | +| A5 | JwtAuthenticationFilter 边界 | **PASS** | +| A6 | /jobs 匹配顺序与权限矩阵 | **PASS** | +| A7 | 无 permitAll 可达的直接身份解析 | **PASS**(存量 43 处均不可匿名到达) | +| A8 | OpenAPI 与响应兼容 | **PASS** | +| B | 自动化复验(40/214/1658/clean verify) | **PASS** | +| C | Docker/MySQL/Flyway | **PASS** | +| D | 三种配置启动 | **PASS**(D1/D2/D3a)| +| E | 真实 MySQL 回滚 + mock=true 链路 | **PASS** | +| F | 真实 HTTP 权限矩阵 | **PASS** | +| G | 非法 Token/principal 黑盒 | **PASS** | +| H | 日志与审计脱敏(`password` 项除外) | **PASS** | +| I | 运行时 OpenAPI | **PASS** | +| 13 | 文档不再指导从日志读取验证码 | **FAIL**(F1、F2) | +| 14 | RabbitMQ 与外围绕 | **PASS** | +| — | SKIP | **0**(核心验收项无 SKIP) | + +**FAIL 计数:3**(F1、F2、F3) + +--- + +## 14. 缺陷详情(可复现) + +### F1(阻断)项目自带冒烟脚本因 S01 改动而失效 + +- **文件/方法**:`docs/smoke-test.sh` → `sms_code()`(第 64-71 行,git 跟踪,提交 `b01616d`) +- **缺陷**:该函数依赖被 S01 删除的日志行 `MOCK SMS CODE: phone=…, code=…` 提取验证码 +- **复现**: + ```bash + LOG=<本轮 mock=true 应用日志> + grep -a "MOCK SMS CODE.*phone=<验收手机号>" "$LOG" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2 + ``` +- **实际结果**:输出为空 → `SMS=""` → 后续登录步骤拿不到验证码,冒烟流程中断 +- **期望结果**:脚本应从数据库/受控夹具取得验证码(与 S01 新语义一致) +- **影响**:既有可执行验收工具**功能性回归**;S01 整改报告将其列为"文档遗留"但未识别此处为**可执行依赖** + +### F2(阻断,对应验收标准第 17 项)文档仍指导从日志读取验证码 + +- **文件/行号**:见第 11 节表格(`acceptance-week1.md:94,148,152`、`api-v0.1.md:27,174`、`frontend-integration.md:37,39,42,144`、`offer-api.md:262`、`resume-api.md:189`) +- **实际结果**:文档仍声称"验证码打印到日志/控制台"并指导据此取码 +- **期望结果**:明确"日志不再输出验证码";dev/test 通过**受控测试夹具或数据库内部验收方式**取码;禁止生产查询验证码表完成登录;说明 `mock=false` 且无真实供应商时返回服务不可用;真实短信供应商待单独接入 +- **约束**:`docs/frontend-integration.md`、`docs/resume-api.md` 属**在途修改文件**,本轮不得改动 + +### F3(非阻断但违反 A2 明确要求)空值配置导致启动失败 + +- **文件**:`auth/sms/MockSmsCodeSender.java`(`@ConditionalOnProperty(name="app.sms.mock.enabled", havingValue="true")`)与 `auth/sms/UnavailableSmsCodeSender.java`(`havingValue="false", matchIfMissing=true`) +- **缺陷**:属性**存在但为空值**(如 env 中 `SMS_MOCK_ENABLED=`)时,两个条件都不匹配 → **0 个 `SmsCodeSender` Bean** +- **复现**:`java -jar target/mydreamjob-server-*.jar --app.sms.mock.enabled=` (其余同 D2) +- **实际结果**: + ``` + APPLICATION FAILED TO START + Parameter 3 of constructor in com.mydreamjob.auth.SmsVerificationService required + a bean of type 'com.mydreamjob.auth.sms.SmsCodeSender' that could not be found. + ``` +- **期望结果**:任何取值(含空值/空白)都应至少装配一个实现,绝不因缺少 Bean 以模糊 Spring Bean 错误启动失败(原工作令第 11 条) +- **影响**:部署可用性风险(空 env 变量在生产会致全站不可启动);未造成数据泄露 + +### F4(观察,非阻断)失败后残留限流键 + +- 现象:mock=false 发送失败后 `sms:rate:` 残留(TTL 60s),60s 内重试返回 `code=400 sms rate limited` 而非 `sms sender unavailable` +- 定性:**既有排序**(限流键在发送前写入),S01 未改变该顺序;MySQL 无残留有效验证码;不属冻结阻断项 +- 建议:后续轮次评估失败时释放限流键或调整写入顺序 + +### F5(观察,非阻断,既有行为)Spring Boot 默认安全口令入日志 + +- 现象:日志出现 `Using generated security password: ` +- 定性:Spring Boot `UserDetailsServiceAutoConfiguration` 默认行为;**经历史日志比对确认 S01 之前即存在**(`mydreamjob-server/logs/backend-startup.log` 等) +- 可利用性:应用未启用 httpBasic/formLogin,该口令无 HTTP 认证入口消费 → 不可用于登录 +- 建议:后续轮次排除 `UserDetailsServiceAutoConfiguration` 或显式声明 `AuthenticationManager` + +--- + +## 15. 剩余风险 + +1. **F1/F2 未修复前,开发/测试人员仍会按文档从日志找验证码 → 必然失败并误判"系统故障"** +2. 存量 43 处直接身份解析(均不可匿名到达,但缺乏统一 `CurrentUserAccessor` 收口);`DeletionGuardInterceptor` 对 20 位数字溢出仍可能 500 +3. 验证码仍以明文存储于 `sms_verification_codes.code`(本轮未改表结构,属已知遗留) +4. 真实短信供应商未接入;生产 `mock=false` 下所有发码请求均安全失败(设计如此,但需产品确认上线策略) +5. 安全层 401 响应不含 `requestId`(既有 `AuthenticationEntryPoint` 固定载荷),与业务层错误响应格式不一致,不利于链路追踪 +6. 本轮验收脚本依赖 Git Bash 环境变量语义(vhost 转换问题已规避并记录) + +--- + +## 16. 是否允许冻结 S01 + +**不允许冻结。** + +自动化指标(1658/1658、clean verify、静态与运行时 OpenAPI 173/211)全部达成,核心安全目标(日志脱敏、`SMS_MOCK_ENABLED` 生效、匿名 POST /jobs 401、非法 subject 401 不回显、真实 MySQL 回滚)**均已独立复验通过**;但存在 3 项 FAIL(F1 可执行脚本回归、F2 文档违规指导、F3 空值启动失败),其中 F2 直接命中验收标准第 17 项,F1 为可执行功能回归。 + +--- + +## 17. S01 下一轮定向整改指令 + +### R1 —— 修复冒烟脚本取码方式(对应 F1) + +- **文件**:`docs/smoke-test.sh` +- **方法**:`sms_code()` +- **错误数据/逻辑**:`grep -a "MOCK SMS CODE.*phone=$PHONE" "$log_path" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2` +- **复现请求**:`bash docs/smoke-test.sh`(mock=true 环境,日志中已无该行) +- **实际结果**:返回空字符串,登录步骤失败 +- **期望结果**:改为受控来源取码,二选一—— + 1. 数据库内部查询(推荐,仅限本地/测试): + `SELECT code FROM sms_verification_codes WHERE phone=? AND purpose='login' AND used=0 ORDER BY id DESC LIMIT 1;` + 2. 或由测试夹具直接注入固定验证码记录后读取 +- **必须新增的失败测试**:新增独立脚本断言 `sms_code()` 在"日志不含 `MOCK SMS CODE`"时仍能返回 6 位码(当前实现必然失败) +- **禁止的绕过**:不得恢复 `MOCK SMS CODE` 日志行;不得让脚本读取生产日志取码 + +### R2 —— 修正文档中的取码指引(对应 F2,命中验收标准第 17 项) + +- **文件与行号**:`docs/acceptance-week1.md:94,148,152`;`docs/api-v0.1.md:27,174`;`docs/offer-api.md:262`;`docs/frontend-integration.md:37,39,42,144`;`docs/resume-api.md:189` +- **错误内容**:声称"验证码打印到日志/控制台"并指导据此取码(含旧示例行) +- **复现**:`rg -n "控制台|日志.*验证码|MOCK SMS|验证码.*日志|查看验证码" docs README.md` +- **实际结果**:上述 10 处仍存在 +- **期望结果**:统一替换为—— + - 日志不再输出验证码; + - dev/test 环境通过**受控测试夹具或数据库内部验收方式**取得验证码; + - **禁止生产环境查询验证码表完成登录**; + - `mock=false` 且未接入真实供应商时返回"服务不可用",绝不返回虚假成功; + - 真实短信供应商仍待单独接入。 +- **协调要求**:`docs/frontend-integration.md`、`docs/resume-api.md` 属**简历上传安全**在途修改文件,需与该项变更方**合并处理**,避免互相覆盖 +- **必须新增的失败测试**:新增文档门禁测试(或 CI 检查)断言 `docs/**` 与 `README.md` 不再匹配 `MOCK SMS CODE|验证码.*打印.*日志|控制台.*验证码|见日志.*验证码` +- **禁止的绕过**:不得删除或改写为同义表述;不得仅在 README 声明而保留违规正文 + +### R3 —— 空值配置不再导致启动失败(对应 F3) + +- **文件**:`auth/sms/MockSmsCodeSender.java`、`auth/sms/UnavailableSmsCodeSender.java`(必要时新增无条件兜底实现) +- **错误配置**:`app.sms.mock.enabled=`(存在但空) +- **复现请求**:`java -jar target/mydreamjob-server-0.0.1-SNAPSHOT.jar --app.sms.mock.enabled=`(DB/Redis/RabbitMQ 同 D2) +- **实际结果**:`APPLICATION FAILED TO START … required a bean of type 'SmsCodeSender' that could not be found` +- **期望结果**:启动成功并装配 `UnavailableSmsCodeSender`(空值、空白值、缺失、`false` 四者行为一致;仅显式 `true` 才装配 Mock) +- **必须新增的失败测试**(`SmsSenderConfigurationTest` 内新增,不改动既有断言): + - `emptyPropertyStillProvidesUnavailableSender` + - `blankPropertyStillProvidesUnavailableSender` + - `exactlyOneSenderBeanForEveryValueIncludingEmpty` +- **禁止的绕过**:不得让 Mock 成为 `matchIfMissing` 默认;不得用 `@Primary` 掩盖歧义;不得让应用以空 Bean 启动(必须真实拒绝发码,不得伪造成功) + +### R4 —— 非阻断建议(可与上轮合并,不单独阻断冻结) + +1. `DeletionGuardInterceptor:60` 增加溢出安全解析(复用 `CurrentUserAccessor` 语义) +2. 评估失败时释放 `sms:rate:` 限流键(F4) +3. 排除 `UserDetailsServiceAutoConfiguration` 或显式声明 `AuthenticationManager`(F5) +4. 统一为安全层 401 响应补充 `requestId`(便于链路追踪) + +--- + +## 18. 验收产物清单(`docs/acceptance/s01-20260911/`) + +| 文件 | 用途 | +|---|---| +| `launch.sh` | 三种配置启动器(true/false/MISSING/EMPTY) | +| `gen-token.js` | 验收专用 HS256 Token 离线生成(密钥仅取自环境变量,不落盘) | +| `matrix.sh` / `matrix-results.txt` | 阶段 F 权限矩阵脚本与结果 | +| `phase-g.sh` / `phase-g-results.txt` | 阶段 G 非法 Token 边界脚本与结果 | +| `snapshot-jobs.sql` / `jobs-before.txt` / `jobs-after.txt` / `jobs-before-g.txt` / `jobs-after-g.txt` | jobs 表完整字段快照与对比 | +| `snapshot-sms.sql` / `before-mockfalse.txt` / `after-mockfalse.txt` | 阶段 E 验证码表回滚对比 | +| `job-body.json` / `tamper-body.json` / `status-body.json` / `ok-body.json` | 请求体夹具(ASCII,避免 shell 编码问题) | +| `d1*/d2*/d3*` 日志 | 三次配置启动的应用日志与 stdout | +| `runtime-openapi.json` | 运行时 OpenAPI 快照 | +| `acc-fixture.txt` / `acc-prefix.txt` | 验收夹具 ID 记录(已清理) | +| `sensitive-markers.txt` | 敏感标记清单(内容未写入报告) | + +**验收数据清理**:按精确 ID 与前缀删除 users(7933-7937)/companies(22,23)/jobs(42,43)/sms_verification_codes(1),Redis 验收键已清理;**保留**本轮窗口内 2 条 audit_logs(append-only 合规记录,不做删除);`jobs` 总数回到基线 26。清理前后均确认目标归属,无模糊 DELETE。 + +--- + +**已复现具体缺陷,S01不可冻结;验收报告已包含下一轮定向整改指令。** diff --git a/docs/s01-independent-mysql-acceptance-20260914.md b/docs/s01-independent-mysql-acceptance-20260914.md new file mode 100644 index 0000000..86f2d58 --- /dev/null +++ b/docs/s01-independent-mysql-acceptance-20260914.md @@ -0,0 +1,288 @@ +# S01 最终真实 MySQL / Docker 增量复验报告(脱敏版) + +- **验收日期**:2026-09-14 +- **分支**:`fix/resume-upload-safety` +- **范围**:只验证 S01 第四轮整改(F6:`expires_at` → `expired_at`)后的**真实运行路径**;本轮**未修改任何代码** +- **结论**:**PASS** +- **本报告不含**:验证码值、完整手机号、JWT、`Authorization` 头、数据库口令、`.env` 内容 + +> 本轮为此前因 Docker Linux Engine 未运行而 BLOCKED 的复验。本次 Docker Engine 可用,全部真实黑盒已执行完成。 + +--- + +## 1. 基线与边界 + +| 项 | 实测 | +|---|---| +| 分支 | `fix/resume-upload-safety` | +| 暂存区 | **空**(`git diff --cached --name-only` 无输出) | +| `git diff --check` | 无空白错误(仅 CRLF 提示) | +| 迁移改动 | **无**(`git diff --name-only -- db/migration` 无输出;41 个文件,最大 `V41__message_recall.sql`) | +| 已跟踪改动 | 24 个(**全部来自此前几轮**:7 个 docs + 11 个 java main + 6 个 java test),本轮 **0 新增** | +| 删除文件 | **0** | +| 历史未跟踪文件 | 未删除、未移动、未覆盖 | + +本轮**未修改**:生产代码、现有测试、`docs/smoke-test.sh`、`V1–V41`;**未新增** Flyway/表/索引/`ErrorCode`/接口;**未恢复**验证码日志。 + +## 2. Docker 环境 + +| 项 | 值 | +|---|---| +| Docker Engine | 29.6.2(Docker Desktop 4.84.0,linux/amd64) | +| `mydreamjob-mysql` | **healthy**(mysql:8.4,127.0.0.1:3307→3306) | +| `mydreamjob-redis` | **healthy**(redis:7.4-alpine) | +| `mydreamjob-rabbitmq` | **healthy**(rabbitmq:3.13-management-alpine) | + +进入本轮时三个容器均为 `Exited`(2 天前停止)。经负责人明确授权后,对**已存在的具名容器**执行 `docker start`:**未重建容器、未改动配置、未触碰 volume(`mysql_data`/`redis_data`/`rabbitmq_data` 均保留)**。验收结束后容器保持运行,由负责人决定是否停止。 + +## 3. 真实 Schema 对账 + +真实 MySQL 执行 `SHOW COLUMNS FROM sms_verification_codes`(**应用账号 + `MYSQL_PWD`,非 root**): + +``` +id phone code purpose attempts max_attempts expired_at used created_at +``` + +三方对账**完全一致**: + +| 来源 | 列集合 | +|---|---| +| 真实 MySQL | `attempts code created_at expired_at id max_attempts phone purpose used` | +| 实体 `@Column` | 同上 | +| `V1__base_schema.sql` | 同上 | + +- **包含 `expired_at`** ✅ +- **不包含 `expires_at`** ✅ + +**反证**:`SELECT expires_at FROM sms_verification_codes LIMIT 1;` → `ERROR 1054 (42S22) Unknown column 'expires_at' in 'field list'`(退出码 1)。 + +## 4. 真实取码 SQL + +修复后的等价只读查询在真实 MySQL 执行: + +```sql +SELECT code FROM sms_verification_codes + WHERE phone = ? AND purpose = ? AND used = 0 + AND expired_at > NOW() + ORDER BY created_at DESC, id DESC LIMIT 1; +``` + +**退出码 = 0,无 `ERROR 1054`**。(查询返回行数按参数而定,本报告不展示任何取回的 `code`。) + +## 5. 最新 JAR 启动 + +| 项 | 值 | +|---|---| +| JAR | `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`(`mvn clean verify` 产物) | +| 启动参数 | `--spring.jpa.hibernate.ddl-auto=validate`、`--app.sms.mock.enabled=true`、`--server.port=8080` | +| 启动结果 | **`Started MyDreamjobApplication`(14.9 s)** | +| Flyway | 无错误(仅一条 MySQL 8.4 版本提示 WARN) | +| Schema validation | **无错误** | +| `GET /api/v1/health` | **HTTP 200,`status=UP`** | +| 运行时 OpenAPI | **173 paths / 211 operations** | + +端口 8080 在启动前空闲,无未知进程被停止。日志写入本轮新目录 `docs/acceptance/s01-20260914/`,未覆盖历史日志。 + +## 6. 真实完整冒烟链 + +以唯一虚构前缀 `1990914xxxx` 与隔离 requestId 执行: + +```bash +SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true bash docs/smoke-test.sh +``` + +结果:**PASS=10 FAIL=0 SKIP=1,exit=0** + +| 步骤 | 结果 | +|---|---| +| `GET /health` | PASS(200) | +| `POST /auth/sms/send` + `POST /auth/login` | **PASS**(200 / 200) | +| `GET /jobs?size=5` | PASS | +| `GET /jobs/public/{id}` | PASS | +| `GET /jobs/{id}/match` | PASS | +| `POST /applications` | PASS | +| `PATCH /applications/{id}/status` | **SKIP**(未提供 `COMPANY_TOKEN`,属脚本既有语义) | +| `GET /notifications` | PASS | +| `GET /notifications/unread-count` | PASS | +| `POST /notifications/{id}/read` | PASS | +| `POST /applications/{id}/withdraw` | PASS | + +逐项确认: + +1. 发码成功 ✅ +2. `smoke-test.sh` 经**真实 `docker exec`** 连真实 MySQL ✅ +3. 使用应用账号 `MYSQL_USER`,**未使用 root** ✅ +4. mysql argv **不含密码** ✅(见 §7) +5. SQL 使用 `expired_at` ✅ +6. 成功取得可用验证码,**未显示其值** ✅ +7. 真实 `POST /auth/login` 成功 ✅ +8. 获得 Access Token,**未输出** ✅ +9. 后续岗位/投递/通知步骤按夹具条件执行 ✅ +10. **未出现 `ERROR 1054`** ✅ +11. 未出现空验证码 ✅ +12. 未从日志读取验证码 ✅ +13. stdout/stderr 不含验证码、完整手机号、JWT、`Authorization`、数据库口令 ✅ + +**核心链路无 SKIP**:唯一 SKIP 是企业状态步骤,取码与登录核心链路均实际执行并通过。 + +**真实库侧证据**:该手机号在 `sms_verification_codes` 中产生 **1 行、`used=1`、`code` 长度 6** —— 真实 MySQL 中确实生成了 6 位验证码并被真实登录消费。 + +> **关键推论**:登录成功即证明真实执行的 SQL 使用了**有效列**。若仍是 `expires_at`,查询会报 `ERROR 1054`、取不到码,登录必然失败。 + +### `bash -x` 复验 + +以 `bash -x` 执行(新手机号):**exit=0,PASS=10 FAIL=0 SKIP=1**,捕获 stdout+stderr 后扫描,敏感命中数: + +| 标记 | 命中 | +|---|---| +| 实际验证码值 | **0** | +| 完整手机号 | **0** | +| `Authorization: Bearer` | **0** | +| `LOGIN_BODY=` | **0** | +| JWT 形态(`eyJ…`) | **0** | +| `ERROR 1054` | **0** | + +## 7. F5 真实环境 argv 证据 + +在真实查询执行期间,读取**容器内 mysql 进程的实际 argv**: + +``` +mysql -umydreamjob -N -B mydreamjob +``` + +- 使用**应用账号**(非 root)✅ +- **不含 `-p<密码>`** ✅ +- **不含 `--password`** ✅ +- 密码经 `MYSQL_PWD` 环境变量传递 ✅ + +## 8. mock=false 真实 MySQL 回滚 + +以 `--app.sms.mock.enabled=false` 重启后调用 `POST /auth/sms/send`: + +| 项 | 结果 | +|---|---| +| 响应 | `HTTP 400` + `{"code":400,"message":"sms sender unavailable"}` | +| 是否虚假成功 | **否** ✅ | +| 验证码表前后状态 | **完全一致**(该手机号 0 行;全局 `max_id` 994→994;总数 918→918) ✅ | +| 残留 `used=0` 验证码 | **无** ✅ | +| 新增虚假发送成功审计 | **0** 行 ✅ | +| 日志含手机号/验证码 | **0** 命中 ✅ | + +响应与上一轮(`docs/acceptance/s01-20260911/d2-sms-response.txt`)逐字一致。 + +## 9. 配置缺失 / 空值 / 非法值启动 + +| 用例 | 启动 | Bean 缺失错误 | 发码响应 | 是否落库 | +|---|---|---|---|---| +| 配置**缺失** | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ | +| `SMS_MOCK_ENABLED=`(**空值**) | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ | +| `SMS_MOCK_ENABLED=invalid-value`(**非法值**) | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ | + +**「恰好一个 `SmsCodeSender` 且为 `UnavailableSmsCodeSender`」在真实环境按排除法成立**: + +- 0 个 Bean → 启动会以 `NoSuchBeanDefinition` 失败;≥2 个 → 会以 `NoUniqueBeanDefinition` 失败;Mock 生效 → 发码成功且落库。 +- 实测:**启动成功** + **发码不可用** + **未落库**,三者互斥地锁定「恰好一个 `UnavailableSmsCodeSender`」。 + +(Bean 数量与类型的严格断言另由 `SmsSenderConfigurationTest`(真实 `ApplicationContextRunner`)在 §10 覆盖。) + +每次只停止本轮明确记录 PID 的 JAR,未触碰任何未知进程。 + +## 10. 日志脱敏扫描 + +扫描对象:**本轮全部新日志**(6 个应用日志 + stdout)**+ 冒烟 stdout/stderr**。 + +| 禁止项 | 命中 | +|---|---| +| 实际验证码(本轮真实产生的 2 个,逐个比对) | **0 / 0** | +| 完整手机号(6 个测试号逐个比对) | **0** | +| `Authorization: Bearer` | **0** | +| JWT 形态(`eyJ…`) | **0** | +| `MYSQL_PASSWORD` 实际值 / 数据库口令 | **0** | +| `LOGIN_BODY` | **0** | +| `MOCK SMS CODE` | **0** | +| `NumberFormatException` | **0** | +| `ERROR 1054` | **0** | + +**敏感标记命中合计 = 0。** + +扫描器有效性对照:注入探针(`MyDreamJob backend smoke test`)命中 **2**,证明扫描器在工作,而非空转的 0。 + +## 11. OpenAPI 与回归 + +| 项 | 结果 | +|---|---| +| `python docs/verify_openapi.py` | **All checks passed!** | +| 运行时 `/v3/api-docs` | **173 paths / 211 operations** | +| 静态 `docs/openapi-rc.json` | **173 paths / 211 operations** | +| 静/运行时一致 | **YES**(路径差集:静态独有 = 无,运行独有 = 无) | +| 是否新增取码接口 | **无**(含 "code" 的唯一路径是 `/admin/scoring/risk/rules/{ruleCode}`,为风控规则码,与验证码无关) | +| 接口结构变化 | **无** | + +Maven 安全专项: + +``` +mvn -Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest,SmsEmptyPropertyStartupIntegrationTest,SmsUnavailableSenderRollbackIntegrationTest" test +``` + +→ **Tests run: 61, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS** + +## 12. 数据清理 + +本轮产生的数据可按唯一虚构前缀 `1990914` 精确圈定(时间戳均为 2026-09-14): + +| 表 | 行数 | +|---|---| +| `users` | 2(验收虚构号码:`199****0101`、`199****0102`) | +| `sms_verification_codes` | 2 | +| `applications` | 2(均为 `WITHDRAWN`) | +| `notifications` | 4 | +| `user_roles` | 2 | +| `audit_logs` | 8 | + +**决策:保留本轮数据,不执行删除。** 理由: + +1. `audit_logs` 有 **8 行** `user_id` 指向这两个用户,且该表**无外键约束**;删除用户会使审计行成为孤儿。 +2. 本轮任务边界明确**禁止删除 `audit_logs` 行**。 +3. 保留可维持审计链一致;边界亦明确允许「无法安全确认时保留数据并记录」。 + +未执行任何模糊 `DELETE`、未清空表、未重置自增、未修改真实账号。上述足迹为精确记录,供后续人工处置。 + +## 13. 最终 Git 检查 + +| 项 | 结果 | +|---|---| +| `git status --short` | 24 个已跟踪改动(**与本轮开始时同一批**)+ 98 个未跟踪(既有残骸 + 本轮验收产物) | +| `git diff --cached --name-only` | **空**(未暂存) | +| `git diff --check` | 无空白错误 | +| `git diff --name-only -- db/migration` | **空**(V1–V41 未修改) | +| 删除文件 | **0** | +| commit / push | **未执行** | +| S02 | **未进入** | + +## 14. 验收结论 + +| # | 判据 | 结果 | +|---|---|---| +| 1 | Docker/MySQL 可用 | ✅ | +| 2 | 真实表包含 `expired_at` | ✅ | +| 3 | 真实取码 SQL 执行成功 | ✅ | +| 4 | 完整学生登录链成功 | ✅ | +| 5 | `bash -x` 无敏感泄露 | ✅ | +| 6 | mock=false 真实 MySQL 完整回滚 | ✅ | +| 7 | 缺失/空/非法配置安全回退 | ✅ | +| 8 | 运行时 OpenAPI 为 173/211 | ✅ | +| 9 | 无日志泄露 | ✅ | +| 10 | 核心项无 SKIP | ✅ | +| 11 | FAIL = 0 | ✅ | + +**S01独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。** + +--- + +## 遗留与观察(不阻断本轮结论) + +1. **验证码仍为数据库明文存储**(未改表结构,属已知遗留)。 +2. **真实短信供应商未接入**;生产 `mock=false` 下发码一律安全失败,需产品确认上线策略。 +3. **本轮测试账号保留在库**(见 §12),与其审计记录一致;如需清理应由人工按精确 ID 处置。 +4. `/api/v1/api-docs` 返回 500(`/api/v1/v3/api-docs` 正常 200)。该行为与 S01 无关,非本轮引入,记录待查。 +5. 三个容器在验收结束后**保持运行**(本轮仅 `docker start`,未重建、未删除 volume),是否停止由负责人决定。 diff --git a/docs/s01-remediation-f1f2f3-20260911.md b/docs/s01-remediation-f1f2f3-20260911.md new file mode 100644 index 0000000..8a491e5 --- /dev/null +++ b/docs/s01-remediation-f1f2f3-20260911.md @@ -0,0 +1,786 @@ +# S01 定向整改报告(F1 / F2 / F3) + +- **整改日期**:2026-09-11 +- **分支**:`fix/resume-upload-safety` +- **范围**:仅修复独立验收确认的 F1、F2、F3,并完成对应自动化与干净构建 +- **本报告不包含**:数据库口令、.env 内容、JWT、验证码值、完整手机号、明文测试口令、非法 principal 敏感标记原文 +- **整改期间状态**:`S01独立验收未通过,定向整改中。` + +--- + +## 1. F1 的修复方式 + +`docs/smoke-test.sh` 的 `sms_code()` **彻底删除**了对应用日志的依赖(grep `MOCK SMS CODE`),改为**双重保护 + 容器内部只读 SQL 查询**。 + +同时删除:`SERVER_LOG` 变量、`resolve_log()` 函数、全部日志 grep。未恢复任何验证码日志。 + +| 项 | 整改前 | 整改后 | +|---|---|---| +| 取码来源 | `grep -a "MOCK SMS CODE.*phone=$PHONE" <日志>` | 容器内 `SELECT code FROM sms_verification_codes …` | +| 前置授权 | 无 | `SMS_MOCK_ENABLED=true` **且** `ALLOW_TEST_OTP_DB_READ=true` | +| 取码返回值 | stdout(进入 command substitution) | `REPLY` 变量约定(不打印) | +| 请求体传参 | `-d "<含验证码>"`(进入进程参数) | `--data-binary @-`(经 stdin) | +| 凭据处理 | 需 `SERVER_LOG` | 容器自身 `MYSQL_ROOT_PASSWORD` / `MYSQL_DATABASE`(不在本机命令行拼写口令) | +| 清理 | 无 | `unset REPLY/SMS/LOGIN_BODY` + `trap cleanup_secrets EXIT` | + +脚本仍为 Bash,兼容当前 Git Bash;`bash -n` 语法校验通过。 + +## 2. smoke-test.sh 的新取码机制 + +``` +sms_code() + ├─ require_mock_sms_mode() # SMS_MOCK_ENABLED 必须为 true + ├─ require_test_otp_db_read_permission() # ALLOW_TEST_OTP_DB_READ 必须为 true + ├─ 校验 PHONE 为 11 位纯数字(防注入) + ├─ 记录并关闭 xtrace(set +x),执行后精确恢复原状 + ├─ IFS= read -r code < <(otp_db_query ...) # 不使用 command substitution 取值 + ├─ 校验 code 为 6 位数字(当前 code-length / CODE_PATTERN) + ├─ 失败 → unset code + 固定提示 + return 1(绝不携带空值继续登录) + └─ 成功 → REPLY="$code"; unset code; return 0 +``` + +`otp_db_query()` 关键点: + +- **容器名不硬编码**:`compose_mysql_container()` 用 `awk` 解析项目实际 `backend-infra/compose.yaml` 中 `mysql` 服务的 `container_name`;用 `BASH_SOURCE` 定位自身目录(被 `source` 时同样正确) +- SQL 经 **stdin**(heredoc 风格 `printf | docker exec -i` ),不出现在任何进程参数中 +- 查询条件完整:`phone` + `purpose` + `used = 0` + `expired_at > NOW()` + (更正:本行原写作 `expires_at`,该列在 `sms_verification_codes` 中**不存在**,是 F6 记录的错误字段名;已于第四轮一并更正,详见文末「第四轮定向整改(F6)」) +- `ORDER BY created_at DESC, id DESC LIMIT 1`,只取 `code` 列 +- 不使用 `curl -v`;失败诊断不打印请求体 + +## 3. 为什么不会把验证码输出到终端 / 日志 / 文件 + +1. 取码函数**从不 `echo`/`printf` 验证码**,只写 `REPLY`;调用方 `SMS="$REPLY"` 后立即 `unset REPLY` +2. `set +x` 包住取值与查询,且**按调用前状态精确恢复**,不无条件开启 xtrace +3. 登录请求体经 `--data-binary @-` 从 stdin 送入 curl,验证码不进入 argv +4. 请求体只在 `LOGIN_BODY` 局部变量中存在,用完 `unset`;**不落盘** +5. `trap cleanup_secrets EXIT` 兜底清理 `OTP/REPLY/LOGIN_BODY` +6. 脚本控制台只输出 `PHONE=$(mask_phone "$PHONE")`(脱敏为 `199****9999`),不打印完整手机号 +7. 应用侧本就不输出验证码(S01 已整改),Mock 发送器仅输出固定文本 + +## 4. `ALLOW_TEST_OTP_DB_READ` 的强制条件 + +```bash +is_true() { v=$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]'); [ "$v" = "true" ]; } +require_mock_sms_mode() { is_true "${SMS_MOCK_ENABLED:-}"; } +require_test_otp_db_read_permission() { is_true "${ALLOW_TEST_OTP_DB_READ:-}"; } +``` + +- 两者同时为 `true`(大小写/空白不敏感)才允许查询 +- 任一不满足:立即返回非 0,输出固定提示 `test OTP retrieval is disabled`,**不执行任何 docker/SQL 调用** +- 提示中不含手机号、验证码、数据库口令、SQL 结果或容器名 + +## 5. 未授权取码的实际结果 + +| 场景 | 实际结果 | +|---|---| +| `SMS_MOCK_ENABLED` 缺失 | 拒绝;输出 `test OTP retrieval is disabled`;**docker 未被调用** | +| `SMS_MOCK_ENABLED=false` | 拒绝;同上 | +| `ALLOW_TEST_OTP_DB_READ` 缺失 | 拒绝;同上 | +| `ALLOW_TEST_OTP_DB_READ=false` | 拒绝;同上 | +| 两者为 true 但无可用验证码 | `test OTP retrieval failed`,**不继续登录** | +| 两者为 true 但查询失败/结果非 6 位数字 | 同上,**不继续登录** | + +## 6. F2 修改的全部文档与行 + +| 文件 | 行(整改后) | 修改前 | 修改后要点 | +|---|---|---|---| +| `docs/acceptance-week1.md` | 94 | `# Mock 登录(从日志获取验证码)` | 指向受控取码方式 | +| `docs/acceptance-week1.md` | 148 | 注: 验证码从应用日志中获取 | 明确日志不输出;列出双开关;生产禁止查表 | +| `docs/acceptance-week1.md` | 152 | 验证码打印到日志 | 明确不输出;标注明文存储遗留 | +| `docs/api-v0.1.md` | 27 | Mock环境验证码打印到日志 | 明确不输出 + 受控取得 | +| `docs/api-v0.1.md` | 174 | 验证码打印到日志 | 明确不输出;未接入供应商返回服务不可用 | +| `docs/frontend-integration.md` | 37-49(§3 整节重写) | 「Mock 验证码日志位置」+ 旧示例行 `MOCK SMS CODE: … code=******` + 「看终端输出/看日志文件/取最新一条」 | 改为「本地获取测试验证码(不通过日志)」:双开关 + 受控脚本 + 生产禁止查表 + 明文遗留风险 | +| `docs/frontend-integration.md` | 149 | 验证码打印在服务日志 | 改为不再输出 + 受控取得 | +| `docs/offer-api.md` | 262 | 验证码见应用日志 | 改为受控取得 + 生产禁止查表 | +| `docs/resume-api.md` | 189 | 登录学生账号(Mock 验证码,见日志) | 改为受控取得 + 生产禁止查表 | +| `docs/smoke-test.sh` | 全文头部与取码段 | 依赖日志取码 | 见第 1-5 节 | + +**文档统一口径(已落实)**: + +1. 删除所有「从控制台/日志查看验证码」的操作指导 +2. 明确应用日志不会输出验证码 +3. dev/test 本地自动化使用受控测试夹具/数据库内部只读查询 +4. 冒烟脚本仅在 `mock=true` **且** `ALLOW_TEST_OTP_DB_READ=true` 时查表 +5. 真实生产环境**禁止**查询验证码表辅助登录 +6. `mock=false` 且无真实供应商时返回「服务不可用」 +7. 明确当前尚未接入真实短信供应商 +8. **未声称**验证码已加密/哈希存储 +9. 明确验证码当前仍为数据库**明文**遗留风险 +10. 示例中不含真实手机号、验证码、JWT 或数据库口令 + +**在途文件保护**:`docs/frontend-integration.md` 与 `docs/resume-api.md` 仅改动取码相关段落;`git diff` 复核确认简历上传加固内容(文件名净化、PDF/DOC/DOCX 结构校验、上传替换一致性、恶意软件扫描边界等)**逐字保留**,未覆盖、未还原、未重新格式化。 + +## 7. 全仓遗留搜索结果 + +``` +grep -rn -i "MOCK SMS CODE|控制台.*验证码|验证码.*打印到日志|从日志获取|查看.*验证码" + docs README.md mydreamjob-server/src +``` + +- **「打印到日志 / 从日志获取」命中数:0** +- `MOCK SMS CODE` 在 `mydreamjob-server/src` 与 `docs/` 中的命中数:**0** +- 剩余匹配全部为**禁止性表述或无关标识符**(如「日志不得包含验证码」「不得写入日志」「SMS_CODE_INVALID」枚举名) +- `README.md:167` 本身即「不写入敏感信息」的正确约定,未修改 + +## 8. F3 使用的 Bean 装配方案 + +采用**推荐的单 `@Configuration` + 单 `@Bean` 方案**(不再依赖两套互斥 `@ConditionalOnProperty`): + +```java +@Configuration +public class SmsSenderConfiguration { + private static final String MOCK_ENABLED_VALUE = "true"; + + @Bean + public SmsCodeSender smsCodeSender( + @Value("${app.sms.mock.enabled:false}") String rawEnabled) { + if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) { + return new MockSmsCodeSender(); + } + return new UnavailableSmsCodeSender(); + } +} +``` + +- `MockSmsCodeSender` / `UnavailableSmsCodeSender` 已移除 `@Component` 与 `@ConditionalOnProperty`,成为普通类 +- 满足:始终恰好一个 Bean;仅显式 `true` 启用 Mock;非法值不启用 Mock;**不根据 profile 偷偷启用**;`application-dev.yml` 仍显式 `true`;`application.yml` 默认 `false` +- 不把选择逻辑放回 `SmsVerificationService`;不新增第二套发送流程;不用 try/catch Bean 缺失兜底;不在每次发送时读配置;**不输出配置原值到日志** + +## 9. 各配置取值对应的实现 + +| `app.sms.mock.enabled` | 装配实现 | +|---|---| +| `true` / `TRUE` / `True` / `" true "` | `MockSmsCodeSender` | +| `false` | `UnavailableSmsCodeSender` | +| 缺失 | `UnavailableSmsCodeSender` | +| 空字符串(`=`) | `UnavailableSmsCodeSender` | +| 仅空白(`= `) | `UnavailableSmsCodeSender` | +| 非法值(`yes`/`1`/`on`/`enabled`/`null`/`mock`/`TRUE1`) | `UnavailableSmsCodeSender`(安全回退,**绝不启用 Mock**) | + +## 10. 每种配置的 Bean 数量 + +由 `SmsSenderConfigurationTest.everySupportedConfigurationCreatesExactlyOneSenderBean` 断言(真实 `ApplicationContextRunner`,含 9 种取值 + 缺失属性): + +**每种取值 `getBeanNamesForType(SmsCodeSender.class).length == 1`**。 + +## 11. 空值启动结果 + +| 层级 | 结果 | +|---|---| +| 快速装配测试(`ApplicationContextRunner`) | 上下文**启动成功**;恰好一个 Bean;类型 `UnavailableSmsCodeSender`;消费者构造注入成功;发送抛固定安全 `SERVICE_UNAVAILABLE` | +| **全应用上下文测试**(`SmsEmptyPropertyStartupIntegrationTest`,真实 H2 + Flyway + 全 Bean) | 应用上下文**启动成功**;恰好一个 `SmsCodeSender`;类型 `UnavailableSmsCodeSender`;`SmsVerificationService` **可注入**;发送返回固定安全服务不可用 ✅ | + +整改前该取值导致 `APPLICATION FAILED TO START … required a bean of type 'com.mydreamjob.auth.sms.SmsCodeSender' that could not be found`(独立验收阶段已复现并记录在验收报告 F3)。 + +> 本轮按要求**未操作 Docker / 未执行真实 MySQL 黑盒**,故未做真实 JAR 启动;全应用上下文启动已由 H2+Flyway 覆盖,真实环境启动仍待独立复验。 + +## 12. 是否恢复验证码日志 + +**未恢复。** `grep -rn "MOCK SMS CODE" mydreamjob-server/src` 命中 0;`MockSmsCodeSender` 仅输出固定文本 `SMS verification code generated (mock sender)`;`SmsVerificationService` 仅两处 `log.warn`,参数为 `maskPhone(phone)`;`AuthService` 仅记录 `id`。 + +## 13. 原 S01 权限与事务回归结果 + +| 原 S01 要求 | 结果 | +|---|---| +| 不恢复验证码日志 | ✅(第 12 节) | +| 不输出完整手机号 | ✅ `SmsVerificationSecurityTest` 10/10 | +| Mock 发送器不打印验证码 | ✅ | +| mock=false 不返回虚假成功 | ✅ `SmsSenderConfigurationTest` + 回滚集成测试 | +| mock=false 发送失败事务回滚 | ✅ `SmsUnavailableSenderRollbackIntegrationTest` 3/3(真实 Spring + H2 + Flyway:失败后该手机号**无任何验证码行**、总数不变、无虚假成功审计) | +| 匿名 POST /jobs = 401 | ✅ `AuthenticationBoundaryIntegrationTest` 15/15 | +| STUDENT/ADMIN 创建岗位 = 403/2002 | ✅ | +| COMPANY 合法创建保持成功 | ✅ | +| 非法 principal 返回 401 | ✅ `CurrentUserAccessorTest` 11/11 | +| 不返回 NumberFormatException / 内部 principal | ✅ | +| OpenAPI 173/211 | ✅ | +| 无新增公网取码接口 | ✅(OpenAPI 173/211 不变) | +| 无新 Flyway/表/索引/ErrorCode | ✅ | + +## 14. Shell 测试数量与结果 + +`docs/tests/smoke-test-sms-code-test.sh`(新增):**22 用例,PASS=22 / FAIL=0** + +覆盖:mock=true+ALLOW=true 可取码、mock=false 拒绝、配置缺失拒绝、ALLOW 缺失/为 false 拒绝、无可用验证码安全失败、查询失败不继续、结果非数字/长度非法拒绝、查询为空时不继续登录(无 login 请求)、stdout/stderr 不含验证码、不含完整手机号、生成文件不含验证码、不 grep 应用日志、不存在 `MOCK SMS CODE`、REPLY/SMS/LOGIN_BODY 清理与 EXIT trap 存在、未授权时不触碰数据库、拒绝提示不含手机号、**授权模式下可完成登录流程(登录请求已发出且携带 6 位码、登录步骤判定 PASS)**。 + +测试通过 PATH 注入假 `docker`/`curl` 实现,**不要求 Docker 或真实 MySQL**,仓库内不写入真实验证码,结束时扫描输出确认标记未泄露。 + +## 15. Maven 专项、关联与全量测试数 + +| 命令 | Tests run | +|---|---| +| `-Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest"` | **56** | +| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **222** | +| `mvn test`(最终,含本轮新增 2 个集成测试) | **1671** | +| `mvn clean verify` | **1671** | + +S01 相关类明细: + +| 测试类 | Tests | +|---|---| +| `SmsSenderConfigurationTest`(F3,由 4 扩展至 12) | 12 | +| `SmsVerificationSecurityTest` | 10 | +| `SmsVerificationServiceTest` | 8 | +| `CurrentUserAccessorTest` | 11 | +| `AuthenticationBoundaryIntegrationTest` | 15 | +| `SmsUnavailableSenderRollbackIntegrationTest`(本轮新增) | 3 | +| `SmsEmptyPropertyStartupIntegrationTest`(本轮新增) | 2 | + +## 16. Failures / Errors / Skipped + +**Failures=0、Errors=0、Skipped=0**(全量与 clean verify 一致) + +## 17. clean verify 结果 + +`mvn clean verify` → **BUILD SUCCESS**(4:34),产出 `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。 + +## 18. OpenAPI paths / operations + +| 来源 | paths | operations | +|---|---|---| +| 静态 `docs/verify_openapi.py` | **173** | **211** | +| 校验输出 | `All checks passed!` | — | + +未新增/删除路径或 operation;无新增测试取码端点。 + +## 19. 精确修改文件 + +**修改(已跟踪,本轮 F1/F2)** + +| 文件 | 变更 | +|---|---| +| `docs/smoke-test.sh` | +176/−…(取码机制整体替换、双保护、脱敏输出、库模式钩子) | +| `docs/acceptance-week1.md` | 3 处取码表述 | +| `docs/api-v0.1.md` | 2 处取码表述 | +| `docs/frontend-integration.md` | §3 整节重写 + 1 处(**在途文件,最小改动**) | +| `docs/offer-api.md` | 1 处取码表述 | +| `docs/resume-api.md` | 1 处取码表述(**在途文件,最小改动**) | + +**修改(S01 新建文件,本轮扩展,F3)** + +| 文件 | 变更 | +|---|---| +| `auth/sms/MockSmsCodeSender.java` | 移除 `@Component`/`@ConditionalOnProperty`,改为普通类 | +| `auth/sms/UnavailableSmsCodeSender.java` | 同上 | +| `auth/SmsSenderConfigurationTest.java` | 由 4 用例扩展至 12(真实 ApplicationContextRunner + 消费者探针) | + +**新增** + +| 文件 | 用途 | +|---|---| +| `auth/sms/SmsSenderConfiguration.java` | F3 单 Bean 装配 | +| `auth/SmsUnavailableSenderRollbackIntegrationTest.java` | 真实 Spring+H2:失败不残留验证码 | +| `auth/SmsEmptyPropertyStartupIntegrationTest.java` | 空值全应用上下文启动 | +| `docs/tests/smoke-test-sms-code-test.sh` | F1 shell 安全测试(22 用例) | + +## 20. Git 状态 + +- 分支:`fix/resume-upload-safety`(未变) +- **暂存区:空** +- `git diff --check`:无空白错误(仅 CRLF 提示) +- 迁移改动:**无**(V1–V41 共 41 个文件,最大版本 41) +- 删除文件:**0** +- 历史未跟踪文件:未删除、未移动、未覆盖(含 `docs/acceptance/s01-20260911/` 全部验收证据 43 个文件与验收报告,保持原样) + +## 21. 是否修改 Flyway、表、ErrorCode 或公网接口 + +**均未修改。** + +- Flyway:无新增/修改,V1–V41 原样 +- 数据库表/索引:未新增或修改(验证码仍明文存储于 `sms_verification_codes`,属已记录遗留风险) +- `ErrorCode`:未被改动 +- 公网接口:OpenAPI 保持 173 paths / 211 operations;未新增取码接口 + +## 22. 明确未执行 git add -A / commit / push + +**未执行** `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。 + +## 23. 明确未操作 Docker、未执行真实 MySQL 黑盒 + +**未执行任何 Docker 命令**、**未执行真实 MySQL 黑盒**。本轮全部验证均为:shell 测试(假 docker/curl 注入)+ Maven 单元/集成测试(H2 + Flyway + 真实 Spring 上下文)。 + +## 24. 真实 MySQL / Docker 复验仍待独立验收 + +F1 的数据库取码路径(`docker exec` + 容器内 SQL)**尚未在真实 MySQL 环境端到端执行**;F3 的运行期 JAR 启动(含空值 / 缺失 / 非法值)亦未在真实环境启动验证。二者**必须由独立复验在真实 MySQL/Docker 环境完成**,不得以本轮 H2 结果代替。 + +--- + +## 遗留与后续 + +1. **验证码仍为数据库明文存储**(本轮未改表结构,属已知遗留) +2. **真实短信供应商未接入**;生产 `mock=false` 下发码一律安全失败,需产品确认上线策略 +3. **存量 43 处直接身份解析**未收口(均不可匿名到达;`DeletionGuardInterceptor` 对 20 位数字溢出仍可能 500)——非本轮范围 +4. **安全层 401 响应不含 requestId**(既有 `AuthenticationEntryPoint` 固定载荷),与业务层错误响应格式不一致 +5. 上一轮验收的其余观察项(失败后残留 `sms:rate` 限流键 60s;Spring Boot 默认 `generated security password` 入日志)**本轮未处理**,仍属非阻断遗留 + +--- + +# 第三轮定向整改(F4 / F5) + +- **整改日期**:2026-09-11(第二轮之后) +- **独立复验结论**:S01 仍 FAIL —— F2/F3 已通过,但新发现 F4(xtrace 泄露)与 F5(数据库密码进入 mysql argv)两个阻断项 +- **本轮范围**:仅修 F4/F5,并相应扩展 shell 测试、修正文档测试口径 +- **本轮修改文件**:`docs/smoke-test.sh`、`docs/tests/smoke-test-sms-code-test.sh`、本报告 +- **本轮未触碰**:S01 Java 生产代码(时间戳仍为 15:45,本轮为 17:38+ 仅改 docs) + +## T1. F4 修复前的泄露位置 + +以 `bash -x` 执行时,以下位置被 trace 到 stderr(原文来自复验报告,此处仅列位置): + +| 位置 | 泄露内容 | 原因 | +|---|---|---| +| `PHONE="${STUDENT_PHONE}"` / 生成分支 | 完整手机号 | 脚本开头未关闭 xtrace | +| `SMS="$REPLY"` | 验证码 | `sms_code()` 返回前 `set -x` 恢复,赋值被 trace | +| `LOGIN_BODY="{...code...}"` | 验证码 + 完整手机号 | 赋值被 trace | +| `printf '%s' "$LOGIN_BODY" \| curl ...` | 登录请求体(含验证码) | 命令与参数被 trace | +| `LOGIN="$(...)"` | 登录响应全文(含 JWT) | 赋值被 trace | +| `TOKEN="$(extract_token "$LOGIN")"` | JWT | 赋值被 trace | +| `AUTH="Authorization: Bearer $TOKEN"` | JWT | 赋值被 trace | +| 后续 `curl ... -H "$AUTH"` | JWT | 命令参数被 trace | +| `sms_code()` 内部 `set +x` / `set -x` | 只保护了取码阶段 | 保护范围不足 | + +## T2. 全局 xtrace 处理方式 + +采用「**开头立即关闭且全程不恢复**」(任务书推荐方案): + +```bash +#!/usr/bin/env bash + +# ── xtrace 防护(S01 第三轮 F4)── +# 必须在读取任何环境变量、组装任何请求之前关闭 xtrace。 +# 脚本是独立可执行程序,安全优先:全程不重新 set -x。 +case "$-" in + *x*) set +x ;; +esac + +set -u +``` + +- 位置:`set +x` 位于**任何环境变量读取与任何请求组装之前**(仅在 shebang 与注释之后) +- `sms_code()` 内部的 `xtrace_was_on` 与恢复 `set -x` 逻辑**已彻底删除**(`grep -c xtrace_was_on` = 0) +- 脚本中**不存在可执行的 `set -x`**(仅 1 处注释提及,测试 F4-9 已断言) +- 保护范围覆盖全脚本:验证码取码、手机号、请求体、登录响应、TOKEN、AUTH 及**后续所有携带 Authorization 的 curl 调用** +- 未使用 `curl -v`/`--trace`/`--trace-ascii`(测试第 20 条断言) +- 登录响应全文不再交给打印函数:新增 `extract_status_fields()` + `report_fields()`,登录步骤只把 http / business code / requestId 传出去(测试第 21 条断言) + +## T3. `bash -x` 真实执行结果 + +由测试脚本内部驱动(捕获被测脚本 stdout+stderr 后扫描标记,只输出 PASS/FAIL): + +| 用例 | 结果 | +|---|---| +| F4-1 bash -x 下不泄露验证码 | PASS | +| F4-2 bash -x 下不泄露完整手机号 | PASS | +| F4-3 bash -x 下不泄露 JWT | PASS | +| F4-4 bash -x 下不泄露 Authorization 头 | PASS | +| F4-5 bash -x 下不泄露数据库密码 | PASS | +| F4-6 bash -x 下不泄露 LOGIN_BODY | PASS | +| F4-7 bash -x 下正常流程仍可完成登录 | PASS | +| F4-8 bash -x 下登录步骤判定 PASS | PASS | +| F4-9 脚本中不存在可执行的 set -x | PASS | +| F4-10 失败流程(无可用验证码)同样不泄露 | PASS | +| F4-11 source 模式下 REPLY 契约可用 | PASS | +| F4-12 source 模式下清理后无敏感变量残留 | PASS | +| F4-13 source 模式下 EXIT trap 已登记清理函数 | PASS | +| F4-14 cleanup_secrets 覆盖 PHONE/TOKEN/AUTH/COMPANY_TOKEN | PASS | + +另外单独执行 `bash -x docs/tests/smoke-test-sms-code-test.sh` → exit=0、PASS=51/FAIL=0; +该模式下测试脚本自身的假值可能被 trace(任务书已明确属预期,不作为安全判定), +但**测试结论输出(stdout)中标记值命中数为 0**。 + +## T4. stdout/stderr 敏感标记命中数 + +以 `bash -x` 执行被测 `smoke-test.sh`(正常流程与失败流程各一次),对捕获的 stdout+stderr 递归扫描: + +| 标记类别 | 命中数 | +|---|---| +| 测试验证码 | **0** | +| 完整手机号 | **0** | +| 测试 JWT | **0** | +| `Authorization: Bearer` | **0** | +| 数据库密码标记 | **0** | +| `LOGIN_BODY=` | **0** | + +(报告不记录标记值本身;标记值均为测试用固定假值,仅存在于测试脚本内。) + +## T5. TOKEN / AUTH 生命周期 + +| 变量 | 生命周期 | +|---|---| +| `REPLY` | `sms_code()` 成功时写入;调用方立即 `SMS="$REPLY"; unset REPLY` | +| `SMS` | 组装 `LOGIN_BODY` 后立即 `unset SMS` | +| `LOGIN_BODY` | 经 `--data-binary @-` 送入 curl 后立即 `unset LOGIN_BODY` | +| `LOGIN`(响应全文) | 仅用于提取 http/code/requestId 与 token;`extract_token` 后立即 `unset LOGIN` | +| `LOGIN_FIELDS` | 只含可安全打印的三字段,用完 `unset` | +| `TOKEN` | **保留到流程结束**(后续接口需要),但不打印、不写文件、不进验收产物 | +| `AUTH` | 由 TOKEN 派生,仅作为 curl 头参数使用;EXIT trap 统一清理 | +| `COMPANY_TOKEN` | 调用方传入,仅作 curl 头参数;EXIT trap 统一清理 | +| `PHONE` / `STUDENT_PHONE` | EXIT trap 统一清理;控制台只输出脱敏形式 | + +`cleanup_secrets()` 现覆盖:`PHONE SMS OTP REPLY LOGIN_BODY LOGIN TOKEN AUTH` 与 `STUDENT_PHONE COMPANY_TOKEN`,由 `trap cleanup_secrets EXIT` 统一清理。 +注意:登记块**不再**给 `PHONE`/`STUDENT_PHONE`/`COMPANY_TOKEN` 赋空值(避免覆盖调用方传入值)。 + +## T6. F5 修复前后的 mysql 调用形式 + +**修复前** + +```bash +docker exec -i "$container" sh -c \ + 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -B "${MYSQL_DATABASE:-mydreamjob}"' +``` +容器内 shell 会把 `$MYSQL_ROOT_PASSWORD` 展开为 `-p<真实密码>`,进入 mysql 进程 argv。 + +**修复后** + +```bash +docker exec -i "$container" sh -c ' + if [ -z "${MYSQL_USER:-}" ] || [ -z "${MYSQL_PASSWORD:-}" ]; then + echo "db credentials unavailable" >&2 + exit 3 + fi + MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \ + -u"$MYSQL_USER" \ + -N -B \ + "${MYSQL_DATABASE:-mydreamjob}"' 2>/dev/null \ +| head -n 1 +``` + +## T7. mysql argv 是否含密码 + +测试用假 `mysql` 记录自身真实 argv(它正是被检查的进程),实测结果形态为: + +``` +-u<应用账号> -N -B <数据库名> +``` + +| 断言 | 结果 | +|---|---| +| F5-1 mysql argv 不含数据库密码值 | PASS | +| F5-2 宿主机 docker 命令行不含密码值 | PASS | +| F5-3 容器侧脚本文本不含密码值 | PASS | +| F5-4 容器侧脚本以变量名引用 MYSQL_PASSWORD | PASS | +| F5-5 mysql argv 不含 `-p<密码>` | PASS | +| F5-6 mysql argv 不含 `--password` | PASS | +| F5-13 密码不经 argv 传递 | PASS | +| F5-14 密码经 `MYSQL_PWD` 环境变量传递 | PASS | + +## T8. 使用的数据库账号类型 + +- 使用**应用账号**(容器环境变量 `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE`) +- **不使用 root**;`MYSQL_ROOT_PASSWORD` 在脚本中命中数为 0,`-uroot` 命中数为 0(F5-7 / F5-8 PASS) +- 凭据缺失时 `exit 3` 安全失败,**不回退 root**(F5-15 PASS) +- 未读取、未打印、未落盘凭据;未修改 compose.yaml;未修改 MySQL 用户权限;查询仅 `SELECT`(F5-11/F5-12 PASS) + +## T9. Shell 测试实际数量 + +`docs/tests/smoke-test-sms-code-test.sh`:**51 用例,PASS=51 / FAIL=0** + +构成:F1 取码保护 10 项 + F4 xtrace/源模式 14 项 + F5 argv 18 项 + 静态保证与文档门禁 9 项。 + +## T10. Maven 专项 / 关联 / 全量数量 + +| 命令 | Tests run | +|---|---| +| 7 类安全专项(含空值与回滚集成测试) | **61** | +| `*Auth*Test,*Security*Test,*Sms*Test,*Job*Test` | **227** | +| `mvn test` | **1671** | +| `mvn clean verify` | **1671** | + +## T11. Failures / Errors / Skipped + +**Failures=0、Errors=0、Skipped=0**(专项、关联、全量、clean verify 全部一致) + +## T12. clean verify + +`mvn clean verify` → **BUILD SUCCESS**(4:34),产出最新 JAR。 + +## T13. OpenAPI 数量 + +**173 paths / 211 operations**(`All checks passed!`);本轮未改 Controller 映射、未新增取码接口。 + +## T14. 精确修改文件(本轮) + +| 文件 | 变更 | +|---|---| +| `docs/smoke-test.sh` | 开头新增全局 xtrace 关闭(全程不恢复);删除 `xtrace_was_on`/恢复逻辑;`otp_db_query` 改为应用账号 + `MYSQL_PWD`(去 root、去 `-p`);`cleanup_secrets` 覆盖面扩展;新增 `report_fields`/`extract_status_fields`,登录响应不再交给打印函数 | +| `docs/tests/smoke-test-sms-code-test.sh` | 扩展至 51 用例:真实 `bash -x` 执行与标记扫描、假 docker 执行容器侧脚本 + 假 mysql 记录 argv、source 模式泄漏防护、活跃文件文档门禁 | +| `docs/s01-remediation-f1f2f3-20260911.md` | 新增本「第三轮定向整改」章节 | + +**未修改**:任何 Java 生产代码、任何既有测试、`docs/smoke-test.sh` 以外的文档正文、`.env`、compose.yaml、Flyway。 + +## T15. Git 状态 + +- 分支:`fix/resume-upload-safety`(未变) +- 暂存区:**空** +- `git diff --check`:无空白错误(仅 CRLF 提示) +- 迁移改动:**无**(V1–V41 共 41 个) +- 删除文件:**0**;历史未跟踪文件:**145 个全部保留** +- 本轮已跟踪文件改动仅 `docs/smoke-test.sh`(S01 Java 文件时间戳仍为 15:45,本轮未触碰) + +## T16. 是否修改 Flyway、表、ErrorCode 或接口 + +**均未修改**:无新增/修改迁移、无表/索引变更、`ErrorCode` 未改、OpenAPI 保持 173/211。 + +## T17. 明确未暂存、未 commit、未 push + +**未执行** `git add` / `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。 + +## T18. 真实 MySQL / Docker 最终复验仍待独立执行 + +本轮**未执行任何 Docker 命令**,**未连接真实 MySQL**: + +- F5 的 `docker exec` + `MYSQL_PWD` 路径仅在**假 docker / 假 mysql** 下验证了 argv 与 stdin 形态; + 真实容器中 `MYSQL_USER` / `MYSQL_PASSWORD` 是否存在、`MYSQL_PWD` 是否被 mysql 客户端接受,**必须由独立复验在真实 Docker/MySQL 环境确认**。 +- F4 的 xtrace 防护已由真实 `bash -x` 本地执行验证(无需容器),但**完整真实登录链路**仍待真实环境复验。 + +## T19. 文档测试口径修正(§7) + +历史验收报告 `docs/s01-independent-mysql-acceptance-20260911.md` 与本报告**合理保留修复前缺陷证据**(含修复前的日志依赖描述与旧命令),因此**不得**以「整个 docs 目录命中数为 0」作为门禁结论。 + +修正后的口径(已落入 shell 测试第 22-24 条断言): + +- **A. 活跃操作文档与正式代码**(必须无「从日志取码」有效指导): + `docs/acceptance-week1.md`、`docs/api-v0.1.md`、`docs/frontend-integration.md`、`docs/offer-api.md`、`docs/resume-api.md`、`docs/smoke-test.sh`、`README.md`、`mydreamjob-server/src/main/java` +- **B. 历史验收报告**(允许保留修复前失败证据,须明示为历史): + `docs/s01-independent-mysql-acceptance-20260911.md`、`docs/s01-remediation-f1f2f3-20260911.md` + +测试使用**精确活跃文件列表**扫描(不扫描全部 docs),并额外断言两份历史报告仍然存在。 + +## T20. 本轮遗留(非阻断,待后续) + +1. JWT 仍作为 `-H "Authorization: Bearer "` 的 curl 参数存在于**进程 argv**(非 xtrace/stdout/stderr 问题;如需消除,应改用 `curl --config` 经 stdin 传头) +2. 验证码仍为数据库明文存储(未改表结构) +3. 真实短信供应商未接入 +4. 存量 43 处直接身份解析未收口;安全层 401 响应不含 requestId +5. 上轮观察项(失败后残留 `sms:rate` 限流键 60s;Spring Boot 默认 `generated security password` 入日志)本轮未处理 + +--- + +**S01第三轮定向整改完成,待独立代码复验及真实MySQL/Docker黑盒复验。** + +--- + +# 第四轮定向整改(F6) + +- **整改日期**:2026-09-14 +- **独立复验结论**:S01 仍 FAIL —— F4 与 F5 已通过(bash -x 不泄露 / Shell 51 项 / Maven 安全专项 61 项 / 密码不进 mysql argv 均通过),新阻断项 **F6**:冒烟脚本使用了 `sms_verification_codes` 不存在的列 `expires_at` +- **本轮范围**:仅修 F6,并补真实表结构契约测试 +- **本轮修改文件**:`docs/smoke-test.sh`、`docs/tests/smoke-test-sms-code-test.sh`、`docs/tests/smoke-test-schema-contract-test.sh`(新增)、本报告 +- **本轮未触碰**:全部 Java 生产代码、实体、Repository、Flyway、数据库表、`ErrorCode`、公网接口 + +## G1. F6 错误位置 + +| 项 | 内容 | +|---|---| +| 文件 | `docs/smoke-test.sh` | +| 行 | **L122** | +| 修复前 | `" AND expires_at > NOW()" \` | +| 修复后 | `" AND expired_at > NOW()" \` | +| 后果 | 真实 MySQL 报 ERROR 1054,冒烟脚本取不到验证码,**F1 未闭环** | + +## G2. 真实 MySQL SHOW COLUMNS 结果摘要 + +独立复验在真实 MySQL 执行 `SHOW COLUMNS FROM sms_verification_codes`,实际字段为: + +``` +id phone code purpose attempts max_attempts expired_at used created_at +``` + +**不存在 `expires_at`。** 该结论与两个契约来源一致: + +| 来源 | 位置 | 结果 | +|---|---|---| +| 实体 `@Column` | `auth/SmsVerificationCode.java:46` | `@Column(name = "expired_at", nullable = false)` | +| 建表 Flyway | `db/migration/V1__base_schema.sql:37` | `expired_at DATETIME NOT NULL` | + +## G3. 修复前的真实报错 + +``` +ERROR 1054 (42S22): Unknown column 'expires_at' in 'where clause' +``` + +## G4. 修复后的 SQL + +```sql +SELECT code FROM sms_verification_codes + WHERE phone = '' AND purpose = '' AND used = 0 + AND expired_at > NOW() + ORDER BY created_at DESC, id DESC LIMIT 1; +``` + +除列名外**未改动任何查询口径**,以下均保持不变:phone 精确匹配、purpose 精确匹配、`used = 0`、`expired_at > NOW()`、`ORDER BY created_at DESC, id DESC`、`LIMIT 1`、仅 SELECT、使用应用数据库账号(非 root)、密码经 `MYSQL_PWD` 不进 argv、SQL 经 stdin、验证码不输出。 + +## G5. 新增的 Shell 断言 + +`docs/tests/smoke-test-sms-code-test.sh` 由 **51 项扩展至 69 项**,新增 Part 6 共 **18 条**: + +| 编号 | 断言 | +|---|---| +| F6-1 | `otp_db_query` 使用 `expired_at > NOW()` | +| F6-2 | `otp_db_query` 不再出现错误字段 `expires_at` | +| F6-3 | 查询只使用 `sms_verification_codes` 的真实列 | +| F6-4 | 真实列清单由 Flyway 派生且含 `expired_at`(非空、非自证) | +| F6-5 | 假 mysql 捕获到完整 stdin SQL(非无条件返回验证码) | +| F6-6 | SQL 字段错误时假 mysql 返回失败(非 0) | +| F6-6b | 字段错误时报 ERROR 1054 (42S22) | +| F6-7 | 字段错误时 `sms_code()` 不继续登录 | +| F6-7b | 字段错误时未发出任何 login 请求 | +| F6-8 | 正确字段时取码成功并完成登录 | +| F6-9 | `expired_at` 恰好等于 NOW() 时不得取出 | +| F6-10 | `expired_at` 晚于 NOW() 时可以取出 | +| F6-11 | `expired_at` 早于 NOW() 时不得取出 | +| F6-12 | `used=1` 不得取出 | +| F6-13 | `purpose` 不匹配不得取出 | +| F6-14 | `phone` 不匹配不得取出 | +| F6-15 | 多条有效记录只取 `created_at`/`id` 最新一条 | +| F6-15b | 与输入顺序无关,按 `created_at`/`id` 排序取值 | + +**另新增独立契约门禁** `docs/tests/smoke-test-schema-contract-test.sh`(**15 条**),契约来源为**实体 `@Column` + 建表 Flyway**,并交叉校验二者列集合一致: + +C1–C4 来源存在性与解析非空 → C5 两个独立来源列集合一致 → C6 `expired_at` 存在 → C7 **`expires_at` 不存在**(表限定)→ C8 实体映射存在 → C9 `expires_at` 确实是其他表的真实列 → C10–C15 脚本 SQL 的表名、列存在性、列不含 `expires_at`、所用列全部真实。 + +## G6. fake docker / fake mysql 是否校验 SQL + +**本轮之前:不校验。** 旧假 mysql 只是 `cat` 掉 stdin 后无条件 `printf "${FAKE_CODE}"`,因此错误的 `expires_at` 也能"取到"验证码并通过测试——这正是 F6 能逃过 51 项测试的原因。 + +**本轮之后:校验。** 假 mysql 改为: + +1. 完整读取并保存 stdin SQL(不再丢弃); +2. 若 SQL 命中 `sms_verification_codes` 且包含 `expires_at` → 如实复现真实 MySQL 的 `ERROR 1054 (42S22)` 并 `exit 1`; +3. 设置 `FAKE_ROWS` 时,按行套用 `WHERE phone/purpose/used` 与 `expired_at > NOW()` 语义、按 `created_at DESC, id DESC` 取值;未设置时保留历史行为供 F1/F5 既有用例使用。 + +## G7. 过期 / 有效 / used / purpose / phone 口径 + +| 场景 | 假表语义 | 期望 | 结果 | +|---|---|---|---| +| `expired_at` 恰好 = NOW() | offset = 0 | 不取出(查询为严格 `>`) | PASS | +| `expired_at` 晚于 NOW() | offset = +300 | 取出 | PASS | +| `expired_at` 早于 NOW() | offset = −300 | 不取出 | PASS | +| `used = 1` | — | 不取出 | PASS | +| `purpose` 不匹配 | 行 purpose=`register`,查 `login` | 不取出 | PASS | +| `phone` 不匹配 | 行 phone 为另一号码 | 不取出 | PASS | +| 多条有效 | 两条均有效,`created_at` 不同 | 只取最新一条 | PASS | + +## G8. F4 / F5 回归结果 + +全部保持通过(本轮未改动 xtrace 防护与 `MYSQL_PWD` 路径): + +| 回归项 | 结果 | +|---|---| +| `bash -x` 不泄露验证码 / 完整手机号 / JWT / Authorization / 数据库密码 / LOGIN_BODY | PASS(F4-1 ~ F4-6) | +| `bash -x` 下正常流程仍可完成登录 | PASS(F4-7、F4-8) | +| 脚本中不存在可执行的 `set -x` | PASS(F4-9) | +| 失败流程同样不泄露 | PASS(F4-10) | +| source 模式 REPLY 契约 / 无敏感变量残留 / EXIT trap | PASS(F4-11 ~ F4-14) | +| mysql argv 不含密码值、不含 `-p`/`--password` | PASS(F5-1 ~ F5-6) | +| 不使用 root / 使用应用账号与数据库名 | PASS(F5-7 ~ F5-10) | +| SQL 经 stdin 且仅 SELECT | PASS(F5-11、F5-12) | +| 密码经 `MYSQL_PWD` 传递、不经 argv | PASS(F5-13、F5-14) | +| 凭据缺失时安全失败(不回退 root) | PASS(F5-15) | +| 失败路径 stderr 不含数据库密码 | PASS(F5-18) | +| 不恢复 `MOCK SMS CODE` 日志、活跃文档不指导从日志取码 | PASS(14、22、23) | + +## G9. Shell 测试数量 + +| 文件 | 用例数 | 结果 | +|---|---|---| +| `docs/tests/smoke-test-sms-code-test.sh` | **69** | PASS=69 / FAIL=0 | +| `docs/tests/smoke-test-schema-contract-test.sh` | **15** | PASS=15 / FAIL=0 | + +合计 **84** 条,全部通过(真实 Git Bash 执行)。 + +**断言非空转的证据(测试自检)**:把 `smoke-test.sh` 还原为 buggy 版本(`expires_at`)放入镜像目录,用**同一套测试**执行: + +- `smoke-test-sms-code-test.sh` → `PASS=56 FAIL=13`,其中 **F6-1 / F6-2 / F6-3 翻红**,F6-3 直接点名不存在的列 `expires_at`;F6-8/10/15/15b 因取不到码一并失败; +- `smoke-test-schema-contract-test.sh` → `PASS=12 FAIL=3`,**C12 / C13 / C14 翻红**,C14 点名 `expires_at`。 + +即:本轮断言确实能识别**不存在的 SQL 列**,而非"改前改后都通过"。 + +## G10. Maven 专项 / 关联 / 全量测试数 + +| 命令 | Tests run | +|---|---| +| `-Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest,SmsEmptyPropertyStartupIntegrationTest,SmsUnavailableSenderRollbackIntegrationTest"` | **61** | +| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **227** | +| `mvn test` | **1671** | +| `mvn clean verify` | **1671** | + +本轮仅改 `docs/` 下的脚本与报告,未增删任何 Java 测试,故总数与上一轮一致(1671)。 + +## G11. Failures / Errors / Skipped + +**Failures=0、Errors=0、Skipped=0**(专项、关联、全量、clean verify 四者一致)。 + +## G12. clean verify + +`mvn clean verify` → **BUILD SUCCESS**(Total time 02:11),产出 `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。 + +## G13. OpenAPI 计数 + +`python docs/verify_openapi.py` → **All checks passed!**,**173 paths / 211 operations**(与上一轮一致,未新增/删除路径或 operation)。 + +## G14. Git 状态 + +- 分支:`fix/resume-upload-safety`(未变) +- **暂存区:空** +- `git diff --check`:无空白错误(仅 CRLF 提示) +- 迁移改动:**无**(`git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` 无输出;目录内 41 个文件,最大版本 `V41__message_recall.sql`) +- 本轮文件时间戳:`docs/smoke-test.sh` 09:22、`smoke-test-sms-code-test.sh` 09:24、`smoke-test-schema-contract-test.sh` 09:27、本报告 09:32(均 2026-09-14) +- **S01 Java 生产代码时间戳仍为 2026-09-11 15:45**,本轮未触碰 +- 删除文件:**0**;历史未跟踪文件:未删除、未移动、未覆盖 + +## G15. 明确未修改 Java、Flyway、表、ErrorCode 或接口 + +**均未修改。** + +- Java 生产代码:**0 处改动**(时间戳可证) +- 实体与 Repository:未改 +- Flyway:未新增/修改,V1–V41 原样 +- 数据库表/列/索引:未新增或修改;**未为迁就脚本把列改成 `expires_at`** +- `ErrorCode`:未改 +- 公网接口:OpenAPI 保持 173/211,未新增取码接口 +- **未恢复验证码日志**(`grep -rn "MOCK SMS CODE" mydreamjob-server/src` 命中 0) + +## G16. 明确未暂存、commit、push + +**未执行** `git add` / `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。**未操作 Docker**,未进入 S02。 + +## G17. 真实 MySQL 完整冒烟链仍待最终独立复验 + +本轮**未执行任何 Docker 命令、未连接真实 MySQL**。全部验证均为:shell 测试(假 docker / 假 mysql 注入)+ Maven H2/Flyway 测试。 + +因此以下**必须由独立复验在真实 MySQL/Docker 环境完成**: + +1. `SHOW COLUMNS FROM sms_verification_codes` 的 Field 集合与 Flyway 列集合对账(含 `expired_at`、不含 `expires_at`); +2. 修复后的取码 SQL 在真实 MySQL 端到端执行成功(不再出现 ERROR 1054); +3. F1 完整冒烟链(发码 → 取码 → 登录 → 后续接口)闭环。 + +**不得以本轮假库结果代替真实环境验收。** + +## G18. 一处必须记录的旁证(不在本轮修改范围) + +`docs/acceptance/s01-20260911/snapshot-sms.sql:4` 同样使用 `expires_at` 查询 `sms_verification_codes`: + +```sql +SELECT id, purpose, CHAR_LENGTH(code) AS code_len, + TIMESTAMPDIFF(SECOND, NOW(), expires_at) AS expire_delta_s, ... +FROM sms_verification_codes +``` + +这是同一字段错误的传播。该文件属上一轮独立验收证据,**不在本轮允许修改的文件范围内**,故保持原样未改,仅在此记录,供后续处理。 + +## G19. 字段名易混淆的说明(供后续维护) + +`expires_at` 并非拼写错误,它是本仓库中**三张表的真实列**: + +| 表 | 迁移 | +|---|---| +| `personal_data_export_tasks.expires_at` | V30 | +| `ability_attempts.expires_at` | V35 | +| `talent_invitations.expires_at`(含索引) | V40 | + +只有 `sms_verification_codes` 用 `expired_at`。因此本轮门禁**按表限定**,未写成"全库禁用 `expires_at`"——那样会误报并掩盖真实错误。契约门禁 C9 专门守卫这一区分。 + +--- + +**S01第四轮定向整改完成,待最终独立代码复验及真实MySQL/Docker黑盒复验。** diff --git a/docs/s02-auth-token-isolation-design.md b/docs/s02-auth-token-isolation-design.md new file mode 100644 index 0000000..4e8f052 --- /dev/null +++ b/docs/s02-auth-token-isolation-design.md @@ -0,0 +1,674 @@ +# S02 Access/Refresh Token 隔离、轮换与吊销设计 + +状态:S02 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。 + +文档类型:解释与技术参考。 + +目标读者:产品负责人、后端开发、前端联调、安全复验和真实环境验收人员。 + +解释版本:`s02-auth-token-isolation-v1` + +## 1. 目标 + +S02 修复当前 Refresh Token 可以作为 Access Token 使用的认证边界缺陷,并建立可轮换、可吊销、可检测重放的登录会话模型。 + +本设计必须保证: + +- 普通业务接口只接受 Access Token; +- 刷新接口只接受 Refresh Token; +- Token 原文不进入数据库、日志、审计、URL 或错误响应; +- 登录、刷新、登出、账号注销和用户禁用具有明确的吊销语义; +- 同一个 Refresh Token 在并发或重放场景中最多成功刷新一次; +- 旧的无 `type` Token 不被永久兼容; +- 不改变手机号验证码登录、角色、资源归属、评分、排名、推荐、邀约、申诉、消息撤回和简历上传语义。 + +## 2. 当前实现事实 + +截至基线提交 `96a920b`: + +1. `JwtTokenProvider.generateAccessToken` 没有强制写入 `type=access`。 +2. `generateRefreshToken` 写入了 `type=refresh`。 +3. `JwtAuthenticationFilter` 只验证 JWT 签名和过期时间,不验证 Token 类型。 +4. Access Token 与 Refresh Token 使用同一 `app.jwt.secret`。 +5. 因此签名有效且未过期的 Refresh Token 可以通过过滤器并建立普通业务登录态。 +6. 登录响应已经包含 `accessToken`、`refreshToken` 和 `expiresIn`。 +7. 当前没有 `/auth/refresh` 和 `/auth/logout`。 +8. `auth_tokens` 表、`AuthToken` 实体和 `AuthTokenRepository` 已存在,但未被登录、刷新、鉴权、登出或账号注销使用。 +9. 当前 JWT 没有 `sid` 和 `jti`,不能标识登录会话和单个 Token。 +10. 当前账号注销完成不会使已经签发的 JWT 立即失效。 +11. 当前 JWT 包含完整手机号;普通鉴权实际只需要用户 ID 和会话信息。 +12. `application.yml` 的 `refresh-expiration-ms` 当前错误地位于 `app.resume` 下,而 `application-dev.yml` 位于正确的 `app.jwt` 下。非 dev 环境可能依赖 Java 默认值,配置契约不一致。 +13. 现有 Access Token 默认有效期为 24 小时,Refresh Token 默认有效期为 7 天。 +14. 现有 `auth_tokens` 表没有会话标识、轮换父子关系、消费时间、撤销时间和乐观锁版本。 + +## 3. 威胁模型 + +本版重点防护以下风险: + +- Refresh Token 被复制到 `Authorization: Bearer` 后直接调用普通业务接口; +- 已登出或已注销用户继续使用旧 Access Token; +- Refresh Token 在日志、数据库、错误响应或 URL 中泄露; +- 同一个 Refresh Token 被并发刷新两次,产生两组有效 Token; +- 已消费 Refresh Token 被攻击者再次使用而系统无法识别; +- Token 类型缺失、大小写变化或未知值被宽松接受; +- 非法 subject、`sid` 或 `jti` 引发 500 或回显内部数据; +- 数据库或会话校验异常时过滤器错误放行; +- 多设备登录时,一个设备的普通登出误伤其他设备; +- 账号注销、用户禁用等全局安全事件没有撤销全部会话。 + +不在本版解决:终端设备指纹、地理异常检测、硬件绑定、OAuth/OIDC、短信供应商、验证码摘要化、密码登录和单点登录。 + +## 4. 设计决策 + +### D01:Token 类型强制隔离 + +- Access Token 必须包含精确字符串 `type=access`。 +- Refresh Token 必须包含精确字符串 `type=refresh`。 +- Token 类型区分大小写。 +- 类型缺失、空白、未知或不匹配均拒绝。 +- 普通业务过滤器只能解析 Access Token。 +- 刷新服务只能解析 Refresh Token。 + +### D02:Access 与 Refresh 使用独立签名密钥 + +新增配置: + +```yaml +app: + jwt: + access-secret: ${JWT_ACCESS_SECRET} + refresh-secret: ${JWT_REFRESH_SECRET} + token-hash-secret: ${JWT_TOKEN_HASH_SECRET} +``` + +三项密钥必须彼此独立。生产环境不允许自动回退到同一个默认密钥。测试配置使用固定的测试专用值,但不得进入生产配置。 + +独立密钥是类型 claim 之外的第二道隔离:即使未来某处遗漏类型判断,Refresh Token 也不能通过 Access Token 验签器。 + +### D03:旧 Token 采用强制重新登录 + +部署 S02 后: + +- 无 `type` 的旧 Access Token 拒绝; +- 旧 Refresh Token 虽有 `type=refresh`,但没有 `sid`、`jti` 和服务端记录,同样拒绝; +- 用户需要重新完成一次手机号验证码登录; +- 不提供永久兼容开关; +- 不把无 `type` Token 猜测为 Access Token。 + +前端必须统一处理 401:清除本地 Token,跳转登录页,不循环刷新。 + +### D04:保持现有有效期 + +- Access Token 默认 24 小时; +- Refresh Token 默认 7 天; +- 登录响应的 `expiresIn=86400` 保持现有含义; +- S02 不顺带缩短 Token 有效期; +- 有效期必须来自配置并经过正数、上限和单位校验。 + +### D05:每次登录创建独立设备会话 + +- 每次成功登录生成新的 `sessionId`; +- 同一用户可以同时拥有多个活动会话; +- 普通登出只撤销当前 `sessionId`; +- Refresh Token 重放只撤销对应 `sessionId`; +- 账号注销完成、用户禁用及未来的敏感认证信息变更撤销该用户全部会话。 + +本版不采集或保存设备指纹,不通过 User-Agent 判断设备身份。 + +### D06:JWT Claim 白名单 + +Access Token 必须包含: + +| Claim | 说明 | +|---|---| +| `sub` | 十进制正整数用户 ID | +| `type` | 固定为 `access` | +| `sid` | UUID 格式的会话 ID | +| `jti` | UUID 格式的 Token ID | +| `role` | 登录时角色,仅作响应和诊断辅助;资源权限仍以现有服务层校验为准 | +| `iat` | 签发时间 | +| `exp` | 过期时间 | +| `iss` | 固定发行者 `mydreamjob` | +| `aud` | 固定受众 `mydreamjob-api` | + +Refresh Token 必须包含: + +| Claim | 说明 | +|---|---| +| `sub` | 十进制正整数用户 ID | +| `type` | 固定为 `refresh` | +| `sid` | 与当前登录会话一致 | +| `jti` | 本次 Refresh Token 的唯一 ID | +| `iat` | 签发时间 | +| `exp` | 过期时间 | +| `iss` | 固定发行者 `mydreamjob` | +| `aud` | 固定受众 `mydreamjob-auth` | + +新 Token 不再包含手机号。不得加入验证码、邮箱、昵称、简历地址或其他个人资料。 + +### D07:Refresh Token 原文不落库 + +数据库只保存: + +```text +HMAC-SHA256(JWT_TOKEN_HASH_SECRET, rawRefreshToken) +``` + +- 不使用普通可逆加密; +- 不使用无密钥的快速摘要作为唯一保护; +- 比较摘要时使用常量时间比较; +- Token、摘要和签名密钥均不得写日志或审计; +- `token_hash` 的业务含义固定为 Refresh Token 摘要。 + +### D08:每次刷新强制轮换 + +刷新成功必须: + +1. 捕获一次 `capturedNow`; +2. 使用 Refresh 专用密钥验签; +3. 验证 `type`、`iss`、`aud`、`sub`、`sid`、`jti`、`iat` 和 `exp`; +4. 计算原始 Refresh Token 摘要; +5. 查询对应服务端记录; +6. 原子消费旧记录; +7. 生成新的 Access/Refresh Token 对; +8. 插入新的 Refresh Token 记录; +9. 提交事务后返回响应。 + +旧 Refresh Token 被消费后永久失效。 + +### D09:Refresh Token 重放关闭当前会话 + +若签名有效的 Refresh Token 对应记录已经被消费或撤销: + +- 本次刷新返回 HTTP 401; +- 撤销相同 `sessionId` 下的全部 Token 记录; +- 当前会话的所有 Access Token 随即失效; +- 不影响同一用户的其他设备会话; +- 写一条不含 Token、摘要或个人信息的安全审计; +- 错误响应不得说明记录是否存在、已消费还是已撤销。 + +### D10:Access Token 必须检查服务端会话状态 + +`JwtAuthenticationFilter` 在完成 Access JWT 验签后,还必须确认: + +- `sid` 属于 `sub` 对应用户; +- 会话存在活动、未消费且未撤销的 Refresh Token 记录; +- 会话未过期; +- 用户存在、处于 active 状态且未完成注销。 + +任一条件不满足时不得建立 `SecurityContext`。 + +数据库是第一版的事实来源。Redis 缓存不属于 S02 首版;后续若增加缓存,数据库仍是最终事实来源,缓存异常不得放行。 + +### D11:普通登出只撤销当前会话 + +`POST /auth/logout`: + +- 需要有效 Access Token; +- 从 Token 中读取 `sub` 和 `sid`,不接受客户端传 userId/sessionId; +- 撤销该用户当前 `sid` 的所有 Token 记录; +- 重复登出保持幂等; +- 不影响其他设备会话; +- 成功响应不返回 Token。 + +### D12:全局安全事件撤销全部会话 + +以下事件必须调用统一的 `revokeAllUserSessions(userId, reason, capturedNow)`: + +- 账号注销完成; +- 用户被禁用; +- 未来的手机号或认证凭据变更。 + +账号注销申请处于冷静期时不立即撤销全部会话,保持当前产品语义;注销完成时必须撤销。取消注销不恢复已经撤销的会话。 + +### D13:单次时间捕获 + +登录、刷新、登出、重放处置和全局吊销每个入口只允许调用一次 `Clock`: + +- JWT `iat`、`exp`; +- 数据库 `createdAt`、`expiredAt`、`consumedAt`、`revokedAt`; +- 审计时间判断; +- 响应有效期; + +均使用同一个 `capturedNow`。不得依赖 `new Date()`、`LocalDateTime.now()` 或系统默认时区散落取时。 + +固定业务时区为 `Asia/Shanghai`,数据库时间按项目现有约定落库。 + +### D14:失败关闭 + +以下情况一律不建立认证: + +- Token 解析异常; +- 类型、发行者或受众错误; +- subject、`sid` 或 `jti` 非法; +- 服务端记录不存在; +- 会话已撤销、已消费或过期; +- 用户不存在、被禁用或已注销; +- 数据库读取或摘要计算失败。 + +过滤器只记录固定安全文本和 requestId,不记录异常消息、Token、摘要或 claim 原值。 + +### D15:错误码与 HTTP 语义 + +优先复用现有: + +- `UNAUTHORIZED(2001)`; +- `TOKEN_EXPIRED(2003)`; +- `TOKEN_INVALID(2004)`; +- `SERVICE_UNAVAILABLE(9001)`。 + +公共认证失败统一表现为 HTTP 401。不得通过不同响应告诉客户端 Token 是“不存在”“已消费”“已撤销”还是“重放”。 + +除非实现阶段发现现有错误映射无法满足固定 401,才允许提出新增 ErrorCode;未获确认不得新增。 + +### D16:接口增量固定为两个 operation + +新增: + +- `POST /auth/refresh`; +- `POST /auth/logout`。 + +预计 OpenAPI 从 `173 paths / 211 operations` 变为 `175 paths / 213 operations`。 + +不得新增会话管理列表、全部设备登出、Token introspection 或管理员代操作接口。 + +### D17:并发由数据库条件更新保证 + +同一 Refresh Token 并发刷新: + +- 使用包含 `id`、`consumed_at IS NULL`、`revoked=0` 和 `version` 的条件更新; +- 只有一个事务可以消费成功; +- 其他事务不得生成第二组有效 Token; +- 失败方执行重放处置或返回固定 401; +- 不使用 JVM 本地锁作为正确性基础。 + +### D18:设计范围隔离 + +S02 不改变: + +- 短信验证码生成、过期、锁定和发送语义; +- 登录自动创建用户的现有规则; +- 用户角色及资源归属; +- 岗位、投递、Offer、评分、排名、推荐、人才发现、邀约; +- 申诉、消息撤回和简历上传; +- V1–V41 迁移内容。 + +## 5. Token 决策表 + +| Token/状态 | 普通业务接口 | `/auth/refresh` | `/auth/logout` | 注销完成后 | 过期后 | +|---|---|---|---|---|---| +| 有效 Access | 允许 | 拒绝 | 允许 | 拒绝 | 拒绝 | +| 有效 Refresh | 拒绝 | 允许并轮换 | 拒绝 | 拒绝 | 拒绝 | +| 无 `type` 旧 Token | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | +| 未知 `type` | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | +| 已消费 Refresh | 拒绝 | 401 并撤销当前会话 | 拒绝 | 拒绝 | 拒绝 | +| 已撤销 Token | 拒绝 | 拒绝 | 幂等 | 拒绝 | 拒绝 | +| 会话不存在 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | +| 用户被禁用 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | + +## 6. 状态机 + +单个 Refresh Token 记录状态: + +```text +ACTIVE -> CONSUMED +ACTIVE -> REVOKED +CONSUMED -> REVOKED (重放或全局吊销) +``` + +不允许从 `CONSUMED` 或 `REVOKED` 恢复为 `ACTIVE`。 + +会话状态由同一 `sessionId` 下的记录推导:存在一个未消费、未撤销且未过期的当前 Refresh Token 记录时,会话为活动状态。 + +## 7. 数据模型 + +### 7.1 迁移原则 + +- 编码前重新确认最新 Flyway;当前建议文件名为 `V42__auth_token_sessions.sql`; +- 若 V42 已被占用,停止并重新编号; +- 不修改 V1–V41; +- 不删除旧记录; +- 新字段先允许旧数据为空,新签发记录由应用强制非空; +- Hibernate 使用 `ddl-auto=validate`; +- 迁移必须兼容真实 MySQL 8.4 和项目 H2 测试模式。 + +### 7.2 `auth_tokens` 扩展字段 + +建议增加: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `session_id` | VARCHAR(36) NULL | 每次登录的会话 UUID | +| `token_jti` | VARCHAR(36) NULL | Refresh Token 的 jti | +| `parent_token_id` | BIGINT NULL | 轮换前一条记录 ID | +| `consumed_at` | DATETIME NULL | 成功刷新时消费时间 | +| `revoked_at` | DATETIME NULL | 撤销时间 | +| `revocation_reason` | VARCHAR(40) NULL | 固定白名单原因 | +| `version` | BIGINT NOT NULL DEFAULT 0 | 乐观锁/条件更新版本 | + +固定含义: + +- `token_hash`:Refresh Token 的 HMAC-SHA256 摘要; +- `token_type`:新记录固定 `REFRESH`; +- `expired_at`:Refresh Token 到期时间; +- `revoked`:现有兼容布尔值; +- `refresh_token_hash`:旧字段保留但新逻辑不依赖,后续版本再评估移除。 + +索引建议: + +```sql +INDEX idx_auth_tokens_session_active (session_id, revoked, consumed_at, expired_at) +INDEX idx_auth_tokens_user_session (user_id, session_id) +UNIQUE INDEX uk_auth_tokens_token_jti (token_jti) +``` + +不得在本轮清理或改写旧 `auth_tokens` 数据。`session_id IS NULL` 的旧记录永远不能被新鉴权逻辑视为活动会话。 + +## 8. 组件职责 + +### `JwtTokenProvider` + +- 分别生成和解析 Access/Refresh Token; +- 使用不同签名密钥; +- 强制校验类型、发行者、受众和必需 claim; +- 接收外部传入的 `capturedNow`,不自行重复取时; +- 不访问数据库。 + +### `AuthTokenHasher` + +- 只负责 Refresh Token HMAC 摘要; +- 使用独立密钥; +- 支持常量时间比较; +- 不记录输入和输出。 + +### `AuthSessionService` + +- 创建会话和首个 Refresh Token 记录; +- 验证、消费、轮换 Refresh Token; +- 判断 Access Token 所属会话是否活动; +- 撤销当前会话或用户全部会话; +- 承担事务与并发控制。 + +### `JwtAuthenticationFilter` + +- 只接受 Access Token; +- 调用 Provider 完成密码学和 claim 校验; +- 调用 Session Service 验证服务端会话状态; +- 通过后建立 SecurityContext; +- 失败时保持匿名,由 SecurityFilterChain 返回既有 401。 + +### `AuthService` + +- 登录成功时创建服务端会话; +- 只在会话记录成功持久化后返回 Token; +- 编排刷新和登出; +- 保持现有用户登录/自动创建语义。 + +### `AuthController` + +- 接收白名单 DTO; +- 不解析 JWT、不计算摘要、不接收 userId/sessionId; +- 不在异常响应中回显 Token; +- 不返回 Entity 或数据库字段。 + +## 9. API 契约 + +### 9.1 登录 + +现有 `POST /auth/login` 请求不变。 + +响应字段保持: + +- `userId` +- `phone` +- `nickname` +- `role` +- `accessToken` +- `refreshToken` +- `expiresIn` + +新 Token 从 S02 起包含完整类型和会话 claim。旧客户端不需要解析 JWT 内部 claim。 + +### 9.2 刷新 + +`POST /auth/refresh` + +请求: + +```json +{ + "refreshToken": "" +} +``` + +成功响应: + +```json +{ + "code": 1000, + "data": { + "accessToken": "", + "refreshToken": "", + "expiresIn": 86400 + }, + "requestId": "" +} +``` + +要求: + +- 请求体只允许 `refreshToken`; +- 不能为空,设置合理长度上限; +- 不接受查询参数或路径参数中的 Token; +- 旧 Refresh Token 刷新成功后立即失效。 + +### 9.3 登出 + +`POST /auth/logout` + +- 使用 `Authorization: Bearer `; +- 无 request body; +- 成功返回现有 `ApiResponse` 包装的固定消息; +- 重复登出按幂等成功处理,但已经撤销的 Access Token通常无法再通过过滤器,因此客户端不应依赖第二次请求; +- 不接收 Refresh Token、userId 或 sessionId 参数。 + +## 10. SecurityConfig + +- `/auth/login`、`/auth/sms/send` 和 `/auth/refresh` 允许匿名进入; +- `/auth/logout` 必须 authenticated; +- `permitAll` 只表示可进入 Controller,不代表 Refresh Token 校验成功; +- 其他接口规则不变; +- 不扩大 `/auth/**` 为整体公开; +- Refresh Token 放入 Authorization 头访问任何普通接口必须得到 401。 + +## 11. 事务与并发 + +### 登录 + +用户读取/创建、会话记录创建和登录审计在一个事务内完成。会话记录失败时不能返回 Token。 + +### 刷新 + +旧 Token 条件消费和新 Token 记录插入在一个事务内完成。提交失败时不能返回新 Token。 + +### 登出 + +同一事务撤销当前会话记录并写脱敏审计。 + +### 重放 + +发现已消费 Token 时,在独立明确事务中撤销对应会话,避免刷新事务回滚后撤销也丢失。实现必须通过测试证明最终会话已撤销。 + +## 12. 审计与日志 + +允许的审计动作建议: + +- `AUTH_SESSION_CREATED` +- `AUTH_TOKEN_REFRESHED` +- `AUTH_SESSION_REVOKED` +- `AUTH_REFRESH_REUSE_DETECTED` + +若新增审计动作不需要新增 ErrorCode,也不需要新表。 + +审计详情仅允许: + +- userId; +- sessionId 的 HMAC 摘要或固定截断不可逆标识; +- reason 白名单; +- requestId; +- 结果状态。 + +禁止: + +- Access Token; +- Refresh Token; +- Token 摘要; +- JWT claim 全文; +- 完整手机号、邮箱; +- Authorization 请求头; +- 异常对象 `toString()`。 + +## 13. 前端联调语义 + +前端必须: + +1. 登录后分别保存 Access 和 Refresh Token; +2. 普通请求只携带 Access Token; +3. 收到普通 401 时最多执行一次刷新; +4. 刷新成功后原子替换两个 Token; +5. 刷新失败时清除两个 Token并跳转登录; +6. 防止多个并发请求同时用同一 Refresh Token刷新; +7. 登出时调用 `/auth/logout`,随后无条件清除本地 Token; +8. S02 上线后接受一次强制重新登录。 + +不允许把 Refresh Token 放入 URL、本地日志、埋点或错误上报正文。 + +## 14. 自动化测试要求 + +至少覆盖: + +### Provider + +- Access 包含 `type=access`; +- Refresh 包含 `type=refresh`; +- 两类 Token 使用不同密钥; +- 交叉验签失败; +- issuer/audience/type/sub/sid/jti 缺失或错误时拒绝; +- 过期边界; +- 新 Token 不含手机号。 + +### Filter + +- Access Token 可访问普通接口; +- Refresh Token 访问普通接口为 401; +- 无 type、未知 type、非法 subject/sid/jti 为 401; +- 已撤销会话、用户禁用、注销完成为 401; +- 服务端检查异常时不放行; +- 响应和日志不回显 Token。 + +### 登录与存储 + +- 登录生成一条活动会话记录; +- 数据库只保存摘要; +- 登录响应结构不变; +- 会话持久化失败不返回 Token; +- 多次登录生成不同 sessionId。 + +### 刷新与并发 + +- 正常刷新轮换 Token; +- 原 Token 变为 consumed; +- 新旧 jti 不同、sid 相同; +- 同一 Refresh Token 第二次使用返回 401并撤销当前会话; +- 两线程并发刷新最多一个成功; +- 最终只有一个后继 Token; +- 无 500、死锁或重复活动记录。 + +### 登出与注销 + +- 登出撤销当前设备; +- 其他设备仍可用; +- 重复撤销幂等; +- 注销完成撤销全部设备; +- 注销取消不恢复旧会话; +- 被禁用用户 Token 无效。 + +### JSON 与日志脱敏 + +递归禁止出现 Token、摘要、Authorization、手机号、邮箱、secret、password 和内部对象。 + +## 15. 真实 MySQL/Docker 验收 + +必须验证: + +- 新迁移在真实 MySQL 8.4 成功; +- `ddl-auto=validate` 通过; +- 登录记录只含摘要; +- Refresh Token 无法调用普通接口; +- Access Token 无法调用刷新接口; +- 正常轮换、重放和两线程并发符合设计; +- 登出后旧 Access/Refresh 均失效; +- 多设备隔离; +- 注销完成全部失效; +- 运行时 OpenAPI 为预计的 `175/213`; +- 日志、审计和数据库无 Token 原文; +- RabbitMQ、通知、评分和其他业务表无新增副作用。 + +## 16. 发布与回滚 + +### 发布前 + +- 前端必须先支持刷新失败后重新登录; +- 配置三个独立强密钥; +- 在隔离环境完成真实 MySQL 迁移和黑盒; +- 确认所有节点同时升级,避免新旧 Token 策略混跑。 + +### 发布结果 + +- 所有旧 Token失效; +- 所有用户需要重新登录一次; +- 新登录产生服务端会话记录。 + +### 回滚 + +- 不回滚或修改已执行 Flyway; +- 应用回滚到旧版会重新接受只看签名的 Token,存在安全倒退; +- 因此 S02 发布后不允许直接回滚到未隔离版本; +- 紧急回滚应发布保持类型隔离的兼容修复版本,而不是恢复旧过滤器。 + +## 17. 验收门槛 + +只有全部满足才可冻结 S02: + +1. Refresh Token 不能访问任何普通业务接口; +2. Access Token 不能用于刷新; +3. 无 type 旧 Token 被拒绝; +4. Token 原文不落库、不入日志、不入审计; +5. 每次刷新完成轮换; +6. 重放撤销当前会话; +7. 并发刷新最多成功一次; +8. 登出立即使当前会话 Access/Refresh 失效; +9. 多设备会话互不误伤; +10. 注销完成和用户禁用使全部会话失效; +11. 旧登录响应字段保持兼容; +12. 自动化测试 0 Failures/Errors/Skipped; +13. `mvn clean verify` 成功; +14. 静态和运行时 OpenAPI 一致; +15. 真实 MySQL迁移和 schema validation成功; +16. 不改变其他业务模块结果。 + +## 18. 明确非目标与剩余风险 + +本版不实现: + +- 查看全部登录设备; +- 远程踢出单个其他设备; +- OAuth/OIDC; +- 短信验证码数据库摘要化; +- 真实短信供应商; +- Redis 会话缓存; +- Access Token 有效期缩短; +- 浏览器 HttpOnly Cookie 模式; +- 设备指纹和异常登录检测。 + +后续可以单独评估,但不得作为 S02 验收通过的替代条件。 diff --git a/docs/s02-auth-token-isolation-implementation-instruction.md b/docs/s02-auth-token-isolation-implementation-instruction.md new file mode 100644 index 0000000..8488d77 --- /dev/null +++ b/docs/s02-auth-token-isolation-implementation-instruction.md @@ -0,0 +1,724 @@ +# S02 Access/Refresh Token 隔离与吊销:编码实施指令 + +> 本文可原样交给编码执行者。权威设计为 `docs/s02-auth-token-isolation-design.md`。任何与设计冲突、会改变其他业务语义或需要扩大公共接口的情况,必须停止并报告,不得自行改设计。 + +## 一、任务目标 + +执行 S02「Access/Refresh Token 隔离、轮换与吊销」。 + +项目根目录: + +```text +C:\Users\15642\Desktop\zhilai +``` + +基线要求: + +- 必须包含 `b5084fd`(S01 认证安全整改); +- 必须包含 `96a920b`(简历上传安全加固); +- 当前最新 Flyway 应为 `V41__message_recall.sql`; +- 当前静态 OpenAPI 应为 `173 paths / 211 operations`; +- S02 预计新增两个接口,完成后为 `175 paths / 213 operations`。 + +本轮只实现 S02,不进入 S03、Q01 或 R01。 + +## 二、硬性边界 + +允许: + +1. 修改认证 Token 生成、解析、过滤、登录、刷新和登出链路; +2. 扩展现有 `auth_tokens` 表; +3. 新增一个 Flyway,建议 `V42__auth_token_sessions.sql`; +4. 新增 `/auth/refresh` 和 `/auth/logout`; +5. 将账号注销完成、用户禁用接入会话吊销; +6. 更新认证文档、前端联调文档和 OpenAPI; +7. 新增自动化测试。 + +禁止: + +1. 修改 V1–V41; +2. 删除或重建 `auth_tokens` 表; +3. 在数据库、日志、审计、异常或 URL 中保存/输出原始 Token; +4. 永久兼容无 `type` 旧 Token; +5. 把 Refresh Token 当 Access Token; +6. 使用同一密钥签发 Access 和 Refresh; +7. 使用 JVM 本地锁代替数据库并发控制; +8. 修改短信验证码、登录自动注册、角色和资源归属语义; +9. 修改评分、排名、推荐、人才发现、邀约、Offer、申诉、消息撤回或简历上传; +10. 新增设备列表、全部设备登出、Token introspection 等接口; +11. 删除、禁用或放宽既有测试; +12. `git reset`、`git restore`、`git checkout`、`git clean`; +13. `git add -A`、commit、push; +14. 删除、移动或覆盖历史未跟踪脚本、日志、JSON、SQL、xlsx、`.env`; +15. 操作 Docker或执行真实 MySQL 黑盒,除非负责人另行授权。 + +## 三、开始前检查 + +执行并记录: + +```powershell +cd C:\Users\15642\Desktop\zhilai +git branch --show-current +git status --short +git diff --cached --name-only +git log -5 --oneline +git diff --check +git diff --name-only -- mydreamjob-server/src/main/resources/db/migration +``` + +要求: + +- 暂存区为空; +- 记录 HEAD; +- 确认基线包含 `96a920b`; +- 保护全部已有修改和未跟踪文件; +- 搜索并完整阅读适用的 `AGENTS.md`,不存在则记录; +- 若认证相关文件存在不属于 S02 的未提交修改,停止并报告; +- 建议从已包含 `96a920b` 的基线创建独立分支 `feat/s02-token-isolation`,但不得覆盖用户现有分支或工作树。 + +## 四、必须完整阅读 + +权威文档: + +```text +docs/s02-auth-token-isolation-design.md +docs/api-v0.1.md +docs/frontend-integration.md +docs/phase1-scope-and-acceptance.md +docs/backend-development-roadmap.md +docs/openapi-rc.json +docs/verify_openapi.py +``` + +生产代码: + +```text +mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java +mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java +mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java +mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java +mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java +mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java +mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java +mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java +mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java +mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionService.java +mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java +mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java +mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java +mydreamjob-server/src/main/resources/application.yml +mydreamjob-server/src/main/resources/application-dev.yml +mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql +``` + +测试: + +```text +mydreamjob-server/src/test/java/com/mydreamjob/auth/** +mydreamjob-server/src/test/java/com/mydreamjob/security/** +mydreamjob-server/src/test/java/com/mydreamjob/deletion/** +mydreamjob-server/src/test/resources/application-test.yml +mydreamjob-server/src/test/resources/application-integration-test.yml +``` + +搜索: + +```powershell +rg -n "generateAccessToken|generateRefreshToken|validateToken|parseUserId|AuthToken|AuthTokenRepository|refreshToken|Authorization|logout|type.*refresh|JWT_SECRET|refresh-expiration" mydreamjob-server/src docs +``` + +## 五、先补失败测试 + +编码前先增加失败测试,证明当前缺陷存在。至少包括: + +1. `refreshTokenCannotAuthenticateOrdinaryEndpoint`:当前 Refresh Token 访问 `/auth/me` 不应成功;修复前应失败。 +2. `accessTokenMustContainAccessType`:Access Token 必须有 `type=access`。 +3. `accessTokenCannotBeUsedAtRefreshEndpoint`。 +4. `legacyTokenWithoutTypeIsRejected`。 +5. `accessAndRefreshTokensCannotCrossVerify`。 +6. `revokedSessionCannotAuthenticate`。 +7. `sameRefreshTokenCannotBeConsumedTwice`。 + +保留修复前失败输出。不得通过修改期望状态码掩盖问题。 + +## 六、配置整改 + +修改 `application.yml`: + +1. 将错误位于 `app.resume.refresh-expiration-ms` 的配置移回 `app.jwt`; +2. 增加: + +```yaml +app: + jwt: + access-secret: ${JWT_ACCESS_SECRET} + refresh-secret: ${JWT_REFRESH_SECRET} + token-hash-secret: ${JWT_TOKEN_HASH_SECRET} + expiration-ms: ${JWT_EXPIRATION_MS:86400000} + refresh-expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000} + issuer: mydreamjob + access-audience: mydreamjob-api + refresh-audience: mydreamjob-auth +``` + +要求: + +- 生产配置不得提供秘密默认值; +- 三项密钥不得互相回退; +- 启动时校验密钥长度和互异性; +- 测试 profile 使用固定测试密钥; +- 不打印密钥; +- 原 `app.jwt.secret` 仅作为旧配置迁移项处理,S02 新 Token 不使用它; +- 缺失、空值或三项相同必须启动失败,并给出固定、不含密钥的安全配置错误。 + +新增配置测试: + +- 三项有效且互异时启动成功; +- 任一缺失、空白或过短时失败; +- access/refresh 相同时失败; +- hash secret 与签名密钥相同时失败; +- 错误日志不包含密钥值。 + +## 七、JWT Provider 重构 + +修改 `JwtTokenProvider`,禁止继续使用通用的 `validateToken + parseUserId` 组合来判断 Token 用途。 + +建议提供明确 API: + +```java +IssuedToken generateAccessToken(..., Instant capturedNow); +IssuedToken generateRefreshToken(..., Instant capturedNow); +AccessTokenClaims parseAndValidateAccessToken(String rawToken, Instant capturedNow); +RefreshTokenClaims parseAndValidateRefreshToken(String rawToken, Instant capturedNow); +``` + +要求: + +- Access 使用 access secret; +- Refresh 使用 refresh secret; +- 精确校验 `type`、`iss` 和 `aud`; +- 验证 `sub` 为正整数; +- 验证 `sid`、`jti` 为规范 UUID; +- Access 必须含 `role`; +- 新 Token 不含 `phone`; +- `iat`、`exp` 使用调用方传入的同一 `capturedNow`; +- 过期判断边界固定:`exp <= capturedNow` 视为过期; +- 不向调用方返回原始 Claims 对象; +- 异常消息不得包含 Token 或 claim 原值。 + +增加强类型内部 record/DTO,例如: + +```text +AccessTokenClaims +RefreshTokenClaims +IssuedToken +``` + +不要让 Controller 直接依赖 JJWT 类型。 + +## 八、Token 摘要 + +新增内部服务,例如: + +```text +mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java +``` + +要求: + +- `HmacSHA256`; +- 密钥为 `app.jwt.token-hash-secret`; +- 输出固定长度十六进制或 Base64URL; +- 不存原文; +- 不记录输入、输出或异常原值; +- 对固定输入输出稳定; +- 比较使用 `MessageDigest.isEqual`; +- 单元测试使用公开测试向量或确定性断言。 + +## 九、Flyway V42 + +编码前执行: + +```powershell +Get-ChildItem mydreamjob-server/src/main/resources/db/migration | Sort-Object Name | Select-Object -Last 10 +``` + +只有 V42 未被占用时才新增: + +```text +mydreamjob-server/src/main/resources/db/migration/V42__auth_token_sessions.sql +``` + +扩展 `auth_tokens`: + +```text +session_id VARCHAR(36) NULL +token_jti VARCHAR(36) NULL +parent_token_id BIGINT NULL +consumed_at DATETIME NULL +revoked_at DATETIME NULL +revocation_reason VARCHAR(40) NULL +version BIGINT NOT NULL DEFAULT 0 +``` + +新增索引: + +```text +idx_auth_tokens_session_active(session_id, revoked, consumed_at, expired_at) +idx_auth_tokens_user_session(user_id, session_id) +uk_auth_tokens_token_jti(token_jti) UNIQUE +``` + +要求: + +- 不修改旧列、不删除旧数据; +- `session_id IS NULL` 的旧行不参与新会话认证; +- 不新增其他表; +- 不添加级联删除; +- CHECK/索引语法必须通过 MySQL 8.4 和 H2 MODE=MySQL; +- 更新 `AuthToken` 实体并使用 `@Version`; +- `token_hash` 保存 Refresh Token HMAC 摘要; +- 新记录 `token_type=REFRESH`; +- `refresh_token_hash` 保留但新逻辑不依赖。 + +新增持久化测试: + +- 旧格式行仍可存在; +- 新记录字段正确; +- `token_jti` 唯一; +- 默认 version=0; +- 两个 EntityManager 的旧 version 更新冲突; +- 不保存原始 JWT。 + +## 十、Repository 原子操作 + +`AuthTokenRepository` 增加明确查询和条件更新,禁止 `findById -> set -> save` 作为并发消费保证。 + +至少实现: + +1. 按 `tokenHash` 查询候选 Refresh 记录; +2. 按 `userId + sessionId` 判断活动会话; +3. 原子消费:条件包含 `id`、`expectedVersion`、`revoked=false`、`consumedAt IS NULL`、`expiredAt > capturedNow`; +4. 按 `userId + sessionId` 撤销当前会话; +5. 按 `userId` 撤销全部会话。 + +所有更新返回受影响行数并严格检查。不得通过捕获异常后继续返回成功。 + +## 十一、AuthSessionService + +新增: + +```text +mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java +``` + +职责: + +- 创建登录会话; +- 保存首个 Refresh Token 摘要; +- 校验 Access 所属会话; +- 消费并轮换 Refresh Token; +- 撤销当前会话; +- 撤销用户全部会话; +- 处理重放。 + +### 登录 + +- 捕获一次时间; +- 生成随机 UUID `sid`; +- 生成 Access 和 Refresh 的独立 `jti`; +- 生成 Token; +- 保存 Refresh 摘要; +- 事务提交成功后才返回; +- 多次登录必须得到不同 `sid`。 + +### 刷新 + +- Controller 传入原始 Refresh Token,但原文只在内存短暂存在; +- Provider 校验 Refresh; +- 摘要查询记录; +- claim 中的 userId/sid/jti 必须与数据库一致; +- 原子消费旧记录; +- 插入同 sid 的新记录; +- 新旧 jti 不同; +- 新 Access 和 Refresh 使用同一次 `capturedNow`; +- 旧 Token 再次使用返回固定 401。 + +### 重放 + +- 已消费或已撤销 Token 被再次提交时撤销整个当前 session; +- 撤销必须在不会随当前失败事务回滚丢失的事务边界执行; +- 写一条脱敏审计; +- 不回显“已消费”“已撤销”或数据库状态。 + +### 活动会话判断 + +- session 必须属于 user; +- 存在未消费、未撤销、未过期的当前 Refresh 记录; +- 用户 active; +- 账号注销未完成; +- 数据库错误失败关闭。 + +## 十二、登录服务 + +修改 `AuthService.mockLogin`: + +- 保持验证码、自动注册、注销墓碑和响应字段不变; +- 改为创建 AuthSession; +- Access Token 必须带 `type=access`; +- Refresh Token 必须带 `type=refresh`; +- 响应仍包含 `userId/phone/nickname/role/accessToken/refreshToken/expiresIn`; +- 数据库只保存 Refresh 摘要; +- 会话持久化失败时整个登录事务失败,不得返回 Token或写成功审计; +- 不改变 `expiresIn=86400`。 + +## 十三、认证过滤器 + +修改 `JwtAuthenticationFilter`: + +- 只调用 `parseAndValidateAccessToken`; +- Refresh Token、无 type、未知 type、错误 issuer/audience 必须拒绝; +- 验证服务端 session; +- 验证用户 active 和注销状态; +- 通过后使用 userId 建立 SecurityContext; +- 不把 Token claim 直接作为权限真相; +- 任何失败均不得建立部分认证; +- 清理可能存在的 SecurityContext; +- 日志使用固定安全文本,不使用异常 `getMessage()`; +- 不回显 Token、subject、sid、jti。 + +真实 MockMvc 测试必须覆盖: + +- Access 访问 `/auth/me` 成功; +- Refresh 访问 `/auth/me` 为 401; +- Access/Refresh 交叉密钥失败; +- 无 type、未知 type、非法 subject/sid/jti 为 401; +- 撤销 session 为 401; +- 用户禁用为 401; +- 服务端校验抛异常时不放行、不返回 500 内部对象。 + +## 十四、刷新和登出接口 + +新增白名单 DTO: + +```text +auth/dto/RefreshTokenRequest.java +auth/dto/TokenPairResponse.java +``` + +### `POST /auth/refresh` + +- `SecurityConfig` 中精确 permitAll; +- 请求体只包含 `refreshToken`; +- `@NotBlank` 且设置合理长度上限; +- Token 不允许作为 query/path 参数; +- 成功返回新 Token 对和 `expiresIn`; +- Access Token 作为 refreshToken 提交必须 401; +- 所有认证失败统一安全响应。 + +### `POST /auth/logout` + +- 必须 authenticated; +- 无 requestBody; +- 从通过验证的 Access Token claims / SecurityContext 取得 userId 和 sid; +- 不接收 userId、sessionId、refreshToken; +- 撤销当前 session; +- 保持幂等语义; +- 成功响应不包含 Token。 + +`AuthController` 不得计算摘要、解析 JWT、访问 Repository 或重复实现会话逻辑。 + +## 十五、注销与用户禁用 + +在不改变账号注销状态机的前提下: + +- `AccountDeletionCompletionWorker` 成功完成注销时撤销用户全部会话; +- 状态更新和会话撤销必须具备一致事务语义; +- 重复完成不重复产生不受控副作用; +- 注销申请 PENDING 时不强制退出; +- 取消注销不恢复旧 session; +- Filter 每次认证必须拒绝 inactive 或 COMPLETED 用户; +- 不接受客户端指定需要撤销的 userId。 + +补测试证明: + +- 注销完成后所有设备 Access/Refresh 均失效; +- 其他用户会话不受影响; +- 禁用用户不能继续认证; +- candidate scoring、消息、投递等业务表不被修改。 + +## 十六、审计隐私 + +允许的固定动作: + +```text +AUTH_SESSION_CREATED +AUTH_TOKEN_REFRESHED +AUTH_SESSION_REVOKED +AUTH_REFRESH_REUSE_DETECTED +``` + +details 使用 ObjectMapper 序列化白名单 Map/record,禁止拼接不受控 JSON。 + +禁止进入日志、审计和响应: + +```text +accessToken +refreshToken +Authorization +tokenHash +refreshTokenHash +JWT 原文 +完整手机号 +email +password +secret +Claims 对象 +异常对象 toString +``` + +日志测试必须捕获真实 Logback 输出,不得只 Mockito verify logger。 + +## 十七、单次 Clock + +注入 `Clock`,使用 `ZoneId.of("Asia/Shanghai")` 的测试 Clock。 + +分别验证登录、刷新、登出、重放和注销完成: + +- 每个入口只读取 Clock 一次; +- JWT iat/exp; +- DB createdAt/expiredAt/consumedAt/revokedAt; +- 审计和响应; + +均使用首次捕获时间。 + +禁止使用 `Thread.sleep()`、系统默认时区或真实时间碰撞作为核心测试。 + +## 十八、并发测试 + +新增真实 H2 集成并发测试: + +- `@SpringBootTest`; +- integration-test profile; +- 真实 Service、Repository、事务和 EntityManager; +- 两个线程和独立事务; +- 使用 CountDownLatch 同时开始; +- 不 Mock 条件更新。 + +测试: + +1. `concurrentRefreshOnlyOneRequestSucceeds`; +2. `refreshReplayRevokesOnlyCurrentSession`; +3. `differentSessionsRefreshIndependently`; +4. `logoutRacingWithRefreshCannotLeaveActiveToken`。 + +断言: + +- 同一 Token 最多一个刷新成功; +- 不产生两个活动后继 Token; +- 失败方不返回 500; +- 当前 session 被安全撤销; +- 其他 session 不受影响; +- 无死锁和未处理乐观锁异常。 + +## 十九、完整测试矩阵 + +建议新增: + +```text +JwtTokenTypeIsolationTest.java +JwtAuthenticationTypeIntegrationTest.java +AuthTokenHasherTest.java +AuthSessionServiceTest.java +AuthSessionPersistenceIntegrationTest.java +AuthRefreshControllerTest.java +AuthRefreshHttpIntegrationTest.java +AuthRefreshConcurrencyIntegrationTest.java +AuthSessionRevocationIntegrationTest.java +AuthTokenClockConsistencyTest.java +AuthTokenRedactionTest.java +``` + +至少覆盖: + +### 类型和 claim + +- access/refresh 类型; +- 独立密钥; +- issuer/audience; +- subject/sid/jti; +- 无 type 和未知 type; +- 过期边界; +- Token 不含手机号。 + +### 生命周期 + +- 登录; +- 刷新轮换; +- 二次使用; +- 并发刷新; +- 当前设备登出; +- 多设备隔离; +- 注销完成; +- 用户禁用。 + +### HTTP + +- 匿名刷新携带有效 Refresh 成功; +- Access 调刷新 401; +- Refresh 调普通接口 401; +- 无 Token 登出 401; +- 有效 Access 登出成功; +- 错误响应不泄漏。 + +### 副作用 + +- 原始 Token 不落库; +- 不产生 RabbitMQ、通知或短信; +- 不修改评分、排名、推荐、邀约、消息和简历表; +- 只产生预期 auth_tokens 和白名单审计变化。 + +## 二十、OpenAPI 和文档 + +更新: + +```text +docs/openapi-rc.json +docs/verify_openapi.py +docs/api-v0.1.md +docs/frontend-integration.md +docs/backend-development-roadmap.md +docs/phase1-scope-and-acceptance.md +docs/s02-auth-token-isolation-design.md +docs/s02-auth-token-isolation-implementation-instruction.md +``` + +运行时生成 OpenAPI,不得手工伪造 schema。 + +`verify_openapi.py` 增加递归断言: + +1. 总数为 `175 paths / 213 operations`; +2. 只新增 `/auth/refresh` 和 `/auth/logout`; +3. Refresh 请求体只含 `refreshToken`; +4. Logout 无 requestBody; +5. 普通安全方案说明只接受 Access Token; +6. schema 不包含 tokenHash、refreshTokenHash、session 数据库对象、phone、secret; +7. 登录原有字段不删除; +8. 不新增其他认证或管理接口。 + +前端文档必须明确: + +- 上线时旧 Token 全部失效; +- 普通请求只使用 Access; +- 401 时最多刷新一次; +- 刷新请求必须串行; +- 刷新失败清除 Token并重新登录; +- 登出后无条件清除本地 Token; +- 不记录或上报 Token。 + +## 二十一、验证顺序 + +先运行新增定向测试,按实际类名调整: + +```powershell +cd C:\Users\15642\Desktop\zhilai\mydreamjob-server +mvn -Dtest="JwtTokenTypeIsolationTest,JwtAuthenticationTypeIntegrationTest,AuthTokenHasherTest,AuthSessionServiceTest,AuthSessionPersistenceIntegrationTest,AuthRefreshControllerTest,AuthRefreshHttpIntegrationTest,AuthRefreshConcurrencyIntegrationTest,AuthSessionRevocationIntegrationTest,AuthTokenClockConsistencyTest,AuthTokenRedactionTest" test +``` + +再运行认证和注销关联回归: + +```powershell +mvn -Dtest="*Auth*Test,*Jwt*Test,*Security*Test,*Deletion*Test,*Sms*Test" test +``` + +最后执行: + +```powershell +mvn test +mvn clean verify +python ..\docs\verify_openapi.py +cd C:\Users\15642\Desktop\zhilai +git diff --check +git status --short +git diff --cached --name-only +git diff --name-only -- mydreamjob-server/src/main/resources/db/migration +``` + +要求: + +- Failures=0; +- Errors=0; +- Skipped=0; +- `mvn clean verify` BUILD SUCCESS; +- OpenAPI=`175/213`; +- 暂存区为空; +- 唯一新迁移为实际确认编号的 auth token migration; +- 未修改 V1–V41; +- 未 commit、未 push。 + +如果 clean verify 因正在运行的 JAR 锁定: + +- 查询实际 PID、端口和完整命令行; +- 不结束未知进程; +- 报告并等待负责人确认。 + +## 二十二、禁止通过的绕过方式 + +- 只给 Refresh 增加 claim,却不让 Filter 检查; +- 仍用同一签名密钥,只依赖字符串类型; +- 继续接受无 type 旧 Token; +- 把 Refresh Token 黑名单只放在 JVM 内存; +- 数据库存原始 JWT; +- 日志打印 Token 便于调试; +- 刷新后旧 Token仍有效; +- 并发刷新两次都成功; +- 登出只删前端 Token,不做服务端吊销; +- Access Token 不检查 session,导致登出后仍可用; +- 数据库异常时跳过 session 检查; +- 用 Mockito 模拟 Repository 代替全部并发和持久化证据; +- 修改测试期望值掩盖旧行为; +- 修改旧 Flyway; +- 为完成 S02 重构全仓身份解析或异常映射。 + +## 二十三、完成状态 + +编码和自动化全部通过后,将设计及路线图状态写为: + +```text +S02 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。 +``` + +不得写“已冻结”“已完成生产验收”或“S03 已开始”。 + +## 二十四、最终报告 + +必须包含: + +1. 修复前 Refresh Token 访问普通接口的复现; +2. Access/Refresh 的 claim 和密钥隔离; +3. 无 type 旧 Token 结果; +4. 登录响应兼容结果; +5. Token 摘要算法和数据库脱敏结果; +6. `auth_tokens` 迁移和实体变化; +7. 正常刷新与轮换结果; +8. 二次使用和重放处置; +9. 并发刷新结果; +10. 当前设备登出和多设备隔离; +11. 注销完成、用户禁用后的结果; +12. 单次 capturedNow 证据; +13. 日志、审计和完整 JSON 脱敏; +14. 数据库与外围副作用; +15. 新增接口和 OpenAPI 实际计数; +16. 专项、关联、全量测试数; +17. Failures、Errors、Skipped; +18. clean verify 结果; +19. 精确修改文件; +20. Git 状态; +21. 是否修改 V1–V41、ErrorCode 或其他业务接口; +22. 明确未执行 `git add -A`、commit、push; +23. 明确真实 MySQL/Docker 黑盒仍待独立验收。 + +任何失败或 SKIP 必须如实报告。完成后停止,不进入 S03。 diff --git a/docs/s02-independent-mysql-acceptance-20260914.md b/docs/s02-independent-mysql-acceptance-20260914.md new file mode 100644 index 0000000..a797ba2 --- /dev/null +++ b/docs/s02-independent-mysql-acceptance-20260914.md @@ -0,0 +1,202 @@ +# S02 独立代码复验与真实 MySQL/Docker 黑盒验收报告(脱敏版) + +- **验收日期**:2026-09-14 +- **分支**:`feat/s02-token-isolation` +- **验收对象**:S02「Access/Refresh Token 隔离、轮换与吊销」 +- **结论**:**PASS** —— *S02独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。* +- **本报告不含**:JWT 原文、Token 摘要全文、验证码、完整手机号、数据库口令、JWT 密钥、`.env` 内容、`Authorization` 请求头 + +--- + +## 1. Git 基线与工作区状态 + +| 项 | 实测 | +|---|---| +| 分支 | `feat/s02-token-isolation` | +| HEAD | `96a920b fix: harden resume upload safety` | +| 暂存区 | **空** | +| `git diff --check` | 无空白错误 | +| 工作区 | 63 个已修改 + 116 个未跟踪 | +| 唯一新增迁移 | `V42__auth_token_sessions.sql`(V1–V41 未修改) | +| 删除文件 | 0 | +| AGENTS.md | **未发现** | +| 静态 OpenAPI | 175 paths / 213 operations(`All checks passed!`) | + +## 2. 独立代码审查逐项结论 + +| 项 | 结论 | 关键证据 | +|---|---|---| +| A1 Token 类型隔离 | **PASS** | Access 用 `accessKey`、带 `type=access`;Refresh 用 `refreshKey`、带 `type=refresh`、无 `role`;摘要用独立 `tokenHashSecret`;三项密钥经 `requireDistinct` 强制互异;两类 Token 均不含 phone/email/nickname/resumeUrl | +| A2 JWT claims | **PASS** | `JwtTokenProvider.parse()` 统一校验 `type`/`iss`/`aud`/`exp`/`iat`,`requirePositiveSubject` 与 `requireCanonicalUuid` 校验 sub/sid/jti;时间契约 `iat <= capturedNow < exp`;异常统一 `InvalidTokenException`(固定文本,不含 Token/claim) | +| A3 服务端会话状态 | **PASS** | `JwtAuthenticationFilter` 验签后调用 `isSessionActive`:用户存在 + active + session 下存在未消费/未撤销/未过期记录;`session_id IS NULL` 永不命中;异常失败关闭;`createSession` 对 inactive 用户先抛异常(不先发 Token) | +| A4 刷新轮换事务 | **PASS** | `refresh()` 无 `@Transactional`(编排入口);`executeRefreshAttempt` 在独立事务;消费与写后继同事务;条件更新含 id/version/revoked/consumedAt/expiredAt;`consumed != 1` 即判定重放;重放信息经 `RefreshAttempt.Replayed` 返回而非抛异常;重放撤销在尝试事务**之后**执行;无 JVM 锁、无 `Thread.sleep` | +| A5 并发重放 | **PASS** | `revokeReplayedSession` 用 REQUIRES_NEW,逐轮 `revokeSession` 后**校验活动 Token 数**,最多重试 3 次(无 sleep),仍失败则抛出(不静默)。日志中已无 `could not take locks` 分支 | +| A6 logout | **PASS** | 只撤销当前 session;幂等;其他设备不受影响;测试未使用 `catch (Throwable)`,未在最终断言前补 logout | +| A7 Token 摘要与脱敏 | **PASS** | HMAC-SHA256 + 独立密钥;64 位小写十六进制;`MessageDigest.isEqual` 常量时间比较;`hash(null)` 与 `hash("")` 同值但生产路径在校验后才调用 | +| A8 capturedNow | **PASS** | 登录/刷新/登出每个入口只读一次 Clock;`created_at`/`expired_at`/`consumed_at`/`revoked_at`/审计均来自该次捕获;固定时区 `Asia/Shanghai` | +| A9 用户禁用与注销 | **PASS** | `createSession` 拒绝 inactive;注销完成在同一业务事务内 `desensitize()`(active=false)+ `revokeAllUserSessions`;回退状态不恢复已撤销 Token;其他用户不受影响 | +| A10 配置安全 | **PASS**(代码层)| 三项密钥缺失/过短/相同/issuer 空/audience 空均启动失败,错误只含**密钥名**不含值;旧 `JWT_SECRET` 不再强制解析;`CandidateKeyGenerator` 在空密钥时**先抛异常**,不产生可预测 key | + +**观察项(非阻断)**:`AppJwtProperties` 的校验规则没有专门的单元测试(设计 §六 曾要求)。本轮改用阶段 O 的真实 JAR 启动矩阵实证,覆盖更强;仍建议后续补一个配置校验单测。 + +## 3. Docker / MySQL / Redis / RabbitMQ + +| 组件 | 版本 | 状态 | +|---|---|---| +| Docker Engine | 29.6.2(linux/amd64) | 可用 | +| `mydreamjob-mysql` | MySQL **8.4.11** | healthy(127.0.0.1:3307) | +| `mydreamjob-redis` | redis:7.4-alpine | healthy | +| `mydreamjob-rabbitmq` | rabbitmq:3.13-management-alpine | healthy | + +验收数据库名:`mydreamjob`。未重建容器、未删除 volume、未打印 `.env` 或口令。 + +## 4–6. Flyway / 表结构 / 启动 + +**Flyway**:接受验收前最新版本为 V41;启动最新 JAR 后 **V42 `auth token sessions` success=1**,失败迁移 0。 + +**`auth_tokens` 真实表结构**(新增 7 列 + 3 索引,旧索引保留): + +``` +session_id VARCHAR(36) NULL token_jti VARCHAR(36) NULL +parent_token_id BIGINT NULL consumed_at / revoked_at DATETIME NULL +revocation_reason VARCHAR(40) version BIGINT NOT NULL DEFAULT 0 +UNIQUE uk_auth_tokens_token_jti (token_jti) +KEY idx_auth_tokens_session_active (session_id, revoked, consumed_at, expired_at) +KEY idx_auth_tokens_user_session (user_id, session_id) +KEY idx_auth_tokens_user / idx_auth_tokens_hash (既有,保留) +``` + +无任何保存 Token 原文的列。 + +**启动**:`Started MyDreamjobApplication`(16.3 s),无 Flyway 错误、无 schema validation 错误;`GET /api/v1/health` → **HTTP 200 / status=UP**;运行时 OpenAPI 175/213。 + +## 7. 自动化实际测试数 + +| 命令 | Tests run | Failures | Errors | Skipped | BUILD | +|---|---|---|---|---|---| +| 原独立失败测试(2 类) | **3** | 0 | 0 | 0 | SUCCESS | +| S02 专项(13 类) | **84** | 0 | 0 | 0 | SUCCESS | +| 认证关联回归 | **208** | 0 | 0 | 0 | SUCCESS | +| `mvn test` | **1758** | 0 | 0 | 0 | SUCCESS | +| `mvn clean verify` | **1758** | 0 | 0 | 0 | SUCCESS(2:21) | + +(候选基线称"扩展专项 87/87" = 专项 84 + 独立 3,与实测一致。) + +## 8–9. Token claims 与类型矩阵(真实 HTTP) + +**claims**:登录响应字段兼容、`expiresIn=86400`、三段 JWT、两 Token 原文不同、jti 不同、sid 相同、type 正确、iss/aud 正确、Access 有 role、Refresh 无 role、均不含个人信息、有效期 24h/7d、均含 iat —— **全部 PASS**。 + +**类型矩阵**(每格记录 method / path / HTTP status / business code / requestId): + +| Token | `/auth/me` | `/auth/refresh` | +|---|---|---| +| Access | **200** | **401** | +| Refresh | **401** | **200**(并轮换) | +| 无 type 旧 Token | 401 | 401 | +| Access 用 Refresh 密钥伪造 | 401 | 401 | +| Refresh 用 Access 密钥伪造 | 401 | 401 | +| 过期 Token | 401 | 401 | +| 缺 iat | 401 | 401 | +| 未来 iat | 401 | 401 | +| 非法 sid | 401 | 401 | +| 非法 sub | 401 | 401 | + +错误响应均未泄漏 JWT 原文、`Bearer`、`token_hash`、sid、jti、手机号、密钥、异常类名或堆栈。 + +## 10–11. 正常轮换与串行重放 + +**轮换**:sid 不变、jti 变化、旧记录 `consumed_at` 非空、version 只 +1、新记录 `parent_token_id` 指向旧记录、同一时刻仅 1 个活动 Refresh、A2 可访问、R2 可再次刷新 —— 全部 PASS。 + +**串行重放**:R1 重放 → **401**;当前 session 活动 Refresh 数 **0**;A1 与 A2 访问 `/auth/me` 均 **401**;R2 刷新 **401**;其他设备 session **200**;写入一次重放安全审计。 + +## 12–13. 真实并发(10 轮)与胜出方 Access + +每轮两个独立 HTTP 客户端 + `threading.Barrier` 同时发起,**10/10 轮**全部满足: + +| 断言 | 实测 | +|---|---| +| 无 HTTP 500 | 10/10 | +| 最多一个刷新成功 | 10/10 | +| 失败方为 401 | 10/10 | +| 重放后活动 Refresh **严格为 0** | 10/10 | +| 原 Access 与**胜出方新 Access** 均失效 | 20 次断言全部满足 | +| 其他设备 session 始终 200 | 10/10 | +| 无锁异常 / 堆栈泄漏 | 10/10 | +| 日志无 `refresh reuse revocation could not take locks` | ✅ | +| 无同一 session 两个活动后继 | ✅ | + +## 14–16. 多设备 / 竞争 / inactive 与注销 + +- **logout**:第一次成功,Access 与 Refresh 随即 401;重复 logout 按既有幂等语义;其他设备不受影响。 +- **多设备隔离**:两设备 sessionId 不同;A 登出后 A 失效、B 的 Access 与 Refresh 均保持有效。 +- **logout 与 refresh 并发**:无 500;未补 logout 的情况下活动 Refresh 数为 0;竞争可能返回的新 Access 最终 401。 +- **inactive**:登录不返回 Token、不新增 `auth_tokens`;置为 inactive 后旧 Access/Refresh 均 401。 +- **注销完成**(走正式懒转换完成路径):状态 COMPLETED、用户 `active=false`、全部会话撤销、两台设备 Access/Refresh 均 401、其他用户完全不受影响。 + +## 17. Clock 与真实数据库时间 + +| 断言 | 实测 | +|---|---| +| `created_at` 与 Access `iat` 同一时刻 | ±1s 内一致 | +| `expired_at` 与 Refresh `exp` 同一时刻 | ±1s 内一致 | +| `consumed_at` 与刷新时刻 | ±1s 内一致 | +| `revoked_at` 与登出时刻 | ±5s 内一致 | +| AUTH 审计 `created_at` 与请求时刻 | ±120s 内一致 | + +## 18–19. 明文扫描 / 审计与日志脱敏 + +- `token_hash` 为 64 位小写十六进制,不等于 Refresh 原文;库中搜索两类 Token 原文命中 **0**。 +- 应用日志与 stdout 扫描:Token / 验证码 / 密钥命中 **0**;扫描器经**注入探针**验证有效。 +- 审计动作仅限 4 个白名单值;details 不含 Token、摘要、`Bearer`、手机号、jti、`Authorization`、密钥;不含原始 sid(用不可逆 `sessionRef`)。 + +## 20–22. 副作用 / RabbitMQ / OpenAPI + +- **数据库副作用**:登录+刷新+串行重放前后,`candidate_dimension_scores`、`messages`、`notifications`、`applications`、`talent_invitations` 内容摘要**零变化**(按 id 摘要比对,非仅 COUNT)。 +- **RabbitMQ**:4 个项目队列全部存在,`ready=0`、`unacked=0`;S02 运行期间无新增消息。 +- **OpenAPI**:静态与运行时均 **175 / 213**;`/auth/refresh` 请求体仅 `refreshToken`;`/auth/logout` 无请求体;公共 schema 不含 tokenHash/sessionId/phone/secret 等内部字段。 + +## 23. PASS / FAIL / SKIP 清单 + +| 阶段 | PASS | FAIL | SKIP | +|---|---|---|---| +| A 代码审查(10 项) | 10 | 0 | 0 | +| B 自动化(5 组) | 5 | 0 | 0 | +| C–E 环境/迁移/启动 | 8 | 0 | 0 | +| F–M 黑盒(含 10 轮并发) | **117** | 0 | 0 | +| N–S 时间/脱敏/副作用/外围/OpenAPI | **30** | 0 | 0 | +| O 配置启动矩阵(9 配置) | **18** | 0 | 0 | +| **合计** | **165** | **0** | **0** | + +## 24. 剩余风险 + +1. **重放撤销重试可能重复写审计**:`revokeReplayedSession` 在需要重试时会再写一对 `AUTH_REFRESH_REUSE_DETECTED` / `AUTH_SESSION_REVOKED`。本轮所有场景(含 10 轮并发)**重试次数为 0**,未观测到重复;属潜在项,建议后续把审计移到循环外。 +2. **`AppJwtProperties` 无专门单元测试**(见第 2 节观察项)。 +3. **旧 `JWT_SECRET` 仍被 `talentdiscovery.CandidateKeyGenerator` 使用**:本轮已确认空密钥时**安全失败**(抛异常、不产生可预测 key),但该配置项仍存在;建议后续为其单独引入专用密钥。 +4. 本轮**未执行**:真实短信供应商、Redis 会话缓存、设备指纹、Access 有效期缩短等 S02 明确非目标项。 +5. 验收数据保留在库(见第 26 节)。 + +## 25. 是否允许冻结 S02 + +**允许。** 24 项验收判据全部满足,无 FAIL、无核心项 SKIP,24 条"不得冻结"清单全部未触发。 + +--- + +## 26. 验收结束处理 + +- 本轮启动的 JAR(PID 21420,命令行含 `s02-acc-main-app.log`)**已停止**;8080 恢复空闲;未停止任何未知 Java 进程;未停止 Docker。 +- **验收数据保留**,未执行删除。理由:`audit_logs` 有 **143** 条 `AUTH_*` 行引用本轮验收用户且该表**无外键**,删除用户会产生孤儿审计行;而本任务边界禁止删除 `audit_logs`。精确足迹如下(供后续人工处置): + +| 范围 | 行数 | +|---|---| +| `users`(本轮 15:25 之后创建,199 前缀) | 35 | +| `auth_tokens`(这些用户) | 93 | +| `audit_logs` `AUTH_*`(这些用户) | 143 | +| `account_deletions`(这些用户) | 1 | + +> 注:库中另有 3 个 199 前缀用户属于 **S01 验收**(2026-09-14 09:48 与 2026-08-13),及其 2 条投递记录,**非本轮产生,未触碰**。 + +- 最终 Git:暂存区**空**、`git diff --check` 无错误、V1–V41 未修改、V42 为唯一新增迁移、未删除文件、**未** `git add -A` / commit / push、未进入 S03。 + +--- + +# S02 独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。 diff --git a/docs/s03-independent-mysql-acceptance-20260915.md b/docs/s03-independent-mysql-acceptance-20260915.md new file mode 100644 index 0000000..c7c46cc --- /dev/null +++ b/docs/s03-independent-mysql-acceptance-20260915.md @@ -0,0 +1,167 @@ +# S03「生产默认账号、简历隐私与 Actuator 隔离」独立代码复验及真实 MySQL/Docker 黑盒验收 + +日期:2026-09-15 +分支:`feat/s03-production-privacy` +结论:**FAIL —— 已复现具体缺陷(阶段 I-1:业务端口 Actuator 路径非 404),S03 不可冻结;报告包含下一轮定向整改指令。** + +## 1. Git 基线与工作区状态 + +- 当前分支 `feat/s03-production-privacy`;HEAD `f399060 feat: isolate and revoke authentication token sessions`。 +- 暂存区为空;全部 S03 修改处于未提交(M/??)状态。 +- `git diff --check` 通过;`git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` 为空(V1–V42 无改动)。 +- 唯一新增迁移:`V43__disable_legacy_seed_accounts.sql`。 +- 未执行 `git add -A`/commit/push/reset/restore/checkout/clean;未删除历史文件。 + +## 2. 独立代码审查(阶段 A) + +| 项 | 结论 | 关键证据 | +|---|---|---| +| A1 V43 迁移安全 | PASS | 3 条 UPDATE(无 DELETE/DROP/TRUNCATE);不按 id 匹配;每条同时匹配原始手机号+邮箱+公开 bcrypt 哈希+主角色+`is_active=1`;只置 `is_active=0`;不碰 `user_roles`;指纹被改的账号不命中;V1–V42 未修改 | +| A2 管理员初始化装配 | PASS | 默认关闭 + 字面量 `"true"` 双重校验;手机号无默认值、非法即启动失败;已有活动 ADMIN 无副作用跳过;不设密码(`password_hash=NULL`)/不发 Token/不发短信/无公网接口;日志与异常文本固定且不含手机号(`AdminBootstrapRunner.ERROR_INVALID_PHONE/ERROR_PHONE_IN_USE`) | +| A3 事务与并发 | PASS | `JdbcTemplate + REQUIRES_NEW` 独立事务,users 与 user_roles 同事务回滚;刻意绕过 Hibernate 避免 `SqlExceptionHelper` 记录绑定手机号;异常被捕获只返回 `CONFLICT`,原始 cause 不传播;冲突后仅 `isValidBootstrapAdmin` 全条件匹配才幂等 | +| A4 简历授权失败关闭 | PASS | `CompanyResumeAccessPolicy`:归属全部服务端推导;无记录/PRIVATE 按 PRIVATE;APPLIED_COMPANIES 严格白名单(SUB/VIEWED/INTERVIEW/HIRED,区分大小写,未知/空白/null/WITHDRAWN/REJECTED 全拒);VERIFIED_COMPANIES 要求 `verified=true` 且岗位归属;授权失败发生在文件读取前 | +| A5 文件与响应安全 | PASS | 授权后读文件;`Content-Disposition attachment+UTF-8`、`Cache-Control: no-store`、Content-Type 白名单回退 octet-stream;响应不含 resumeUrl/storagePath/studentUserId | +| A6 Actuator 隔离(设计层面) | PASS* | 独立管理端口默认 127.0.0.1;仅暴露 health/info/metrics/prometheus;`show-details/components=never`;Prometheus 显式开启;不当 JWT 运维认证。*注:阶段 I-1 真实验收发现业务端口行为不达标,见第 12 节 | +| A7 副作用测试真实性 | PASS | `CompanyResumeDownloadIntegrationTest` 行级字段快照(如 users: id/phone/email/role/is_active/is_verified/updated_at),非 COUNT/SUM;含反证测试(单行 status 变化必须被发现)与 15 表字段覆盖断言 | + +## 3. 自动化独立复验(阶段 B) + +| 命令 | Tests | Failures | Errors | Skipped | 结果 | +|---|---:|---:|---:|---:|---| +| `mvn clean verify`(实测,非沿用历史) | 1834 | 0 | 0 | 0 | BUILD SUCCESS | +| `python docs\verify_openapi.py` | — | — | — | — | paths=176 operations=214,All checks passed | + +## 4. Docker/MySQL 环境(阶段 C) + +- Docker Engine 29.6.2;容器由负责人环境启动(本次仅 `docker start` 已存在容器,未重建、未删 volume)。 +- MySQL 8.4.11(127.0.0.1:3307,库 `mydreamjob`);Redis PONG;RabbitMQ healthy。 +- Flyway V1–V42 全部 success=1。 + +## 5. V43 真实迁移验收(阶段 D) + +- 迁移前三个种子账号脱敏快照:role=ADMIN/STUDENT/COMPANY,均 `is_active=1`,指纹与 V1_1 原始值完全匹配,`user_roles` 各 1 条,关联业务数据 4(applications)/21(jobs)/2(profiles)/1(company)。 +- 最新 JAR + dev(`ddl-auto=validate`)启动后 `flyway_schema_history`:`V43 disable legacy seed accounts success=1`,无失败迁移,V1–V42 校验和未变。 +- 三个种子账号 `is_active` 1→0;users 行仍存在;`user_roles` 仍存在;关联业务数据未删除。 +- 指纹偏差隔离夹具(7976/7977,改过手机号与哈希)在 V43 后仍 `is_active=1`,未误伤。 +- 原种子账号(demo 口令)登录返回 400 且无法取得 Token(已禁用账号不能建立会话),达成"不能取得 Token"目标。 + +## 6. 管理员初始化配置矩阵(阶段 E,真实 MySQL) + +| 用例 | 结果 | 实测证据 | +|---|---|---| +| E1 默认关闭 | PASS | 应用正常启动且未创建任何 ADMIN | +| E2 enabled=true+非法手机号 | PASS | 启动失败,固定文案 `admin bootstrap rejected: configured phone is invalid` | +| E3 无活动ADMIN+空闲手机号 | PASS | users 恰 1 行(role=ADMIN、active、verified、`password_hash=NULL`);`user_roles` 仅 ADMIN;无 Token/短信/通知;日志无手机号 | +| E4 重复启动同配置 | PASS | `skipped`;users 与 ADMIN 角色计数均不增 | +| E5 手机号被 STUDENT 占用 | PASS | 启动失败固定文案 `phone is already in use`;该 STUDENT 未被提权/复活/覆盖 | +| E6 真实双 JAR 并发同手机号 | PASS | 两个独立进程 + 屏障同时初始化:最终 users 恰 1 行、ADMIN 角色恰 1 条;一方创建、一方安全幂等跳过;无手机号泄漏;无 `SqlExceptionHelper` 唯一键日志 | + +前置与后置:临时禁用 20 个历史验收 ADMIN(id 逐一记录)并以精确 UPDATE 恢复原状;E3/E6 新建的验收 ADMIN 置为 inactive,未删除。 + +## 7. 启动最新 JAR 与双端口(阶段 F) + +- `Started MyDreamjobApplication`;无 Flyway/schema-validation 错误。 +- 8080 与 8081 均监听;**8081 仅绑定 127.0.0.1**。 +- `GET /api/v1/health` → 200/UP(仅最小状态,无 db/redis/rabbit/连接池字段)。 +- 运行时 OpenAPI:paths=176,operations=214,与静态一致。 + +## 8. 企业简历真实 HTTP 权限矩阵(阶段 G / H) + +夹具:3 家企业(认证/未认证/其他)、6 名学生(APPLIED_COMPANIES·SUBMITTED、PRIVATE、无隐私记录、VERIFIED_COMPANIES·未认证企业、APPLIED_COMPANIES·WITHDRAWN、VERIFIED_COMPANIES·已认证企业),真实简历文件进存储基目录,投递/隐私由服务端推导。 + +| 用例 | HTTP | 期望 | 结果 | +|---|---|---|---| +| 匿名 | 401 | 401 | PASS | +| STUDENT Token | 403/2002 | 403 | PASS | +| ADMIN Token | 403/2002 | 403 | PASS | +| 其他企业 | 403/2002 | 403 | PASS | +| 未认证企业(岗位不属于) | 403/2002 | 403 | PASS | +| 认证企业+APPLIED+SUBMITTED | 200 | 200 | PASS(下文字节/头断言) | +| PRIVATE 隐私 | 403/2002 | 403 | PASS | +| 无隐私记录(默认 PRIVATE) | 403/2002 | 403 | PASS | +| VERIFIED_COMPANIES+未认证企业 | 403/2002 | 403 | PASS | +| APPLIED_COMPANIES+WITHDRAWN | 403/2002 | 403 | PASS | +| VERIFIED_COMPANIES+已认证企业 | 200 | 200 | PASS(字节一致) | +| 投递不存在 | 404/8002 | 404 | PASS | + +成功下载断言:文件字节与夹具逐字节一致(`RESUME-FIXTURE-A/F-20260915`,字节级比较 PASS);`Content-Disposition: attachment; filename="=?UTF-8?Q?...?="`(净化文件名);`Cache-Control: no-store`;Content-Type 走白名单;响应不含存储路径/resumeUrl/studentUserId/手机号/邮箱。 +日志/审计扫描:未发现简历正文标记、本地路径、S03 测试手机号、密钥等禁止字段。 + +## 9. Actuator 双端口真实验收(阶段 I) + +**管理端口 127.0.0.1:8081 — 全部 PASS** +- `/actuator/health` 200,JSON 仅含 `status`(最小化,无 components/db/redis/rabbit/diskSpace/hikari)。 +- `/actuator/metrics` 200(含 names 数组);`/actuator/prometheus` 200(Prometheus 文本、含 `jvm_memory_used_bytes` 等稳定指标)。 +- `/actuator/env`、`/beans`、`/configprops`、`/heapdump`、`/threaddump` 均不可读(401)。 +- 端口仅绑定 127.0.0.1;prometheus 文本未发现密码/Token/Bearer 等敏感值。 + +**业务端口 8080 — FAIL(本轮关键缺陷)** + +| 路径 | 匿名 | 带 STUDENT Token(有效) | +|---|---|---| +| `/actuator` | 401 | 500 | +| `/actuator/health` | 500 | 500 | +| `/actuator/metrics` | 500 | 500 | +| `/actuator/info` | 500 | 500 | +| `/actuator/prometheus` | 500 | 500 | +| `/actuator/env` | 401 | 500 | +| `/actuator/beans` | 401 | 500 | + +任务书要求这些路径**无论匿名或带 Token 一律 404**;实测为 401/500。根因:`SecurityConfig` 对 `/actuator/health/**` 等显式 `permitAll`,但业务端口并不注册 Actuator 端点,请求落入 MVC 静态资源解析抛 `NoResourceFoundException`,被 `GlobalExceptionHandler` 通用 handler 映射为 500;未放行的 `/actuator`、`/actuator/env`、`/actuator/beans` 在匿名时被安全层拦为 401,带 Token 后同样 500。 +同时,`ActuatorIsolationIntegrationTest.businessPortDoesNotExposeActuatorEndpoints` 断言这些路径为 404 且在 H2 测试环境下通过——**测试环境(H2、无独立管理端口随机化 etc.)与真实 MySQL + 完整 Security 配置的生产行为不一致,测试未能捕获 401/500 差异**,属测试真实性缺口。 + +## 10. 副作用(阶段 J) + +下载成功、下载拒绝、下载 404 三类请求前后,对 15 张表(users、user_roles、student_profiles、resume_versions、applications、jobs、companies、auth_tokens、audit_logs、candidate_dimension_scores、notifications、messages、scoring_risk_events、scoring_risk_actions、talent_invitations)作行级全量快照对比:**完全一致,无任何变化**。PASS。 + +## 11. RabbitMQ(阶段 K) + +4 个队列真实存在且 0 积压:`queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq`(messages=0、ready=0、unacked=0)。管理员初始化与简历下载均未产生新增消息。PASS。 + +## 12. OpenAPI(阶段 L) + +静态 `verify_openapi.py`:176 paths / 214 operations,全部通过(含 S03 简历下载契约、S02/S03 校验)。运行时 `/api/v1/v3/api-docs`:176/214 与静态一致。新端点仅 `GET /company/candidates/{applicationId}/resume`,无 requestBody、返回文件流;企业 DTO 不含 resumeUrl/storagePath/studentUserId/phone/email。PASS。 + +## 13. PASS / FAIL / SKIP 清单 + +- PASS:阶段 A(A1–A7);阶段 B(1834 测试、OpenAPI 静态);阶段 C;阶段 D(V43 真实迁移与种子禁用、不误伤、登录不可用);阶段 E(E1–E6);阶段 F;阶段 G/H(12/12、字节与头断言、日志脱敏);阶段 I-2 管理端口;阶段 J;阶段 K;阶段 L。 +- **FAIL:阶段 I-1(业务端口 Actuator 路径 401/500 而非 404)+ 关联测试真实性缺口(ActuatorIsolationIntegrationTest 与生产行为不一致)。** +- SKIP:无(核心项全部执行;仅"更大规模容量压测"不在本轮范围,不构成阻断)。 + +## 14. 剩余风险 + +- 业务端口对 `/actuator/health` 等返回 500:属于错误响应泄漏(对探测者暴露服务器异常迹象),且不满足契约的 404 语义;修复后需补"业务端口带 Token 亦为 404"的真实 HTTP 覆盖。 +- 登录接口对已禁用账号返回 400 而非 403/401(无法取得 Token 已满足;状态码语义差异建议与契约对齐)。 +- 管理端口依赖网络隔离运维边界,生产中需确认部署层网络策略确实限制 8081 仅内网可访问。 + +## 15. 是否允许冻结 S03 + +**不允许。** 建议整改指令(下一轮定向修复,不修改本报告已执行的验收环境): + +1. 业务端口对未注册 Actuator 路径统一返回 404: + - 在 `GlobalExceptionHandler` 增加 `NoResourceFoundException` 的精确映射(404),避免落入通用 500;或 + - 移除 `SecurityConfig` 中业务端口对 `/actuator/**` 的 `permitAll` 并显式声明业务端口 Actuator 不可达(任选其一,保持管理端口行为不变)。 +2. 增加真实 MySQL 环境下的 HTTP 断言:业务端口 `/actuator`、`/actuator/health`、`/actuator/metrics`、`/actuator/prometheus`、`/actuator/env`、`/actuator/beans` 在匿名与有效 STUDENT/COMPANY/ADMIN Token 下均为 404;并说明测试环境中为何出现 H2 404 而真实 MySQL 401/500 的差异,消除测试假绿。 +3. 修复后在最新 JAR + 真实 MySQL 上重跑阶段 I(业务端口 404 矩阵 + 管理端口只读矩阵)与 1834 项全量回归。 + +本报告不包含数据库密码、JWT、验证码、完整手机号、邮箱、验证手机号或简历正文。验收脚本与日志保留在 `backend-infra/s03-*.ps1|.py|.log`,夹具数据按"本轮唯一前缀/精确主键"清理并记录;`audit_logs` 未做删除。 + +## 16. Actuator 404 增量复验结果(定向整改后) + +上一节记录了初次独立验收的唯一阻断:业务端口 8080 上 `/actuator/**` 匿名/带 Token 行为为 401/500 而非任务书要求的 404,且 H2 测试与生产行为不一致。针对该阻断完成定向整改后,使用最新 `mvn clean verify` JAR 与真实 MySQL 8.4.11 复验: + +1. **整改方式 —— SecurityConfig**:原对 `/actuator/health/**`、`/actuator/info`、`/actuator/metrics/**`、`/actuator/prometheus` 的四个 `permitAll` 收敛为单一 `.requestMatchers("/actuator/**").permitAll()`。目的**非暴露 Actuator**(Actuator 仍只注册在独立管理端口),而是让所有 `/actuator/**` 请求进入 MVC 的不存在资源处理,消除匿名请求先被认证层转 401 的分支;业务端口未新增任何 Actuator Controller 或端点映射。 +2. **整改方式 —— GlobalExceptionHandler**:新增 `@ExceptionHandler(NoResourceFoundException.class)`,固定返回 HTTP 404、`code=404`、`message=not found`、`requestId`;不回显异常 message、请求路径、本地路径或堆栈;日志仅固定文案 + 归一化 requestId;未新增 ErrorCode。 +3. **业务端口矩阵(真实 HTTP)**:14 条路径(`/actuator`、`/actuator/`、`/actuator/health`、`/actuator/health/liveness`、`/actuator/info`、`/actuator/metrics`、`/actuator/metrics/jvm.memory.used`、`/actuator/prometheus`、`/actuator/env`、`/actuator/beans`、`/actuator/configprops`、`/actuator/heapdump`、`/actuator/threaddump`、`/actuator/unknown`)× 6 种身份(匿名 / STUDENT / COMPANY / ADMIN / 非法 Token / Refresh Token)共 **84 个组合全部返回 404**,且响应递归扫描无任何异常类名、框架包名、路径、Token、手机号等泄漏。 +4. **管理端口 127.0.0.1:8081**:health(200,最小 JSON,仅 `status`)/ info(200)/ metrics(200,含 names)/ prometheus(200,含稳定 JVM 指标)白名单端点正常;env / beans / configprops / heapdump / threaddump / unknown 全部 404(未暴露端点未被修复带成 200);8081 仅绑定 127.0.0.1。 +5. **副作用**:复验期间日志无 `NoResourceFoundException` 堆栈、无 ERROR;RabbitMQ 4 个队列(sms.verification / notification / audit / notification.dlq)0 积压;数据库无任何改动。 +6. **自动化**:定向(Actuator/Security/GlobalExceptionHandler)61/61;全量 `mvn clean verify` **1836/1836**(含新增 2 个 Actuator 复验测试),Failures=0、Errors=0、Skipped=0;OpenAPI 静态与运行时均为 **176 paths / 214 operations**,Actuator 不进入业务 OpenAPI。 +7. **一致性**:V1–V42 未修改,V43 仍为唯一新增迁移;未新增 ErrorCode、公网业务接口、表或索引;应用进程已停止,当前未提交、未推送。 + +## 17. 最终结论 + +- 初次独立验收发现业务端口 Actuator 401/500 阻断; +- 完成定向整改(SecurityConfig 统一 `/actuator/**` 语义 + GlobalExceptionHandler 安全 404 映射 + 失败测试补齐); +- 最新 JAR + 真实 MySQL/Docker 增量复验全部通过。 + +**S03 独立代码复验和真实 MySQL/Docker 黑盒全部通过,可以进入精确暂存检查点。** \ No newline at end of file diff --git a/docs/s03-production-security-and-resume-privacy-design.md b/docs/s03-production-security-and-resume-privacy-design.md new file mode 100644 index 0000000..a9cfed1 --- /dev/null +++ b/docs/s03-production-security-and-resume-privacy-design.md @@ -0,0 +1,167 @@ +# S03 生产配置、简历隐私与 Actuator 隔离设计 + +状态:设计已确认,待按独立检查点实施。 + +## 1. 目标与边界 + +S03 只处理三类高风险问题: + +1. 禁用仍保持原始状态的通用 Flyway 演示账号,并提供显式、一次性的管理员初始化能力; +2. 让 `resumeVisibility` 在企业读取简历文件的正式边界真正生效; +3. 将 Actuator 健康探针与指标端点隔离,防止普通业务用户读取运行指标。 + +S03 不修改 Access/Refresh Token 语义,不修改评分、排名、推荐、人才发现、邀约、Offer、申诉、消息撤回、简历上传与学生本人下载语义,不做性能重构,不进入 Q01/R01。 + +## 2. 已核实的现状 + +- `V1_1__seed_data.sql` 无环境隔离地插入已知 ADMIN、STUDENT、COMPANY 账号及公开口令哈希。 +- V1–V42 已经执行,不允许修改校验和;S03 使用新的 V43 迁移。 +- `student_privacy_settings.resume_visibility` 已支持 `PRIVATE`、`APPLIED_COMPANIES`、`VERIFIED_COMPANIES`,默认 `PRIVATE`。 +- `PrivacySettingsService.canCompanyViewResume(...)` 没有生产调用方。 +- 该方法的 `APPLIED_COMPANIES` 分支没有核对投递岗位归属 `companyId`,不能直接作为授权边界使用。 +- 企业候选人 DTO 当前主动排除了 `resumeUrl`;当前没有企业下载简历文件的正式接口。 +- `/actuator/health/**` 和 `/actuator/info` 公开;`metrics/prometheus` 被暴露后会落入普通 `authenticated()`,任何登录用户均可能访问。 + +## 3. S03-A 历史种子账号治理 + +### 3.1 V43 迁移原则 + +新增且仅新增: + +`V43__disable_legacy_seed_accounts.sql` + +V43 只能禁用仍与 V1_1 原始指纹完全一致的三条种子账号。匹配至少同时包含: + +- 原始手机号; +- 原始邮箱; +- 原始公开 bcrypt 哈希; +- 原始主角色; +- `is_active=1`。 + +不得依赖固定 `id=1/2/3`。不得删除用户、角色、审计或业务数据,不得重置自增。不得修改已被运营人员改过手机号、邮箱、哈希或角色的账号。 + +命中后仅将 `users.is_active` 置为 `0`。保留记录与关联关系,用于历史数据完整性和审计追溯。由于认证签发链已拒绝 inactive 用户,已知账号不能再取得会话。 + +迁移必须幂等,并分别覆盖 ADMIN、STUDENT、COMPANY 三条原始种子。迁移报告只记录命中数量,不输出完整手机号、邮箱或哈希。 + +### 3.2 管理员一次性初始化 + +新增显式开关,默认关闭: + +- `app.bootstrap.admin.enabled=${ADMIN_BOOTSTRAP_ENABLED:false}` +- `app.bootstrap.admin.phone=${ADMIN_BOOTSTRAP_PHONE:}` +- `app.bootstrap.admin.nickname=${ADMIN_BOOTSTRAP_NICKNAME:平台管理员}` + +初始化器仅在开关严格为 `true` 时装配。普通启动不得隐式创建管理员。 + +初始化规则: + +1. 手机号必须通过现有手机号格式校验;不得写日志、异常详情或审计 details。 +2. 数据库已有活动 ADMIN 时安全跳过,不创建第二个管理员。 +3. 数据库没有活动 ADMIN 且手机号不存在时,创建 `active=true`、`verified=true` 的 ADMIN,并同时写入 `users.role` 与 `user_roles`。 +4. 手机号已属于 STUDENT/COMPANY、inactive 用户或角色信息不一致时启动失败,禁止自动提权或复活。 +5. 相同配置重复启动必须幂等。 +6. 初始化成功后应提示固定文本,部署方必须移除开关;日志不得包含手机号。 +7. 多实例部署时只允许一个实例带初始化开关启动;数据库唯一手机号约束作为最后防线,冲突必须失败而不是吞掉。 + +不新增公网初始化接口,不接受请求参数创建 ADMIN,不设置默认密码。管理员仍使用正式短信认证流程;真实短信供应商未配置时不能以 Mock 绕过。 + +## 4. S03-B 简历隐私授权 + +### 4.1 唯一新增企业接口 + +新增: + +`GET /company/candidates/{applicationId}/resume` + +该接口返回文件流,不返回 `resumeUrl`、存储相对路径、绝对路径、学生用户 ID、手机号或邮箱。文件名必须继续经过 `ResumeFilenameSanitizer`。 + +除该接口外不新增按 `studentUserId`、`candidateKey` 或任意 URL 下载简历的入口。人才发现接口本轮不得直接下载简历。 + +### 4.2 授权顺序 + +必须在读取文件前依次完成: + +1. 从 `SecurityContext` 取得当前用户,要求 COMPANY; +2. 定位当前用户拥有的 Company; +3. 查询 `applicationId` 及对应 Job; +4. 校验 Job 属于当前 Company; +5. 取得投递对应的 `studentUserId`; +6. 按隐私设置判定可见性; +7. 通过后才读取 `student_profiles.resume_url` 和文件内容。 + +跨企业投递统一返回既有 403 语义,不通过差异响应泄露归属。隐私拒绝使用 `FORBIDDEN(2002)`。不存在的投递保持既有 `APPLICATION_NOT_FOUND`;授权通过但学生没有简历时使用既有 `PROFILE_NOT_FOUND`,不新增 ErrorCode。 + +### 4.3 可见性决策表 + +| 设置 | 企业条件 | 投递条件 | 结果 | +|---|---|---|---| +| 无设置记录/null | 任意 | 任意 | 403,按 PRIVATE 处理 | +| PRIVATE | 任意 | 包括已投递、面试、录用 | 403 | +| APPLIED_COMPANIES | 岗位属于当前企业 | SUBMITTED/VIEWED/INTERVIEW/HIRED | 200 | +| APPLIED_COMPANIES | 岗位属于当前企业 | WITHDRAWN/REJECTED | 403 | +| APPLIED_COMPANIES | 其他企业 | 任意 | 403 | +| VERIFIED_COMPANIES | 当前企业 `verified=true` 且拥有该投递岗位 | 任意合法投递状态 | 200 | +| VERIFIED_COMPANIES | 未认证企业或其他企业 | 任意 | 403 | + +`VERIFIED_COMPANIES` 不是“知道学生 ID 即可查看”。它仍要求合法的候选业务上下文,本轮该上下文限定为当前企业拥有的 `applicationId`。 + +### 4.4 代码结构 + +将现有布尔方法收束为失败关闭的授权组件,例如 `CompanyResumeAccessPolicy`。授权组件必须校验 company ownership,不能依赖 Controller 先验,也不能把客户端传入的 `studentUserId/companyId` 当成可信事实。 + +文件读取放在独立 Service 方法中。Controller 只解析身份、路径参数、构建安全响应头。任何拒绝路径都不得调用 `ResumeStorageService.read()`。 + +企业候选人列表与详情 DTO 保持不含 `resumeUrl`。学生本人上传、下载、版本、导出接口完全不变。 + +## 5. S03-C Actuator 隔离 + +### 5.1 运行边界 + +推荐配置: + +- `management.server.port=${MANAGEMENT_SERVER_PORT:8081}` +- `management.server.address=${MANAGEMENT_SERVER_ADDRESS:127.0.0.1}` +- 仅暴露 `health,info,metrics,prometheus`; +- `health.show-details=never`; +- `health.show-components=never`。 + +生产默认只绑定回环地址。需要采集 Prometheus 时,由部署层通过受控网络或反向代理访问管理端口,不通过公网业务端口开放。 + +### 5.2 业务端口行为 + +业务端口不得提供 `/actuator/metrics` 或 `/actuator/prometheus`。普通 STUDENT、COMPANY、ADMIN 的业务 JWT 不能把管理指标变成公网资源。健康检查继续提供最小 `UP/DOWN` 信息,不返回数据库、Redis、RabbitMQ、磁盘路径或连接池细节。 + +本轮不新增运维账号体系,不把 ADMIN 等同于基础设施运维身份。 + +## 6. 数据与副作用 + +允许的数据库变化仅为: + +- V43 对完全匹配的旧种子账号执行 `is_active: 1 -> 0`; +- 显式管理员初始化首次创建一条 ADMIN 用户和对应角色; +- 企业成功下载简历如需审计,只允许白名单动作与 `applicationId/companyId`,不得记录路径、文件名、学生 ID、联系方式或简历内容。 + +简历访问不得修改 `student_profiles`、`resume_versions`、投递、评分、排名、推荐、邀约、消息、通知或风险表,不发送 RabbitMQ、短信或邮件。 + +## 7. 契约与兼容性 + +- S02 基线为 175 paths / 213 operations。 +- 新增一个企业简历下载接口后,预期为 176 paths / 214 operations;最终以运行时 OpenAPI 导出为准。 +- 不修改既有响应字段,不把 `resumeUrl` 加回任何企业 DTO。 +- 不新增 ErrorCode。 +- V43 是唯一新增迁移,V1–V42 不得修改。 + +## 8. 验收门槛 + +必须同时通过: + +- V43 在 H2 快速层和真实 MySQL 8 执行成功; +- 仅完全匹配的三条原始种子账号被禁用,改过任一指纹的账号不受影响; +- 管理员初始化关闭、成功、重复、已有 ADMIN、手机号冲突、并发冲突场景; +- 三种简历可见性及所有投递状态、跨企业、未认证企业、ADMIN/STUDENT/匿名权限矩阵; +- 拒绝路径零文件读取,企业 JSON/响应头无路径及个人信息泄漏; +- Actuator 业务端口与管理端口真实启动验证; +- 全量测试和 `mvn clean verify` 零失败、零错误、零跳过; +- 静态与运行时 OpenAPI 一致; +- 真实 MySQL/Docker 黑盒通过后才能冻结。 diff --git a/docs/s03-production-security-and-resume-privacy-implementation-instruction.md b/docs/s03-production-security-and-resume-privacy-implementation-instruction.md new file mode 100644 index 0000000..5cc1430 --- /dev/null +++ b/docs/s03-production-security-and-resume-privacy-implementation-instruction.md @@ -0,0 +1,270 @@ +# S03 生产配置、简历隐私与 Actuator 隔离实施指令 + +项目根目录:`C:\Users\15642\Desktop\zhilai` + +候选基线:S02 已完成独立代码复验、真实 MySQL/Docker 黑盒、精确提交并推送;功能分支提交为 `f399060edb2ff778322a4f021c93213f39a6f6bf`。 + +本轮必须先完整阅读 `docs/s03-production-security-and-resume-privacy-design.md`。该文档是 S03 权威口径;发生冲突时停止并报告,不得自行扩大语义。 + +## 一、开始前核查 + +执行: + +```powershell +cd C:\Users\15642\Desktop\zhilai +git branch --show-current +git status --short +git diff --cached --name-only +git log -5 --oneline +git diff --check +``` + +要求: + +- 从 S02 已提交基线创建独立分支 `feat/s03-production-privacy`; +- 切分支前确认暂存区和已跟踪工作区为空; +- 保留全部历史未跟踪脚本、日志、JSON、SQL、xlsx、`.env` 和验收目录; +- 不执行 `git reset/restore/checkout -- /clean`;创建新分支仅允许 `git switch -c feat/s03-production-privacy`; +- 不执行 `git add -A`、commit、push; +- 实施阶段不操作 Docker,不执行真实 MySQL 黑盒; +- 搜索并完整阅读适用的 `AGENTS.md`,不存在则记录。 + +如已有修改与 S03 目标文件重叠,停止并报告。 + +## 二、必须完整阅读 + +迁移与模型: + +- `V1__base_schema.sql` +- `V1_1__seed_data.sql` +- `V2__business_schema.sql` +- `V27__student_privacy_settings.sql` +- `V42__auth_token_sessions.sql` +- `User`、`UserRepository`、`Company`、`CompanyRepository` +- `Application`、`ApplicationRepository`、`Job`、`StudentProfile` + +隐私与简历: + +- `PrivacySettingsService`、`StudentPrivacySettings`、`ResumeVisibility` +- `ApplicationController`、`ApplicationService`、`CandidateDetailView` +- `ProfileController`、`ProfileService` +- `ResumeStorageService`、`LocalResumeStorageService` +- `ResumeFilenameSanitizer`、`ResumeFileValidator` +- 全部 `profile`、`resume`、`privacy`、`application` 测试 + +认证与运维: + +- `SecurityConfig`、`CurrentUserAccessor` +- `AuthService`、`AuthSessionService`、`JwtAuthenticationFilter` +- `application.yml` 及所有 profile 配置 +- `ErrorCode`、`GlobalExceptionHandler` +- `pom.xml` 与 Actuator 测试 + +文档: + +- S03 权威设计文档 +- `privacy-settings-api.md` +- `resume-api.md` +- `frontend-integration.md` +- `backend-development-roadmap.md` +- `phase1-scope-and-acceptance.md` +- `openapi-rc.json` +- `verify_openapi.py` + +先执行搜索并记录所有企业侧简历暴露点: + +```powershell +rg -n -i "resumeUrl|resume_url|download.*resume|resume.*download|ResumeStorageService\.read|canCompanyViewResume|studentUserId" mydreamjob-server/src/main/java +``` + +## 三、S03-A:禁用历史种子账号 + +新增且仅新增 `V43__disable_legacy_seed_accounts.sql`。 + +要求: + +1. 不修改 V1–V42。 +2. 不按固定 ID 匹配。 +3. 每条 UPDATE 必须同时匹配 V1_1 中的原手机号、原邮箱、原 bcrypt 哈希、原角色、`is_active=1`。 +4. ADMIN、STUDENT、COMPANY 三条分别处理。 +5. 仅更新 `is_active=0`,不删除 users/user_roles,不修改历史业务表。 +6. 改过手机号、邮箱、哈希或角色的同源账号不得被禁用。 +7. 重复执行效果稳定。 + +新增迁移集成测试,至少覆盖:完整指纹命中、每个单字段偏差不命中、非固定 ID 也能命中、重复执行、关联数据保留、V1–V42 校验和不变。 + +## 四、S03-A:一次性管理员初始化 + +新增强类型配置和条件装配,例如: + +- `AdminBootstrapProperties` +- `AdminBootstrapRunner` +- 必要的内部 Service + +严格按设计文档执行:默认关闭;只有严格 `true` 才装配;手机号来自环境变量;无默认手机号;不打印手机号;不提供公网接口。 + +核心行为: + +- 已有活动 ADMIN:无副作用跳过; +- 无活动 ADMIN + 手机号不存在:创建活动且已验证 ADMIN,`users.role` 与 `user_roles` 一致; +- 手机号已属于任何用户或角色状态异常:启动失败,禁止提权、复活或覆盖; +- 重复启动幂等; +- 多实例冲突安全失败; +- 不签发 Token、不发送短信、不写默认密码。 + +错误信息必须固定且不包含手机号、用户对象、SQL 或环境变量值。日志只能使用固定事件文本。 + +必须先写失败测试,覆盖:disabled、missing phone、invalid phone、existing active admin、new admin、duplicate run、existing student/company/inactive user、users/user_roles 一致性、日志脱敏、并发冲突。 + +## 五、S03-B:企业简历下载与隐私 + +新增唯一公网接口: + +`GET /company/candidates/{applicationId}/resume` + +推荐新增: + +- `CompanyResumeAccessPolicy` +- `CompanyResumeService` +- 必要的文件响应 record/DTO + +不得将文件访问逻辑塞入现有候选人 DTO,不得返回 `resumeUrl`。 + +授权必须在 Service/Policy 中完成,不可信任 Controller 传入的归属信息。按以下顺序:当前 COMPANY 用户 → 当前 Company → Application+Job → Job.companyId → Student → privacy setting → profile/file。 + +必须修复现有 `canCompanyViewResume` 的公司归属缺失:`APPLIED_COMPANIES` 不能只校验学生和状态;必须证明 application 对应 Job 属于当前 company。允许替换该方法或让它委托给新 Policy,但不得保留一个可被误用的宽松生产方法。 + +语义严格使用设计文档决策表: + +- 默认/null 与 PRIVATE:全部企业 403; +- APPLIED_COMPANIES:仅本企业有效投递,WITHDRAWN/REJECTED 拒绝; +- VERIFIED_COMPANIES:企业 verified=true 且拥有 application 的岗位,状态不额外限制; +- 跨企业、未认证、错误角色均拒绝; +- 人才发现本轮不能借 candidateKey 下载简历。 + +响应要求: + +- 使用安全 `Content-Disposition`; +- content type 只能来自已验证的白名单; +- 不返回内部路径、studentUserId、手机号、邮箱; +- 拒绝路径不得调用 storage `read`; +- 文件不存在安全失败,不回显路径; +- 不缓存私有文件,至少返回 `Cache-Control: no-store`; +- 不把简历正文写入日志、审计或异常。 + +权限测试必须使用真实 SecurityFilterChain/MockMvc,并覆盖 COMPANY、其他企业、未认证企业、STUDENT、ADMIN、匿名。集成测试必须使用真实 H2 Repository 和真实本地临时存储,不得 Mock 掉被验证的授权链。 + +对所有状态 `SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN` 分别验证三种 visibility。验证企业候选人列表/详情仍不含 `resumeUrl`。 + +## 六、S03-C:Actuator 隔离 + +按设计新增: + +```yaml +management: + server: + port: ${MANAGEMENT_SERVER_PORT:8081} + address: ${MANAGEMENT_SERVER_ADDRESS:127.0.0.1} +``` + +并将 health details/components 设为 `never`。不要使用业务 JWT 角色代替运维网络隔离,不新增运维公网接口。 + +必须验证: + +- 业务端口无法访问 metrics/prometheus; +- 管理端口只绑定配置地址; +- health 仅返回最小状态,不含 DB/Redis/RabbitMQ/磁盘/连接池详情; +- metrics/prometheus 只在管理端口存在; +- STUDENT/COMPANY/ADMIN Token 不会使业务端口指标可见; +- dev/test/integration-test 不发生随机端口冲突。 + +若 Spring Boot 独立 management context 与当前 SecurityFilterChain 行为不一致,先新增真实上下文失败测试,再做最小配置;不得通过重新公开指标绕过。 + +## 七、数据与副作用测试 + +前后保存完整摘要,至少覆盖: + +- users、user_roles; +- student_profiles、resume_versions; +- applications、jobs、companies; +- auth_tokens、audit_logs; +- candidate_dimension_scores; +- notifications、messages; +- scoring_risk_events/actions; +- talent_invitations。 + +企业下载只允许读取;不得修改上述业务表,不发送 RabbitMQ、短信、邮件或通知。若记录下载审计,字段必须为固定白名单且不得包含路径、文件名、学生 ID 或内容。 + +## 八、OpenAPI 与文档 + +从运行时契约更新 `docs/openapi-rc.json`,不得手工伪造。 + +更新: + +- `privacy-settings-api.md` +- `resume-api.md` +- `frontend-integration.md` +- `backend-development-roadmap.md` +- `phase1-scope-and-acceptance.md` +- `verify_openapi.py` + +校验器必须递归断言: + +- 只有一个新增路径/operation; +- 下载接口无 requestBody; +- 企业候选人 DTO 不含 `resumeUrl/studentUserId/phone/email/storagePath`; +- 不存在按 studentUserId/candidateKey 的简历下载路径; +- Actuator 不进入业务 OpenAPI; +- 预期基线为 176 paths / 214 operations,最终以运行时生成结果为准。 + +## 九、验证顺序 + +先运行 S03 新增定向测试,再运行: + +```powershell +cd C:\Users\15642\Desktop\zhilai\mydreamjob-server +mvn -Dtest="*AdminBootstrap*Test,*LegacySeed*Test,*Privacy*Test,*Resume*Test,*CompanyCandidate*Test,*Actuator*Test" test +mvn -Dtest="*Auth*Test,*Security*Test,*Application*Test,*Company*Test,*Privacy*Test,*Resume*Test" test +mvn test +mvn clean verify +python ..\docs\verify_openapi.py +``` + +最后执行: + +```powershell +cd C:\Users\15642\Desktop\zhilai +git diff --check +git status --short +git diff --cached --name-only +git diff --name-only -- mydreamjob-server/src/main/resources/db/migration +``` + +必须满足 Failures=0、Errors=0、Skipped=0、clean verify 成功、静态 OpenAPI 校验通过、暂存区为空、V43 为唯一迁移、未新增 ErrorCode。 + +## 十、实施结束状态 + +实施结束立即停止,不操作 Docker,不执行真实 MySQL 黑盒,不暂存、不提交、不推送,不进入 Q01。 + +最终报告必须列出: + +1. V43 精确匹配条件和各账号命中结果; +2. 被修改过的种子账号不受影响证据; +3. 管理员初始化配置、幂等、冲突与并发结果; +4. 是否打印手机号或创建默认密码; +5. 企业简历权限完整决策矩阵; +6. `APPLIED_COMPANIES` 公司归属缺陷修复方式; +7. 拒绝路径文件读取次数; +8. 完整响应与响应头脱敏结果; +9. Actuator 业务端口/管理端口结果; +10. 数据库副作用与 RabbitMQ 结果; +11. 专项、关联、全量测试数及 Failures/Errors/Skipped; +12. clean verify 与 OpenAPI 结果; +13. 精确修改文件和 Git 状态; +14. 是否新增 Flyway、表、索引、ErrorCode、路径; +15. 明确未执行 git add -A、commit、push、Docker/MySQL 黑盒; +16. 明确真实 MySQL/Docker 黑盒仍待独立验收。 + +最终状态只能写: + +“S03 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。” diff --git a/docs/smoke-test.sh b/docs/smoke-test.sh index b363088..7022457 100644 --- a/docs/smoke-test.sh +++ b/docs/smoke-test.sh @@ -1,20 +1,56 @@ #!/usr/bin/env bash + +# ── xtrace 防护(S01 第三轮 F4)─────────────────────────────── +# 必须在读取任何环境变量、组装任何请求之前关闭 xtrace。 +# 脚本是独立可执行程序,安全优先:全程不重新 set -x, +# 避免验证码 / 完整手机号 / LOGIN_BODY / 登录响应 / TOKEN / AUTH 进入 stderr。 +case "$-" in + *x*) set +x ;; +esac + set -u # MyDreamJob backend RC smoke test. -# Required tools: curl, grep, sed, head, tail. +# Required tools: curl, grep, sed, head, tail, docker(仅取测试验证码时)。 # Environment: -# BASE_URL API base URL (default: http://127.0.0.1:8080/api/v1) -# STUDENT_PHONE Fresh 11-digit phone; generated when omitted -# COMPANY_TOKEN Company JWT; company status step is SKIP when omitted -# SERVER_LOG Server log path used to read Mock SMS codes +# BASE_URL API base URL (default: http://127.0.0.1:8080/api/v1) +# STUDENT_PHONE Fresh 11-digit phone; generated when omitted +# COMPANY_TOKEN Company JWT; company status step is SKIP when omitted +# SMS_MOCK_ENABLED=true **必需**:声明被测应用以 app.sms.mock.enabled=true 运行 +# ALLOW_TEST_OTP_DB_READ=true **必需**:显式授权本次本地测试从数据库读取验证码 +# +# 安全说明(S01 定向整改 F1 / 第三轮 F4+F5): +# * 应用日志**不再**输出验证码,本脚本也**不得**从日志/控制台/stdout 读取验证码。 +# * 仅在 [SMS_MOCK_ENABLED=true] **且** [ALLOW_TEST_OTP_DB_READ=true] 时, +# 才通过容器内部只读 SQL 取得验证码;任一条件不满足立即安全失败。 +# * 验证码只保存在 shell 局部变量 / REPLY 约定中,不写日志、不写文件、不输出到终端。 +# * **xtrace 永远关闭**:即使以 `bash -x` 调用,脚本开头即 set +x 且全程不恢复。 +# * 数据库凭据通过容器进程环境 MYSQL_PWD 传递,**不进入 mysql 命令行参数**; +# 使用应用账号 MYSQL_USER(不使用 root,缺失即安全失败)。 +# * 不接入真实短信供应商;生产环境禁止使用该取码方式。 BASE_URL="${BASE_URL:-http://127.0.0.1:8080/api/v1}" -SERVER_LOG="${SERVER_LOG:-}" PASS=0 FAIL=0 SKIP=0 +# ── 凭据变量登记(退出时统一清理)───────────────────────────── +# 覆盖全部敏感变量:手机号、验证码、登录请求体、登录响应、JWT、Authorization 头。 +# 注意:不得在此处给 PHONE / STUDENT_PHONE / COMPANY_TOKEN 赋空值, +# 它们携带调用方传入值或由后续逻辑生成。 +SMS="" +OTP="" +REPLY="" +LOGIN_BODY="" +LOGIN="" +TOKEN="" +AUTH="" +cleanup_secrets() { + unset PHONE SMS OTP REPLY LOGIN_BODY LOGIN TOKEN AUTH 2>/dev/null || true + unset STUDENT_PHONE COMPANY_TOKEN 2>/dev/null || true +} +trap cleanup_secrets EXIT + if [ -n "${STUDENT_PHONE:-}" ]; then PHONE="$STUDENT_PHONE" else @@ -27,20 +63,113 @@ ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); } fail() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); } skip() { echo "[SKIP] $1"; SKIP=$((SKIP + 1)); } -resolve_log() { - case "$SERVER_LOG" in - [A-Za-z]:/*) - local drive rest - drive="$(printf '%s' "${SERVER_LOG:0:1}" | tr '[:upper:]' '[:lower:]')" - rest="${SERVER_LOG:2}" - if [ -d /mnt/c ]; then - echo "/mnt/$drive$rest" - else - echo "/$drive$rest" - fi - ;; - *) echo "$SERVER_LOG" ;; +# 手机号脱敏(控制台输出不展示完整号码) +mask_phone() { + local p="${1:-}" + if [ "${#p}" -lt 7 ]; then + printf '%s' '***' + return + fi + printf '%s****%s' "${p:0:3}" "${p: -4}" +} + +# ── 布尔解析(大小写/空白不敏感,仅 true 为真)──────────────── +is_true() { + local v + v="$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" + [ "$v" = "true" ] +} + +# ── 取码双重保护 ───────────────────────────────────────────── +# 保护 1:被测应用必须显式处于 mock 短信模式 +require_mock_sms_mode() { + is_true "${SMS_MOCK_ENABLED:-}" +} + +# 保护 2:本次执行必须显式授权读取验证码表 +require_test_otp_db_read_permission() { + is_true "${ALLOW_TEST_OTP_DB_READ:-}" +} + +# 从项目实际 compose 文件解析 mysql 容器名(不硬编码、不假设) +# 使用 BASH_SOURCE 以便脚本被 `source` 时仍能正确定位自身目录 +compose_mysql_container() { + local self base compose + self="${BASH_SOURCE[0]:-$0}" + base="$(cd "$(dirname "$self")" && pwd)" + for compose in "$base/../backend-infra/compose.yaml" "$base/../backend-infra/docker-compose.yml"; do + [ -f "$compose" ] || continue + awk ' + /^[[:space:]]+mysql:[[:space:]]*$/ { in_mysql=1; next } + /^[[:space:]]+[A-Za-z0-9_-]+:[[:space:]]*$/ { in_mysql=0 } + in_mysql && /^[[:space:]]+container_name:[[:space:]]*/ { + sub(/^[[:space:]]*container_name:[[:space:]]*/, ""); print; exit + } + ' "$compose" + done +} + +# 容器内只读查询(F5:凭据经进程环境 MYSQL_PWD 传递,不进入 mysql argv;SQL 经 stdin) +# - 使用应用账号 MYSQL_USER(不使用 root;缺失即安全失败,不回退 root) +# - 仅允许 SELECT;查询失败返回非 0,由 sms_code() 输出固定安全错误 +otp_db_query() { + local phone="$1" purpose="$2" container + container="$(compose_mysql_container | head -n 1)" + [ -n "$container" ] || return 1 + + printf '%s\n' "SELECT code FROM sms_verification_codes" \ + " WHERE phone = '$phone' AND purpose = '$purpose' AND used = 0" \ + " AND expired_at > NOW()" \ + " ORDER BY created_at DESC, id DESC LIMIT 1;" \ + | docker exec -i "$container" sh -c ' + # 应用账号凭据必需,缺失则安全失败(绝不回退 root) + if [ -z "${MYSQL_USER:-}" ] || [ -z "${MYSQL_PASSWORD:-}" ]; then + echo "db credentials unavailable" >&2 + exit 3 + fi + # 密码只经环境变量 MYSQL_PWD 传递,argv 中不出现 -p/--password + MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \ + -u"$MYSQL_USER" \ + -N -B \ + "${MYSQL_DATABASE:-mydreamjob}"' 2>/dev/null \ + | head -n 1 +} + +# 取得验证码:成功时写入 REPLY;绝不打印到 stdout/stderr +sms_code() { + local disabled_msg="test OTP retrieval is disabled" + + if ! require_mock_sms_mode || ! require_test_otp_db_read_permission; then + echo "$disabled_msg" >&2 + return 1 + fi + + case "${PHONE:-}" in + *[!0-9]*|'') echo "$disabled_msg" >&2; return 1 ;; esac + [ "${#PHONE}" -eq 11 ] || { echo "$disabled_msg" >&2; return 1; } + + # xtrace 已在脚本开头全局关闭,这里无需(也不得)保存/恢复其状态 + local code="" qstatus=0 + IFS= read -r code < <(otp_db_query "$PHONE" "login") || qstatus=$? + + # 校验格式:当前验证码为 6 位数字(app.sms.mock.code-length / CODE_PATTERN) + local valid=1 + [ "$qstatus" -eq 0 ] || valid=0 + case "$code" in + [0-9][0-9][0-9][0-9][0-9][0-9]) : ;; + *) valid=0 ;; + esac + + if [ "$valid" -ne 1 ]; then + unset code + echo "test OTP retrieval failed" >&2 + return 1 + fi + + REPLY="$code" + unset code + return 0 } report() { @@ -53,6 +182,23 @@ report() { echo " http=$http_code ${biz_code:-code=N/A} ${request_id:-requestId=N/A}" } +# 仅接收可安全打印的三个字段,不接收任何响应体全文(用于登录等敏感响应) +report_fields() { + local http_code="$1" biz_code="$2" request_id="$3" + echo " http=$http_code ${biz_code:-code=N/A} ${request_id:-requestId=N/A}" +} + +# 从响应中只提取可安全打印字段(http / business code / requestId),不保留响应体 +extract_status_fields() { + local response="$1" + local http_code body biz_code request_id + http_code="$(printf '%s\n' "$response" | tail -n 1)" + body="$(printf '%s\n' "$response" | sed '$d')" + biz_code="$(printf '%s' "$body" | grep -oE '"code"[[:space:]]*:[[:space:]]*[0-9]+' | head -n 1 | grep -oE '[0-9]+' || true)" + request_id="$(printf '%s' "$body" | grep -oE '"requestId"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n 1 || true)" + printf '%s\n%s\n%s' "$http_code" "$biz_code" "$request_id" +} + extract_token() { printf '%s' "$1" | sed -n 's/.*"accessToken"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 } @@ -61,17 +207,16 @@ extract_id() { printf '%s' "$1" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n 1 } -sms_code() { - local log_path - log_path="$(resolve_log)" - [ -n "$log_path" ] || return 1 - [ -f "$log_path" ] || return 1 - grep -a "MOCK SMS CODE.*phone=$PHONE" "$log_path" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2 -} +# ── 可测试性钩子 ─────────────────────────────────────────── +# 供 shell 单元测试加载函数(验证取码保护与泄漏防护)而不执行完整流程。 +# 正常运行(不设该变量)时完全不生效,不影响任何行为。 +if [ "${SMOKE_TEST_LIB_ONLY:-}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi echo "=== MyDreamJob backend smoke test ===" echo "BASE_URL=$BASE_URL" -echo "PHONE=$PHONE" +echo "PHONE=$(mask_phone "$PHONE")" say "GET /health" HEALTH="$(curl -sS -w '\n%{http_code}' "$BASE_URL/health" || true)" @@ -84,19 +229,33 @@ for attempt in 1 2 3; do curl -sS -X POST "$BASE_URL/auth/sms/send?phone=$PHONE&purpose=login" \ -H "X-Request-Id: smoke-sms-$attempt" >/dev/null 2>&1 || true sleep 2 - SMS="$(sms_code || true)" - [ -n "$SMS" ] && break + if sms_code; then + SMS="$REPLY" + unset REPLY + [ -n "$SMS" ] && break + fi done if [ -z "$SMS" ]; then - fail "cannot read Mock SMS code; set SERVER_LOG to the server log path" + fail "login skipped" + unset SMS TOKEN="" else - LOGIN="$(curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/auth/login" \ + # 请求体经 stdin 传入(--data-binary @-),验证码不出现在进程参数中 + LOGIN_BODY="{\"phone\":\"$PHONE\",\"code\":\"$SMS\",\"purpose\":\"login\"}" + LOGIN="$(printf '%s' "$LOGIN_BODY" | curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/auth/login" \ -H 'Content-Type: application/json' \ -H 'X-Request-Id: smoke-login-001' \ - -d "{\"phone\":\"$PHONE\",\"code\":\"$SMS\",\"purpose\":\"login\"}" || true)" - report "$LOGIN" + --data-binary @- || true)" + unset LOGIN_BODY + unset SMS + # 只保留可安全打印的三个字段;登录响应全文绝不交给打印函数,也绝不落盘 + LOGIN_FIELDS="$(extract_status_fields "$LOGIN")" TOKEN="$(extract_token "$LOGIN")" + unset LOGIN + report_fields "$(printf '%s' "$LOGIN_FIELDS" | sed -n '1p')" \ + "$(printf '%s' "$LOGIN_FIELDS" | sed -n '2p')" \ + "$(printf '%s' "$LOGIN_FIELDS" | sed -n '3p')" + unset LOGIN_FIELDS if [ -n "$TOKEN" ]; then ok "login"; else fail "login"; fi fi diff --git a/docs/tests/smoke-test-schema-contract-test.sh b/docs/tests/smoke-test-schema-contract-test.sh new file mode 100644 index 0000000..74a756d --- /dev/null +++ b/docs/tests/smoke-test-schema-contract-test.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# S01 第四轮 F6 —— sms_verification_codes 表结构契约门禁 +# +# 目的:防止冒烟脚本再次使用**不存在的列**(F6:expires_at), +# 且不允许测试自己维护一份与生产无关的假字段列表来自证正确。 +# +# 契约来源(本脚本同时使用前两个,并交叉校验二者一致): +# 1. 实体 @Column:mydreamjob-server/.../auth/SmsVerificationCode.java +# 2. 建表 Flyway:mydreamjob-server/.../db/migration/V1__base_schema.sql +# 3. 真实 MySQL SHOW COLUMNS —— 属**真实验收阶段**,本脚本只输出对账命令, +# 不在自动化阶段伪造该来源。 +# +# 重要:expires_at 是 personal_data_export_tasks(V30) / ability_attempts(V35) / +# talent_invitations(V40) 三张表的**真实列**。因此本门禁只对 +# sms_verification_codes 这一个表断言"不存在 expires_at", +# 不得写成全库禁令(那会误报,并掩盖真正的字段错误)。 +# +# 运行: bash docs/tests/smoke-test-schema-contract-test.sh +set -u + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SMOKE="$REPO_ROOT/docs/smoke-test.sh" +ENTITY="$REPO_ROOT/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCode.java" +MIGRATION_DIR="$REPO_ROOT/mydreamjob-server/src/main/resources/db/migration" +FLYWAY="$MIGRATION_DIR/V1__base_schema.sql" +TABLE="sms_verification_codes" + +PASS=0 +FAIL=0 +ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); } +bad() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); } +check() { if [ "$1" = "1" ]; then ok "$2"; else bad "$2"; fi; } + +echo "=== S01 F6: sms_verification_codes 表结构契约门禁 ===" +echo "实体来源 : $ENTITY" +echo "Flyway来源: $FLYWAY" +echo "脚本 : $SMOKE" +echo "" + +# ── 契约来源 1:实体 @Column ────────────────────────────────── +entity_cols="" +if [ -f "$ENTITY" ]; then + # 主键 id 在实体中以 @Id 标注(无 @Column(name=...)),单独补入 + grep -qE '^[[:space:]]*@Id[[:space:]]*$' "$ENTITY" && entity_cols="id" + entity_cols="$entity_cols $(grep -oE '@Column\(name[[:space:]]*=[[:space:]]*"[A-Za-z0-9_]+"' "$ENTITY" \ + | sed 's/.*"\(.*\)"/\1/')" + entity_cols="$(printf '%s' "$entity_cols" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ')" +fi + +# ── 契约来源 2:建表 Flyway ─────────────────────────────────── +flyway_cols="" +if [ -f "$FLYWAY" ]; then + flyway_cols="$(awk -v t="$TABLE" ' + $0 ~ "^CREATE TABLE (IF NOT EXISTS )?" t "([[:space:]]|\\()" { in_t=1; next } + in_t && /^\)/ { in_t=0 } + in_t { + line=$0 + sub(/^[[:space:]]+/, "", line) + if (line ~ /^(INDEX|KEY|PRIMARY|CONSTRAINT|UNIQUE|FOREIGN)/) next + if (line ~ /^[A-Za-z0-9_]+[[:space:]]/) { split(line, a, /[[:space:]]/); print a[1] } + } + ' "$FLYWAY" | sort -u | tr '\n' ' ')" +fi + +check "$([ -f "$ENTITY" ] && echo 1 || echo 0)" "C1. 实体文件存在" +check "$([ -f "$FLYWAY" ] && echo 1 || echo 0)" "C2. 建表 Flyway 存在" +check "$([ -n "$entity_cols" ] && echo 1 || echo 0)" "C3. 实体 @Column 解析出非空列集合" +check "$([ -n "$flyway_cols" ] && echo 1 || echo 0)" "C4. Flyway 解析出非空列集合" + +# 两个独立来源必须一致(否则说明解析失败或生产定义本身不一致) +check "$([ "$(printf '%s' "$entity_cols" | tr ' ' '\n' | grep -c .)" -gt 0 ] \ + && [ "$entity_cols" = "$flyway_cols" ] && echo 1 || echo 0)" \ + "C5. 实体 @Column 与建表 Flyway 的列集合一致" + +echo " 实体列 : ${entity_cols:-<空>}" +echo " Flyway列: ${flyway_cols:-<空>}" +echo "" + +# ── 字段存在性(表限定)─────────────────────────────────────── +check "$(case " $flyway_cols " in *" expired_at "*) echo 1 ;; *) echo 0 ;; esac)" \ + "C6. sms_verification_codes 存在 expired_at 列" +check "$(case " $flyway_cols " in *" expires_at "*) echo 0 ;; *) echo 1 ;; esac)" \ + "C7. sms_verification_codes **不存在** expires_at 列" +check "$(case " $entity_cols " in *" expired_at "*) echo 1 ;; *) echo 0 ;; esac)" \ + "C8. 实体 expired_at 映射存在" + +# ── 反向护栏:expires_at 确实是别的表的真实列 ────────────────── +# 若此条失败,说明有人把它当成了"全库不存在的列"——那会让本门禁退化成误报。 +other_tables_with_expires_at=0 +for f in "$MIGRATION_DIR"/V30__personal_data_export_tasks.sql \ + "$MIGRATION_DIR"/V35__ability_assessment.sql \ + "$MIGRATION_DIR"/V40__enterprise_talent_invitations.sql; do + [ -f "$f" ] || continue + grep -qE '^[[:space:]]*expires_at[[:space:]]' "$f" && other_tables_with_expires_at=$((other_tables_with_expires_at + 1)) +done +check "$([ "$other_tables_with_expires_at" -gt 0 ] && echo 1 || echo 0)" \ + "C9. expires_at 确实是其他表的真实列(故门禁必须按表限定,不得全局禁用)" + +# ── 脚本侧:表名与列名 ──────────────────────────────────────── +sql_block="$(awk '/SELECT code FROM sms_verification_codes/,/LIMIT 1;/' "$SMOKE" 2>/dev/null)" +# 只保留双引号内的 SQL 片段,剥掉 shell 语法(printf / 引号 / 续行反斜杠), +# 否则 printf、%s 之类会被误当成列名。 +sql_text="$(printf '%s' "$sql_block" | grep -oE '"[^"]*"' | tr -d '"' | tr '\n' ' ')" +check "$([ -n "$sql_text" ] && echo 1 || echo 0)" "C10. 能从 smoke-test.sh 提取取码 SQL 文本" + +check "$(printf '%s' "$sql_text" | grep -q "FROM $TABLE" && echo 1 || echo 0)" \ + "C11. 取码 SQL 查询表为 $TABLE" +check "$(printf '%s' "$sql_text" | grep -q 'expired_at' && echo 1 || echo 0)" \ + "C12. 取码 SQL 使用 expired_at" +check "$(printf '%s' "$sql_text" | grep -q 'expires_at' && echo 0 || echo 1)" \ + "C13. 取码 SQL **不含** expires_at" + +# 脚本 SQL 使用的每个列名都必须落在真实列集合内 +used_cols="$(printf '%s' "$sql_text" \ + | sed "s/'[^']*'//g" \ + | grep -oE '\b[A-Za-z_][A-Za-z0-9_]*\b' \ + | grep -viE '^(SELECT|code|FROM|WHERE|AND|OR|ORDER|BY|DESC|ASC|LIMIT|NOW)$|^'$TABLE'$' \ + | sort -u | tr '\n' ' ')" +unknown="" +for c in $used_cols; do + case " $flyway_cols " in + *" $c "*) ;; + *) unknown="$unknown $c" ;; + esac +done +check "$([ -z "$unknown" ] && echo 1 || echo 0)" \ + "C14. 取码 SQL 使用的列全部存在于 $TABLE${unknown:+(不存在的列:$unknown)}" + +# 取码列 code 必须在真实列集合内(正向确认解析没跑偏) +check "$(case " $flyway_cols " in *" code "*) echo 1 ;; *) echo 0 ;; esac)" \ + "C15. 取码 SQL 的列 code 是 $TABLE 的真实列" + +echo "" +echo "── 真实验收阶段必须执行(本脚本不伪造该来源)───────────────" +echo "docker exec -i sh -c \\" +echo " 'MYSQL_PWD=\"\$MYSQL_PASSWORD\" mysql -u\"\$MYSQL_USER\" -N -B \"\${MYSQL_DATABASE:-mydreamjob}\" <<<\"SHOW COLUMNS FROM $TABLE;\"'" +echo "对账要求:SHOW COLUMNS 的 Field 集合必须等于上方 Flyway列;" +echo " 必须包含 expired_at;必须不含 expires_at。" +echo "" + +echo "============================================" +echo "PASS=$PASS FAIL=$FAIL" +echo "============================================" +[ "$FAIL" -eq 0 ] diff --git a/docs/tests/smoke-test-sms-code-test.sh b/docs/tests/smoke-test-sms-code-test.sh new file mode 100644 index 0000000..5a09f71 --- /dev/null +++ b/docs/tests/smoke-test-sms-code-test.sh @@ -0,0 +1,548 @@ +#!/usr/bin/env bash +# S01 定向整改 F1 / F4 / F5 —— smoke-test.sh 敏感信息泄漏防护测试 +# +# 覆盖: +# F1 取码机制(双重保护 / 不依赖应用日志 / 格式校验 / 清理) +# F4 xtrace 泄漏:**真实以 `bash -x` 执行被测脚本**,捕获 stdout+stderr 并扫描敏感标记 +# F4 source 模式不把敏感变量带回调用方 +# F5 数据库密码不进入 mysql argv(真实执行容器侧脚本文本,检查假 mysql 的实际 argv) +# §7 文档门禁只针对**活跃文件列表**,不对历史验收报告做全 docs 扫描 +# +# 测试使用假的 docker / curl / mysql(PATH 注入),不要求 Docker 或真实 MySQL。 +# 仓库内不写入真实验证码、JWT 或数据库口令;测试结束扫描输出确认标记未泄露。 +# +# 运行: bash docs/tests/smoke-test-sms-code-test.sh +set -u + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SMOKE="$REPO_ROOT/docs/smoke-test.sh" + +# ── 固定测试标记(均为假值,非真实凭据)───────────────────────── +TEST_CODE="739251" +TEST_PHONE="19900009999" +TEST_JWT="FAKEJWT_MARKER_QA7X" +TEST_DBPASS="FAKEDBPASS_MARKER_QA7X" +MARKER_MSG="test OTP retrieval is disabled" + +TMP="$(mktemp -d)" +FAKEBIN="$TMP/bin" +mkdir -p "$FAKEBIN" +export FAKE_DIR="$TMP" +trap 'rm -rf "$TMP"' EXIT + +PASS=0 +FAIL=0 +ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); } +bad() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); } +check() { if [ "$1" = "1" ]; then ok "$2"; else bad "$2"; fi; } + +# ── 假 docker:真实执行被传入的容器侧 sh -c 脚本(含假 mysql)────── +cat > "$FAKEBIN/docker" <<'FAKEDOCKER' +#!/usr/bin/env bash +# 记录完整 argv(用于 F5 断言:宿主机侧命令行不得含密码) +printf '%s\n' "$*" >> "${FAKE_DIR:?}/docker-argv.log" +script="" +for a in "$@"; do script="$a"; done +# 记录容器侧脚本文本(变量名可以出现,值不可以) +printf '%s\n' "$script" > "${FAKE_DIR}/container-script.txt" +case "${FAKE_DOCKER_MODE:-ok}" in + fail) exit 1 ;; + empty) cat >/dev/null 2>&1 || true; exit 0 ;; + garbage) cat >/dev/null 2>&1 || true; printf '%s\n' "${FAKE_GARBAGE:-not-a-code}"; exit 0 ;; + short) cat >/dev/null 2>&1 || true; printf '%s\n' "12345"; exit 0 ;; +esac +# 真实以本地 sh 执行容器侧脚本,使假 mysql 成为真正被检查 argv 的进程 +exec sh -c "$script" +FAKEDOCKER +chmod +x "$FAKEBIN/docker" + +# ── 假 mysql:记录 argv 与 stdin(SQL),并按 sms_verification_codes 真实语义作答 ── +# 它**不是**无条件返回验证码:先校验 SQL 使用的列。 +# 真实 MySQL 对未知列返回 ERROR 1054 (42S22),这里如实复现, +# 使脚本里字段名写错(例如 expires_at)无法被"总能拿到验证码"掩盖。 +# +# FAKE_ROWS 未设置 → 保留历史行为(输出 FAKE_CODE),供 F1/F5 既有用例使用。 +# FAKE_ROWS 设置 → 按行套用 WHERE / ORDER BY 语义作答。行格式: +# id|phone|purpose|used|expire_offset_s|created_at|code +# expire_offset_s > 0 表示 expired_at 晚于 NOW();查询用严格大于,故 =0 不命中。 +cat > "$FAKEBIN/mysql" <<'FAKEMYSQL' +#!/usr/bin/env bash +printf '%s\n' "$*" > "${FAKE_DIR:?}/mysql-argv.log" +sql="$(cat)" +printf '%s' "$sql" > "${FAKE_DIR}/mysql-stdin.sql" + +# ── 表结构契约检查:sms_verification_codes 的真实列是 expired_at ── +case "$sql" in + *sms_verification_codes*) + case "$sql" in + *expires_at*) + printf "ERROR 1054 (42S22): Unknown column 'expires_at' in 'where clause'\n" >&2 + exit 1 + ;; + esac + ;; +esac + +# 未提供假数据:保持历史行为 +if [ -z "${FAKE_ROWS:-}" ]; then + printf '%s\n' "${FAKE_CODE:-}" + exit 0 +fi + +# 从 SQL 解析查询参数(形态与应用脚本一致) +phone="$(printf '%s' "$sql" | sed -n "s/.*phone *= *'\([^']*\)'.*/\1/p" | head -n 1)" +purpose="$(printf '%s' "$sql" | sed -n "s/.*purpose *= *'\([^']*\)'.*/\1/p" | head -n 1)" + +# 逐行套用 WHERE 语义,按 created_at DESC, id DESC 取第一条 +best_key="" best_code="" +while IFS='|' read -r rid rphone rpurpose rused roffset rcreated rcode; do + [ -n "${rid:-}" ] || continue + [ "$rphone" = "$phone" ] || continue + [ "$rpurpose" = "$purpose" ] || continue + [ "$rused" = "0" ] || continue + case "$roffset" in + ''|*[!0-9-]*) continue ;; + esac + [ "$roffset" -gt 0 ] || continue + key="$(printf '%s|%010d' "$rcreated" "$rid")" + if [ -z "$best_key" ] || [[ "$key" > "$best_key" ]]; then + best_key="$key" best_code="$rcode" + fi +done <<< "${FAKE_ROWS}" + +[ -n "$best_code" ] && printf '%s\n' "$best_code" +exit 0 +FAKEMYSQL +chmod +x "$FAKEBIN/mysql" + +# ── 假 curl:记录请求;登录响应含固定测试 JWT ──────────────────── +cat > "$FAKEBIN/curl" <<'FAKECURL' +#!/usr/bin/env bash +url=""; want_stdin=0 +for a in "$@"; do + case "$a" in + http*) url="$a" ;; + --data-binary) want_stdin=1 ;; + *@-) want_stdin=1 ;; + esac +done +has_code="no" +if [ "$want_stdin" = "1" ]; then + body="$(cat)" + case "$body" in + *[0-9][0-9][0-9][0-9][0-9][0-9]*) has_code="yes" ;; + esac +fi +case "$url" in + *"/auth/login"*) + echo "login-attempted has6digit=$has_code" >> "${FAKE_DIR:?}/flow.log" + printf '{"code":200,"data":{"accessToken":"%s"}}\n200\n' "${FAKE_JWT:-FAKEJWT}" ;; + *"/auth/sms/send"*) printf '{}\n200\n' ;; + *"/health"*) printf '{"code":200}\n200\n' ;; + *"/notifications/unread-count"*) printf '{"unreadCount":1}\n200\n' ;; + *"/notifications/"*) printf '{"message":"marked as read"}\n200\n' ;; + *"/notifications"*) printf '{"items":[{"id":88}]}\n200\n' ;; + *"/applications/"*) printf '{"status":"VIEWED"}\n200\n' ;; + *"/applications"*) printf '{"id":77}\n200\n' ;; + *"/jobs/public/"*) printf '{"title":"x"}\n200\n' ;; + *"/match"*) printf '{"rulesVersion":"v1"}\n200\n' ;; + *"/jobs"*) printf '{"items":[{"id":11,"title":"x"}]}\n200\n' ;; + *) printf '{}\n200\n' ;; +esac +exit 0 +FAKECURL +chmod +x "$FAKEBIN/curl" +export PATH="$FAKEBIN:$PATH" +export FAKE_JWT="$TEST_JWT" + +# 数据库凭据标记:让容器侧脚本的凭据前置检查通过, +# 同时用固定标记值验证它绝不出现在 argv / stdout / stderr / 文件中 +export MYSQL_USER="smoke_readonly" +export MYSQL_PASSWORD="$TEST_DBPASS" +export MYSQL_DATABASE="mydreamjob" + +# 扫描给定文本是否含任一敏感标记;输出命中的标记名(不含值本身) +scan_hits() { + local text="$1" hits="" + case "$text" in *"$TEST_CODE"*) hits="$hits CODE" ;; esac + case "$text" in *"$TEST_PHONE"*) hits="$hits PHONE" ;; esac + case "$text" in *"$TEST_JWT"*) hits="$hits JWT" ;; esac + case "$text" in *"$TEST_DBPASS"*) hits="$hits DBPASS" ;; esac + case "$text" in *"Authorization: Bearer"*) hits="$hits AUTH" ;; esac + case "$text" in *"LOGIN_BODY="*) hits="$hits LOGIN_BODY" ;; esac + printf '%s' "$hits" +} + +echo "=== S01 F1/F4/F5: smoke-test.sh 敏感信息泄漏防护测试 ===" +echo "smoke-test.sh: $SMOKE" +echo "固定测试标记已加载(值不打印)。" +echo "" + +# ══════════════════════════════════════════════════════════════ +# Part 1 — F1 取码保护(进程内,可观测 REPLY) +# ══════════════════════════════════════════════════════════════ +run_sms_code() { + local mock="$1" allow="$2" mode="$3" garbage="${4:-}" + local out + out="$(cd "$REPO_ROOT" && bash -c ' + set -u + SMOKE_TEST_LIB_ONLY=1 + export SMOKE_TEST_LIB_ONLY + if [ "$1" != "__UNSET__" ]; then export SMS_MOCK_ENABLED="$1"; else unset SMS_MOCK_ENABLED; fi + if [ "$2" != "__UNSET__" ]; then export ALLOW_TEST_OTP_DB_READ="$2"; else unset ALLOW_TEST_OTP_DB_READ; fi + export FAKE_DOCKER_MODE="$3" + export FAKE_CODE="'"$TEST_CODE"'" + export FAKE_GARBAGE="$4" + # shellcheck disable=SC1090 + . "'"$SMOKE"'" + if sms_code; then + if [ -n "${REPLY:-}" ]; then printf "RESULT=ok\n"; else printf "RESULT=ok_but_empty\n"; fi + unset REPLY + else + printf "RESULT=fail\n" + fi + ' _ "$mock" "$allow" "$mode" "$garbage" 2>&1)" + printf '%s' "$out" +} + +# ── 以指定假表数据运行 sms_code,并比对取回的验证码 ───────────── +# 只输出 RESULT=ok/fail 与 MATCH=yes/no,**绝不输出验证码本身**。 +run_sms_code_with_rows() { + local rows="$1" expect="$2" + local out + out="$(cd "$REPO_ROOT" && bash -c ' + set -u + export SMOKE_TEST_LIB_ONLY=1 + export SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE=ok + export FAKE_ROWS="$1" + export STUDENT_PHONE="'"$TEST_PHONE"'" + export MYSQL_USER="smoke_readonly" MYSQL_PASSWORD="$2" MYSQL_DATABASE="mydreamjob" + # shellcheck disable=SC1090 + . "'"$SMOKE"'" + if sms_code; then + if [ -n "${REPLY:-}" ]; then + printf "RESULT=ok\n" + if [ "${REPLY}" = "$3" ]; then printf "MATCH=yes\n"; else printf "MATCH=no\n"; fi + else + printf "RESULT=ok_but_empty\n" + fi + unset REPLY + else + printf "RESULT=fail\n" + fi + ' _ "$rows" "$TEST_DBPASS" "$expect" 2>&1)" + printf '%s' "$out" +} + +# ── 端到端运行指定脚本(用于验证「字段错误时不继续登录」)──────── +# 脚本置于 $TMP 下镜像 docs/ 的结构,使其相对定位 backend-infra/compose.yaml 生效。 +run_full_script() { + local script="$1" + : > "$TMP/flow.log" + (cd "$REPO_ROOT" && \ + SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE=ok \ + FAKE_CODE="$TEST_CODE" FAKE_JWT="$TEST_JWT" \ + MYSQL_PASSWORD="$TEST_DBPASS" MYSQL_USER="smoke_readonly" MYSQL_DATABASE="mydreamjob" \ + STUDENT_PHONE="$TEST_PHONE" BASE_URL="http://127.0.0.1:1/api/v1" \ + bash "$script" 2>&1) || true +} + +r="$(run_sms_code true true ok)" +check "$(printf '%s' "$r" | grep -q 'RESULT=ok' && echo 1 || echo 0)" "1. mock=true且ALLOW=true时可取得测试验证码" +check "$(printf '%s' "$r" | grep -q "$TEST_CODE" && echo 0 || echo 1)" "10a. 取码调用 stdout/stderr 不含验证码" + +r="$(run_sms_code false true ok)" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "2. mock=false 时拒绝查询" +check "$(printf '%s' "$r" | grep -qF "$MARKER_MSG" && echo 1 || echo 0)" "2b. 拒绝时输出固定提示" + +r="$(run_sms_code __UNSET__ true ok)" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "3. 配置缺失时拒绝查询" + +r="$(run_sms_code true __UNSET__ ok)" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "4. ALLOW_TEST_OTP_DB_READ 缺失时拒绝查询" + +r="$(run_sms_code true false ok)" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "5. ALLOW_TEST_OTP_DB_READ=false 时拒绝查询" + +r="$(run_sms_code true true empty)" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "6. 没有可用验证码时安全失败" + +r="$(run_sms_code true true fail)" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "7. 查询失败时不继续" + +r="$(run_sms_code true true garbage 'not-a-code')" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "8a. 非数字结果被拒绝" +r="$(run_sms_code true true short)" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "8b. 长度非法结果被拒绝" + +# ══════════════════════════════════════════════════════════════ +# Part 2 — F4:真实 `bash -x` 执行,扫描 stdout+stderr +# ══════════════════════════════════════════════════════════════ +run_smoke_xtrace() { + local mode="$1" + : > "$TMP/flow.log"; : > "$TMP/docker-argv.log"; : > "$TMP/mysql-argv.log" + (cd "$REPO_ROOT" && \ + SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE="$mode" \ + FAKE_CODE="$TEST_CODE" FAKE_JWT="$TEST_JWT" \ + MYSQL_PASSWORD="$TEST_DBPASS" MYSQL_USER="smoke_readonly" MYSQL_DATABASE="mydreamjob" \ + STUDENT_PHONE="$TEST_PHONE" BASE_URL="http://127.0.0.1:1/api/v1" \ + bash -x "$SMOKE" 2>&1) || true +} + +out_x="$(run_smoke_xtrace ok)" +hits_x="$(scan_hits "$out_x")" +check "$(printf '%s' "$hits_x" | grep -q 'CODE' && echo 0 || echo 1)" "F4-1. bash -x 下不泄露验证码" +check "$(printf '%s' "$hits_x" | grep -q 'PHONE' && echo 0 || echo 1)" "F4-2. bash -x 下不泄露完整手机号" +check "$(printf '%s' "$hits_x" | grep -q 'JWT' && echo 0 || echo 1)" "F4-3. bash -x 下不泄露 JWT" +check "$(printf '%s' "$hits_x" | grep -q 'AUTH' && echo 0 || echo 1)" "F4-4. bash -x 下不泄露 Authorization 头" +check "$(printf '%s' "$hits_x" | grep -q 'DBPASS' && echo 0 || echo 1)" "F4-5. bash -x 下不泄露数据库密码" +check "$(printf '%s' "$hits_x" | grep -q 'LOGIN_BODY' && echo 0 || echo 1)" "F4-6. bash -x 下不泄露 LOGIN_BODY" + +# 正常流程仍可完成(登录被调用且带 6 位码;登录判定 PASS) +check "$(grep -q 'login-attempted has6digit=yes' "$TMP/flow.log" 2>/dev/null && echo 1 || echo 0)" \ + "F4-7. bash -x 下正常流程仍可完成登录" +check "$(printf '%s' "$out_x" | grep -q '\[PASS\] login' && echo 1 || echo 0)" \ + "F4-8. bash -x 下登录步骤判定 PASS" + +# xtrace 未在脚本内被重新启用:脚本内不存在可执行的 set -x +setx_code="$(grep -n 'set -x' "$SMOKE" | grep -v '^[0-9]*:[[:space:]]*#' | wc -l)" +check "$([ "$setx_code" -eq 0 ] && echo 1 || echo 0)" "F4-9. 脚本中不存在可执行的 set -x(不会恢复 xtrace)" + +# 失败流程同样不泄露 +out_f="$(run_smoke_xtrace empty)" +hits_f="$(scan_hits "$out_f")" +check "$([ -z "$hits_f" ] && echo 1 || echo 0)" "F4-10. 失败流程(无可用验证码)同样不泄露敏感值" + +# ══════════════════════════════════════════════════════════════ +# Part 3 — F4:source 模式不把敏感值带回调用方 +# ══════════════════════════════════════════════════════════════ +src_out="$(cd "$REPO_ROOT" && bash -c ' + set -u + export SMOKE_TEST_LIB_ONLY=1 + export SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true + export FAKE_DOCKER_MODE=ok FAKE_CODE="'"$TEST_CODE"'" + . "'"$SMOKE"'" + trap -p EXIT > "'"$TMP"'/trap.txt" 2>/dev/null || true + if sms_code; then + if [ -n "${REPLY:-}" ]; then echo "reply-nonempty"; else echo "reply-empty"; fi + unset REPLY + fi + # 清理后不得残留任何非空敏感变量 + leak=0 + for v in REPLY SMS OTP LOGIN_BODY LOGIN TOKEN AUTH; do + eval "val=\${$v:-}" + [ -n "$val" ] && leak=1 + done + echo "leak=$leak" +' 2>&1)" +check "$(printf '%s' "$src_out" | grep -q 'reply-nonempty' && echo 1 || echo 0)" "F4-11. source 模式下取码契约(REPLY)可用" +check "$(printf '%s' "$src_out" | grep -q 'leak=0' && echo 1 || echo 0)" "F4-12. source 模式下清理后无敏感变量残留" +check "$(grep -q 'cleanup_secrets' "$TMP/trap.txt" 2>/dev/null && echo 1 || echo 0)" "F4-13. source 模式下 EXIT trap 已登记清理函数" +check "$(grep -q 'PHONE' "$SMOKE" && grep -q 'TOKEN' "$SMOKE" && grep -q 'AUTH' "$SMOKE" && grep -q 'COMPANY_TOKEN' "$SMOKE" && echo 1 || echo 0)" \ + "F4-14. cleanup_secrets 覆盖 PHONE/TOKEN/AUTH/COMPANY_TOKEN" + +# ══════════════════════════════════════════════════════════════ +# Part 4 — F5:数据库密码不进入 mysql argv +# ══════════════════════════════════════════════════════════════ +: > "$TMP/mysql-argv.log"; : > "$TMP/docker-argv.log" +_f5="$(run_sms_code true true ok)" +mysql_argv="$(cat "$TMP/mysql-argv.log" 2>/dev/null)" +docker_argv="$(cat "$TMP/docker-argv.log" 2>/dev/null)" +container_script="$(cat "$TMP/container-script.txt" 2>/dev/null)" +mysql_stdin="$(cat "$TMP/mysql-stdin.sql" 2>/dev/null)" + +check "$(printf '%s' "$mysql_argv" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-1. mysql argv 不含数据库密码值" +check "$(printf '%s' "$docker_argv" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-2. 宿主机 docker 命令行不含数据库密码值" +check "$(printf '%s' "$container_script" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-3. 容器侧脚本文本不含密码值" +check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PASSWORD' && echo 1 || echo 0)" "F5-4. 容器侧脚本以变量名引用 MYSQL_PASSWORD" +check "$(printf '%s' "$mysql_argv" | grep -qE '(^| )-p' && echo 0 || echo 1)" "F5-5. mysql argv 不含 -p<密码>" +check "$(printf '%s' "$mysql_argv" | grep -q -- '--password' && echo 0 || echo 1)" "F5-6. mysql argv 不含 --password" +check "$(printf '%s' "$container_script" | grep -q 'MYSQL_ROOT_PASSWORD' && echo 0 || echo 1)" "F5-7. 不使用 root 凭据(无 MYSQL_ROOT_PASSWORD)" +check "$(printf '%s' "$container_script" | grep -q 'uroot' && echo 0 || echo 1)" "F5-8. 不使用 -uroot" +check "$(printf '%s' "$mysql_argv" | grep -q 'smoke_readonly' && echo 1 || echo 0)" "F5-9. 使用应用账号 MYSQL_USER" +check "$(printf '%s' "$mysql_argv" | grep -q 'mydreamjob' && echo 1 || echo 0)" "F5-10. 使用 MYSQL_DATABASE" +check "$(printf '%s' "$mysql_stdin" | grep -qi '^SELECT' && echo 1 || echo 0)" "F5-11. SQL 经 stdin 传入" +check "$(printf '%s' "$mysql_stdin" | grep -qiE 'INSERT|UPDATE|DELETE|DROP|ALTER' && echo 0 || echo 1)" "F5-12. 查询仅包含 SELECT(无写操作)" +check "$(printf '%s' "$mysql_argv" | grep -q 'MYSQL_PWD\|MYSQL_PASSWORD' && echo 0 || echo 1)" "F5-13. 密码不经 argv 传递" +check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PWD' && echo 1 || echo 0)" "F5-14. 密码经 MYSQL_PWD 环境变量传递" +check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PASSWORD' && grep -q 'exit 3' "$TMP/container-script.txt" && echo 1 || echo 0)" \ + "F5-15. 凭据缺失时安全失败(不回退 root)" +check "$(printf '%s' "$_f5" | grep -q 'RESULT=ok' && echo 1 || echo 0)" "F5-16. argv 安全前提下取码仍可用" + +# docker 不可用(容器不存在)时不泄露且安全失败 +: > "$TMP/mysql-argv.log" +r="$(run_sms_code true true fail)" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "F5-17. 查询失败时安全失败" +check "$(printf '%s' "$r" | grep -q "$TEST_DBPASS" && echo 0 || echo 1)" "F5-18. 失败路径 stderr 不含数据库密码" + +# ══════════════════════════════════════════════════════════════ +# Part 5 — F1 静态保证 + §7 活跃文件文档门禁 +# ══════════════════════════════════════════════════════════════ +check "$(grep -qE 'grep[^|]*\.log|grep[^|]*stdout' "$SMOKE" && echo 0 || echo 1)" "13. smoke-test.sh 不 grep 应用日志" +check "$(grep -q 'MOCK SMS CODE' "$SMOKE" && echo 0 || echo 1)" "14. smoke-test.sh 中不存在 MOCK SMS CODE" +mech=1 +grep -q 'unset REPLY' "$SMOKE" || mech=0 +grep -q 'unset SMS' "$SMOKE" || mech=0 +grep -q 'trap cleanup_secrets EXIT' "$SMOKE" || mech=0 +grep -q 'unset LOGIN_BODY' "$SMOKE" || mech=0 +check "$mech" "15. 存在 REPLY/SMS/LOGIN_BODY 清理与 EXIT trap" +check "$(grep -qE 'curl.*(-v|--trace|--trace-ascii)' "$SMOKE" && echo 0 || echo 1)" "20. 未使用 curl -v/--trace/--trace-ascii" +check "$(grep -q 'report_fields' "$SMOKE" && echo 1 || echo 0)" "21. 登录响应经 report_fields(不把响应全文交给打印函数)" + +# §7:仅扫描**活跃操作文档与正式代码**,不把历史验收报告计入 +ACTIVE_FILES="docs/acceptance-week1.md docs/api-v0.1.md docs/frontend-integration.md docs/offer-api.md docs/resume-api.md docs/smoke-test.sh README.md" +active_viol=0 +for f in $ACTIVE_FILES; do + [ -f "$REPO_ROOT/$f" ] || continue + c=$(grep -c "验证码.*打印到日志" "$REPO_ROOT/$f" 2>/dev/null); c=${c:-0} + c2=$(grep -c "从日志获取验证码" "$REPO_ROOT/$f" 2>/dev/null); c2=${c2:-0} + c3=$(grep -c "MOCK SMS CODE" "$REPO_ROOT/$f" 2>/dev/null); c3=${c3:-0} + [ $((c + c2 + c3)) -ne 0 ] && active_viol=$((active_viol + 1)) +done +check "$([ "$active_viol" -eq 0 ] && echo 1 || echo 0)" "22. 活跃操作文档不存在『从日志取码』有效指导" +src_viol=$(grep -rn "MOCK SMS CODE" "$REPO_ROOT/mydreamjob-server/src/main/java" 2>/dev/null | wc -l) +check "$([ "$src_viol" -eq 0 ] && echo 1 || echo 0)" "23. 正式 Java 代码中不存在 MOCK SMS CODE" +hist_ok=1 +[ -f "$REPO_ROOT/docs/s01-independent-mysql-acceptance-20260911.md" ] || hist_ok=0 +[ -f "$REPO_ROOT/docs/s01-remediation-f1f2f3-20260911.md" ] || hist_ok=0 +check "$hist_ok" "24. 历史验收报告仍保留(作为历史失败证据,不参与活跃文档扫描)" + +# ══════════════════════════════════════════════════════════════ +# Part 6 — F6:SQL 字段契约(expired_at)与查询语义 +# ══════════════════════════════════════════════════════════════ +# 背景:expires_at 在本仓库中是 personal_data_export_tasks(V30)、 +# ability_attempts(V35)、talent_invitations(V40) 三张表的**真实列**; +# 只有 sms_verification_codes 使用 expired_at。因此断言必须限定在该表的查询上, +# **不能**写成"全库不存在 expires_at"——那会误报。 +# +# 真实列清单从**创建该表的 Flyway** 派生,不在测试里手工维护一份与生产无关的 +# 假字段列表来自证正确。 +FLYWAY="$REPO_ROOT/mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql" +real_cols="$(awk ' + /^CREATE TABLE (IF NOT EXISTS )?sms_verification_codes/ { in_t=1; next } + in_t && /^\)/ { in_t=0 } + in_t { + line=$0 + sub(/^[[:space:]]+/, "", line) + if (line ~ /^(INDEX|KEY|PRIMARY|CONSTRAINT|UNIQUE|FOREIGN)/) next + if (line ~ /^[a-z_]+[[:space:]]/) { split(line, a, /[[:space:]]/); print a[1] } + } +' "$FLYWAY" | sort -u | tr '\n' ' ')" + +# 取一次真实查询文本(此调用未设置 FAKE_ROWS,走历史路径) +: > "$TMP/mysql-stdin.sql" +_f6="$(run_sms_code true true ok)" +sql_text="$(cat "$TMP/mysql-stdin.sql" 2>/dev/null)" + +check "$(printf '%s' "$sql_text" | grep -q 'expired_at > NOW()' && echo 1 || echo 0)" \ + "F6-1. otp_db_query 使用 expired_at > NOW()" +check "$(printf '%s' "$sql_text" | grep -q 'expires_at' && echo 0 || echo 1)" \ + "F6-2. otp_db_query 不再出现错误字段 expires_at" + +# 解析查询里使用的列名(去掉字符串字面量与 SQL 关键字) +used_cols="$(printf '%s' "$sql_text" \ + | sed "s/'[^']*'//g" \ + | grep -oE '\b[a-z_][a-z0-9_]*\b' \ + | grep -vEx 'select|from|where|and|or|order|by|desc|asc|limit|now|sms_verification_codes' \ + | sort -u)" +unknown_cols="" +for c in $used_cols; do + case " $real_cols " in + *" $c "*) ;; + *) unknown_cols="$unknown_cols $c" ;; + esac +done +check "$([ -z "$unknown_cols" ] && echo 1 || echo 0)" \ + "F6-3. 查询只使用 sms_verification_codes 的真实列${unknown_cols:+(命中不存在的列:$unknown_cols)}" +# 防止"派生失败得到空清单"导致上一条恒真 +check "$(printf '%s' "$real_cols" | grep -q 'expired_at' && echo 1 || echo 0)" \ + "F6-4. 真实列清单由 Flyway 派生且包含 expired_at(非空、非自证)" + +check "$(printf '%s' "$sql_text" | grep -qiE '^SELECT code FROM sms_verification_codes' && echo 1 || echo 0)" \ + "F6-5. 假 mysql 捕获到完整 stdin SQL(不是无条件返回验证码)" + +# 字段错误 → 假 mysql 必须失败(复现真实 MySQL 的 ERROR 1054) +printf '%s\n' "SELECT code FROM sms_verification_codes WHERE phone='x' AND expires_at > NOW();" \ + | "$FAKEBIN/mysql" >/dev/null 2>"$TMP/err1054.txt" || rc1054=$? +rc1054="${rc1054:-0}" +check "$([ "$rc1054" -ne 0 ] && echo 1 || echo 0)" \ + "F6-6. SQL 字段错误时假 mysql 返回失败(非 0)" +check "$(grep -q '1054' "$TMP/err1054.txt" 2>/dev/null && echo 1 || echo 0)" \ + "F6-6b. 字段错误时报 ERROR 1054 (42S22)" + +# 端到端:把字段改错的副本跑完整流程,验证不会继续登录 +BUGDIR="$TMP/bugrepo" +mkdir -p "$BUGDIR/docs" "$BUGDIR/backend-infra" +cp "$REPO_ROOT/backend-infra/compose.yaml" "$BUGDIR/backend-infra/compose.yaml" 2>/dev/null || true +sed 's/expired_at > NOW()/expires_at > NOW()/' "$SMOKE" > "$BUGDIR/docs/smoke-test.sh" +out_bug="$(run_full_script "$BUGDIR/docs/smoke-test.sh")" +check "$(printf '%s' "$out_bug" | grep -q '\[FAIL\] login skipped' && echo 1 || echo 0)" \ + "F6-7. 字段错误时 sms_code() 不继续登录" +check "$(grep -q 'login-attempted' "$TMP/flow.log" 2>/dev/null && echo 0 || echo 1)" \ + "F6-7b. 字段错误时未发出任何 login 请求" + +# 正确字段 → 取码成功并可完成登录 +out_ok="$(run_full_script "$SMOKE")" +check "$(grep -q 'login-attempted has6digit=yes' "$TMP/flow.log" 2>/dev/null && echo 1 || echo 0)" \ + "F6-8. 正确字段时取码成功并完成登录" + +# ── 过期 / used / purpose / phone / 最新一条 语义 ────────────── +# 行格式 id|phone|purpose|used|expire_offset_s|created_at|code +P="$TEST_PHONE" +R_FUTURE="1|$P|login|0|300|2026-09-11 10:00:00|$TEST_CODE" +R_EXACT="1|$P|login|0|0|2026-09-11 10:00:00|$TEST_CODE" +R_PAST="1|$P|login|0|-300|2026-09-11 10:00:00|$TEST_CODE" +R_USED="1|$P|login|1|300|2026-09-11 10:00:00|$TEST_CODE" +R_PURPOSE="1|$P|register|0|300|2026-09-11 10:00:00|$TEST_CODE" +R_PHONE="1|19900001111|login|0|300|2026-09-11 10:00:00|$TEST_CODE" +R_OLD="1|$P|login|0|300|2026-09-11 10:00:00|111111" +R_NEW="2|$P|login|0|300|2026-09-11 11:00:00|222222" + +r="$(run_sms_code_with_rows "$R_EXACT" "$TEST_CODE")" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ + "F6-9. expired_at 恰好等于 NOW() 时不得取出" + +r="$(run_sms_code_with_rows "$R_FUTURE" "$TEST_CODE")" +check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \ + "F6-10. expired_at 晚于 NOW() 时可以取出" + +r="$(run_sms_code_with_rows "$R_PAST" "$TEST_CODE")" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ + "F6-11. expired_at 早于 NOW() 时不得取出" + +r="$(run_sms_code_with_rows "$R_USED" "$TEST_CODE")" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ + "F6-12. used=1 不得取出" + +r="$(run_sms_code_with_rows "$R_PURPOSE" "$TEST_CODE")" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ + "F6-13. purpose 不匹配不得取出" + +r="$(run_sms_code_with_rows "$R_PHONE" "$TEST_CODE")" +check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ + "F6-14. phone 不匹配不得取出" + +r="$(run_sms_code_with_rows "$(printf '%s\n%s' "$R_OLD" "$R_NEW")" "222222")" +check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \ + "F6-15. 多条有效记录只取 created_at/id 最新一条" +# 逆序输入仍取同一条,证明按 ORDER BY 取值而非"最后一行胜出" +r="$(run_sms_code_with_rows "$(printf '%s\n%s' "$R_NEW" "$R_OLD")" "222222")" +check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \ + "F6-15b. 与输入顺序无关,按 created_at/id 排序取值" + +echo "" +echo "============================================" +echo "PASS=$PASS FAIL=$FAIL" +echo "============================================" + +# 最终泄露扫描(测试自身输出也不得含标记) +if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_CODE"; then + echo "LEAK DETECTED: code"; exit 1 +fi +if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_JWT"; then + echo "LEAK DETECTED: jwt"; exit 1 +fi +if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_DBPASS"; then + echo "LEAK DETECTED: dbpass"; exit 1 +fi +[ "$FAIL" -eq 0 ] diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 0e29331..0baf84d 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) print('paths:', len(d['paths'])) -assert len(d['paths']) == 173, f"Expected 173 paths, got {len(d['paths'])}" +assert len(d['paths']) == 176, f"Expected 176 paths, got {len(d['paths'])}" paths = d['paths'] @@ -591,10 +591,79 @@ assert '/conversations' in paths and '/conversations/messages' in paths assert '/conversations/{id}/messages' in paths and '/conversations/{id}/read' in paths print(' N07 recall path/schema validated; existing messaging operations preserved') -# 统计 operation 总数并校验 V43 + N07 计数 211 +# ── S02:Access/Refresh Token 隔离的两个新增 operation ───────────── +# 只允许新增 /auth/refresh 与 /auth/logout;登录原字段不得删除; +# 新 schema 不得暴露数据库字段或凭据。 +assert '/auth/refresh' in paths, 'Missing /auth/refresh' +assert '/auth/logout' in paths, 'Missing /auth/logout' +assert 'post' in paths['/auth/refresh'], '/auth/refresh must be POST' +assert 'post' in paths['/auth/logout'], '/auth/logout must be POST' + +_refresh_body = paths['/auth/refresh']['post']['requestBody']['content']['application/json']['schema'] +_refresh_req = resolve_schema(_refresh_body) +assert sorted(_refresh_req['properties']) == ['refreshToken'], \ + f"Refresh request body must only contain refreshToken, got {sorted(_refresh_req['properties'])}" + +assert 'requestBody' not in paths['/auth/logout']['post'], '/auth/logout must not declare requestBody' + +# 登录接口的静态契约不得被 S02 改动(请求/响应引用保持原样) +_login_post = paths['/auth/login']['post'] +assert _login_post['requestBody']['content']['application/json']['schema']['$ref'] == \ + '#/components/schemas/LoginRequest', 'login request schema changed' +assert _login_post['responses']['200']['content']['*/*']['schema']['$ref'] == \ + '#/components/schemas/ApiResponseMapStringObject', 'login response schema changed' +assert _login_post.get('operationId') == 'mockLogin', 'login operationId changed' + +_s02_text = json.dumps({ + 'refresh': paths['/auth/refresh'], + 'logout': paths['/auth/logout'], + 'schemas': {k: v for k, v in d['components']['schemas'].items() + if k in ('RefreshTokenRequest', 'TokenPairResponse', 'ApiResponseTokenPairResponse')}, +}, ensure_ascii=False) +for forbidden in ('tokenHash', 'refreshTokenHash', 'sessionId', 'revocationReason', + 'phone', 'secret', 'password', 'jwt'): + assert forbidden not in _s02_text, f'S02 contract exposes forbidden field {forbidden}' + +# 未新增其他认证或管理接口(认证相关路径只能是既有集合 + 这两个) +_auth_paths = {p for p in paths if p.startswith('/auth')} +assert _auth_paths == { + '/auth/sms/send', '/auth/login', '/auth/me', + '/auth/refresh', '/auth/logout', +}, f'Unexpected /auth paths: {sorted(_auth_paths)}' +print(' S02 refresh/logout operations and isolation schemas validated') + +# ── S03:唯一新增的企业简历下载接口 ──────────────────────────────── +_download_path = '/company/candidates/{applicationId}/resume' +assert _download_path in paths, 'Missing company resume download endpoint' +_download_get = paths[_download_path]['get'] +assert 'requestBody' not in _download_get, 'Resume download must not declare requestBody' +assert set(paths[_download_path].keys()) == {'get'}, 'Resume download must be GET only' + +# 不得存在按 studentUserId / candidateKey 下载简历的入口 +for _p in paths: + _lower = _p.lower() + if 'resume' in _lower or 'download' in _lower: + assert 'studentuserid' not in _lower, f'Resume path must not key on studentUserId: {_p}' + assert 'candidatekey' not in _lower, f'Resume path must not key on candidateKey: {_p}' + +# 企业候选人 DTO 不得回带 resumeUrl / studentUserId / phone / email / storagePath +for _dto in ('CandidateDetailView', 'CandidateItemView'): + _node = d['components']['schemas'].get(_dto) + if _node is None: + continue + _text = json.dumps(resolve_schema(_node), ensure_ascii=False) + for _forbidden in ('resumeUrl', 'studentUserId', 'phone', 'email', 'storagePath'): + assert _forbidden not in _text, f'{_dto} exposes forbidden field {_forbidden}' + +# Actuator 不得进入业务 OpenAPI +for _p in paths: + assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}' +print(' S03 company resume download contract validated; candidate DTOs remain redacted') + +# 统计 operation 总数并校验 V43 + N07 + S02 + S03 计数 214 operations = sum(len(methods) for methods in d['paths'].values()) print('operations:', operations) -assert operations == 211, f"Expected 211 operations, got {operations}" +assert operations == 214, f"Expected 214 operations, got {operations}" print(" Offer advance endpoints all present") print(" 26 operations all present") print(" Account deletion endpoints present (3 operations)") diff --git a/mydreamjob-server/pom.xml b/mydreamjob-server/pom.xml index 55db580..6c77e07 100644 --- a/mydreamjob-server/pom.xml +++ b/mydreamjob-server/pom.xml @@ -54,6 +54,11 @@ org.springframework.boot spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java index 695f868..b7af7ad 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java @@ -10,6 +10,7 @@ import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -43,6 +44,7 @@ public class AbilityAttemptService { private static final int MAX_PAGE_SIZE = 50; private static final String[] DIMENSIONS = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"}; + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final AbilityDefinitionRepository definitionRepository; private final AbilityQuestionRepository questionRepository; private final AbilityAttemptRepository attemptRepository; @@ -56,12 +58,14 @@ public class AbilityAttemptService { private final UserRepository userRepository; private final AuditService auditService; private final Clock clock; + /** R01-D4-1:能力测评提交成功后登记评分快照刷新(ABILITY 维度)。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; // ─────────────────────────── 创建答卷 ─────────────────────────── @Transactional(isolation = Isolation.READ_COMMITTED) public AbilityAttemptResponse create(Long userId, CreateAbilityAttemptRequest req, AuditContext ctx) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AbilityDefinition def = definitionRepository.findByTypeCode(req.getTypeCode()) .orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND)); if (!AbilityDefinition.STATUS_ACTIVE.equals(def.getStatus())) { @@ -148,7 +152,7 @@ public class AbilityAttemptService { @Transactional public AbilityAttemptResponse get(Long userId, Long attemptId) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AbilityAttempt attempt = loadOwned(userId, attemptId); expireIfTimedOut(attempt); return toAttemptResponse(attempt); @@ -158,7 +162,7 @@ public class AbilityAttemptService { @Transactional(readOnly = true) public AbilityResultResponse loadResult(Long userId, Long attemptId) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AbilityAttempt attempt = loadOwned(userId, attemptId); AbilityResult result = resultRepository.findByAttemptId(attemptId) .orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_RESULT_NOT_FOUND)); @@ -171,7 +175,7 @@ public class AbilityAttemptService { @Transactional public AbilityAttemptResponse saveAnswers(Long userId, Long attemptId, SaveAbilityAnswersRequest req, AuditContext ctx) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AbilityAttempt attempt = loadOwned(userId, attemptId); if (expireIfTimedOut(attempt)) { throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE); @@ -248,7 +252,7 @@ public class AbilityAttemptService { @Transactional public AbilityResultResponse submit(Long userId, Long attemptId, AuditContext ctx) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AbilityAttempt attempt = loadOwned(userId, attemptId); if (AbilityAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) { @@ -287,6 +291,8 @@ public class AbilityAttemptService { + ", questionSetVersion=" + attempt.getQuestionSetVersion() + ", rulesVersion=" + (def == null ? null : def.getRulesVersion()), ctx); + // R01-D4-1:能力测评提交成功(新有效结果)影响 ABILITY 维度 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(userId, now()); return resultService.toResponse(attempt, result, def, now()); } @@ -294,7 +300,7 @@ public class AbilityAttemptService { @Transactional(readOnly = true) public Page listMine(Long userId, int page, int size) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { throw new BusinessException(ErrorCode.BAD_REQUEST); } @@ -540,16 +546,6 @@ public class AbilityAttemptService { } return a; } - - private void requireStudent(Long userId) { - User u = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isStudent = u.getRoles() != null && u.getRoles().contains(User.Role.STUDENT); - if (!isStudent) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } - } - private AbilityAttempt findByIdem(Long userId, String idem) { if (idem == null) { return null; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java index d69d9e7..2f9f7e1 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java @@ -9,8 +9,8 @@ import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.PageResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @@ -18,8 +18,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -37,6 +35,8 @@ public class AbilityController { private final AbilityCatalogService catalogService; private final AbilityAttemptService attemptService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "能力测评类型列表", description = "返回全部 ACTIVE 能力测评定义,不返回题库/答案/评分规则。任意已登录用户。") @@ -46,7 +46,7 @@ public class AbilityController { }) @GetMapping("/abilities/types") public ApiResponse> listTypes(HttpServletRequest request) { - return ApiResponse.ok(catalogService.listTypes(), requestId(request)); + return ApiResponse.ok(catalogService.listTypes(), requestIdAccessor.get(request)); } @Operation(summary = "创建能力答卷", @@ -63,8 +63,8 @@ public class AbilityController { @PostMapping("/abilities/attempts") public ApiResponse createAttempt( @Valid @RequestBody CreateAbilityAttemptRequest req, HttpServletRequest request) { - return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)), - requestId(request)); + return ApiResponse.ok(attemptService.create(currentUserAccessor.requireUserId(), req, AuditContext.from(request)), + requestIdAccessor.get(request)); } @Operation(summary = "能力答卷详情", @@ -78,7 +78,7 @@ public class AbilityController { @GetMapping("/abilities/attempts/{attemptId}") public ApiResponse getAttempt( @PathVariable Long attemptId, HttpServletRequest request) { - return ApiResponse.ok(attemptService.get(resolveUserId(request), attemptId), requestId(request)); + return ApiResponse.ok(attemptService.get(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request)); } @Operation(summary = "保存能力测评答案", @@ -96,8 +96,8 @@ public class AbilityController { public ApiResponse saveAnswers( @PathVariable Long attemptId, @Valid @RequestBody SaveAbilityAnswersRequest req, HttpServletRequest request) { - return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req, - AuditContext.from(request)), requestId(request)); + return ApiResponse.ok(attemptService.saveAnswers(currentUserAccessor.requireUserId(), attemptId, req, + AuditContext.from(request)), requestIdAccessor.get(request)); } @Operation(summary = "提交能力测评答卷", @@ -114,8 +114,8 @@ public class AbilityController { @PostMapping("/abilities/attempts/{attemptId}/submit") public ApiResponse submitAttempt( @PathVariable Long attemptId, HttpServletRequest request) { - return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId, - AuditContext.from(request)), requestId(request)); + return ApiResponse.ok(attemptService.submit(currentUserAccessor.requireUserId(), attemptId, + AuditContext.from(request)), requestIdAccessor.get(request)); } @Operation(summary = "查看能力测评结果", @@ -129,7 +129,7 @@ public class AbilityController { @GetMapping("/abilities/attempts/{attemptId}/result") public ApiResponse getResult( @PathVariable Long attemptId, HttpServletRequest request) { - return ApiResponse.ok(attemptService.loadResult(resolveUserId(request), attemptId), requestId(request)); + return ApiResponse.ok(attemptService.loadResult(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request)); } @Operation(summary = "我的能力测评历史", @@ -144,21 +144,9 @@ public class AbilityController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Page result = attemptService.listMine(resolveUserId(request), page, size); + Page result = attemptService.listMine(currentUserAccessor.requireUserId(), page, size); return ApiResponse.ok(PageResponse.of( result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), - requestId(request)); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + requestIdAccessor.get(request)); } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java index f719074..7c07fa3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java @@ -4,6 +4,7 @@ import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.application.dto.CandidateDetailView; +import com.mydreamjob.application.dto.CompanyResumeFile; import com.mydreamjob.application.dto.CandidateListResponse; import com.mydreamjob.application.dto.ApplicationCreateRequest; import com.mydreamjob.application.dto.ApplicationStatusRequest; @@ -15,11 +16,13 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +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; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -31,9 +34,16 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class ApplicationController { + /** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; + 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; + private final CompanyResumeService companyResumeService; private static final Set ALLOWED_STATUSES = Set.of( "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); @@ -47,9 +57,9 @@ public class ApplicationController { public ResponseEntity>> 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))); } // ── 学生撤回投递 ────────────────────────────────────────── @@ -59,9 +69,9 @@ public class ApplicationController { public ResponseEntity>> 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))); } // ── 投递时间线 ──────────────────────────────────────────── @@ -71,9 +81,9 @@ public class ApplicationController { public ResponseEntity>>> timeline( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); List> data = applicationService.getTimeline(id, userId); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } // ── 学生查看自己的投递 ──────────────────────────────────── @@ -85,7 +95,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 result = applicationService.listStudentApplications(userId, status, page, size); Map data = new HashMap<>(); @@ -94,7 +104,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 = "查看自己的投递记录详情") @@ -102,9 +112,9 @@ public class ApplicationController { public ResponseEntity>> 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))); } // ── 企业查看岗位收到的投递 ──────────────────────────────── @@ -117,7 +127,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; @@ -129,7 +139,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))); } // ── 企业更新投递状态 ────────────────────────────────────── @@ -140,11 +150,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))); } // ── 企业候选人看板 ──────────────────────────────────────── @@ -160,8 +170,8 @@ public class ApplicationController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); - assertCompanyRole(userId); + Long userId = currentUserAccessor.requireUserId(); + userRoleGuard.requireCompany(userId); // 参数校验 if (page < 0 || size < 1 || size > 100) @@ -186,7 +196,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 = "查看投递详情及候选人公开资料,只能查看本企业岗位下的投递") @@ -194,33 +204,38 @@ public class ApplicationController { public ResponseEntity> getCompanyCandidateDetail( @PathVariable Long applicationId, HttpServletRequest request) { - Long userId = resolveUserId(request); - assertCompanyRole(userId); + Long userId = currentUserAccessor.requireUserId(); + userRoleGuard.requireCompany(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))); - } - - // ── 辅助 ────────────────────────────────────────────────── - - private void assertCompanyRole(Long userId) { - var user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - if (user.getRoles().stream().noneMatch(r -> r.name().equals("COMPANY"))) - throw new BusinessException(ErrorCode.FORBIDDEN); + 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()); + @Operation(summary = "企业下载候选人简历", + description = "按候选人隐私设置授权后返回简历文件流;不返回存储路径、学生 ID 或联系方式") + @GetMapping("/company/candidates/{applicationId}/resume") + public ResponseEntity downloadCandidateResume( + @PathVariable Long applicationId, + HttpServletRequest request) { + Long userId = currentUserAccessor.requireUserId(); + userRoleGuard.requireCompany(userId); + + // 授权全部在 Service/Policy 内完成:这里不传递任何归属信息,只传 applicationId。 + CompanyResumeFile file = companyResumeService.load(userId, applicationId); + + ContentDisposition disposition = ContentDisposition.attachment() + .filename(file.filename(), StandardCharsets.UTF_8) + .build(); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(file.contentType())) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + // 私有简历不进任何中间缓存。 + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .body(file.content()); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } + // ── 辅助 ────────────────────────────────────────────────── } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java index fc5fd57..60cf990 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java @@ -15,6 +15,7 @@ import com.mydreamjob.job.JobService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.profile.StudentProfile; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -46,6 +47,8 @@ public class ApplicationService { private final AuditService auditService; private final ApplicationStatusHistoryRepository historyRepository; private final NotificationService notificationService; + /** R01-D4-1:投递新增/撤回/状态变更后登记评分快照刷新。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; @PersistenceContext private EntityManager em; @@ -83,6 +86,8 @@ public class ApplicationService { "你已成功投递岗位 #" + req.getJobId(), "APPLICATION", app.getId(), "apply-" + studentUserId + "-" + req.getJobId()); + // R01-D4-1:投递数量影响 STABILITY/ACTIVITY 聚合 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now()); return app; } @@ -120,6 +125,8 @@ public class ApplicationService { "你的投递 #" + app.getId() + " 已撤回", "APPLICATION", app.getId(), "withdraw-" + studentUserId + "-" + app.getId()); + // R01-D4-1:撤回参与 WITHDRAWN/总投递比率计算 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now()); return app; } @@ -213,6 +220,8 @@ public class ApplicationService { "投递状态更新", "你的投递 #" + app.getId() + " 状态变更为 " + target.name(), "APPLICATION", app.getId(), "status-" + companyId + "-" + app.getId() + "-" + target.name()); + // R01-D4-1:仅状态实际变化(含 WITHDRAWN/HIRED 等聚合口径)时登记刷新。 + scoringRefreshRequestService.requestRefresh(app.getStudentUserId(), java.time.LocalDateTime.now()); return app; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java index 57341dc..667cab0 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java @@ -9,4 +9,7 @@ import java.util.List; public interface ApplicationStatusHistoryRepository extends JpaRepository { List findByApplicationIdOrderByCreatedAtAsc(Long applicationId); + + List findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc( + List applicationIds); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java new file mode 100644 index 0000000..0f858ac --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java @@ -0,0 +1,132 @@ +package com.mydreamjob.application; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.privacy.ResumeVisibility; +import com.mydreamjob.privacy.StudentPrivacySettings; +import com.mydreamjob.privacy.StudentPrivacySettingsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Set; + +/** + * S03-B 企业简历访问授权(失败关闭)。 + * + *

授权顺序严格按设计 §4.2:当前 COMPANY 用户 → 当前 Company → Application+Job → + * Job.companyId → Student → privacy setting。归属信息全部由服务端推导, + * 不信任任何客户端传入的 companyId / studentUserId。

+ * + *

决策表(设计 §4.3):

+ *
    + *
  • 无设置记录 / null / PRIVATE:拒绝(按 PRIVATE 处理)
  • + *
  • APPLIED_COMPANIES:岗位必须属于当前企业,且投递状态为 + * SUBMITTED/VIEWED/INTERVIEW/HIRED;WITHDRAWN/REJECTED 拒绝
  • + *
  • VERIFIED_COMPANIES:当前企业 {@code verified=true} 且拥有该投递岗位; + * 投递状态不额外限制
  • + *
+ * + *

本类修复了此前 {@code PrivacySettingsService.canCompanyViewResume} 只校验学生与状态、 + * 不校验企业归属的缺陷。

+ */ +@Component +@RequiredArgsConstructor +public class CompanyResumeAccessPolicy { + + /** + * {@code APPLIED_COMPANIES} 允许的投递状态:**严格白名单**。 + * + *

不得改为「不是拒绝状态就允许」的黑名单逻辑 —— 那会让 {@code null}、空白、 + * 未知状态或大小写变体(如 {@code "submitted"})失败开放。状态比较区分大小写。

+ */ + static final Set APPLIED_COMPANY_VISIBLE_STATUSES = + Set.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED"); + + private final CompanyRepository companyRepository; + private final ApplicationRepository applicationRepository; + private final JobRepository jobRepository; + private final StudentPrivacySettingsRepository privacyRepository; + + /** 授权通过后返回候选人用户 ID;这是后续读取简历的唯一依据。 */ + public record Decision(Long studentUserId) { + } + + /** + * 企业下载接口使用的授权入口:仅凭当前登录公司用户与路径中的 applicationId 完成全部判定。 + * + * @throws BusinessException 未拥有企业资料 / 跨企业 / 隐私拒绝 → {@code FORBIDDEN(2002)}; + * 投递不存在 → {@code APPLICATION_NOT_FOUND(8002)} + */ + public Decision authorizeForDownload(Long companyUserId, Long applicationId) { + if (companyUserId == null || applicationId == null) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + Company company = companyRepository.findByOwnerUserId(companyUserId).orElse(null); + if (company == null) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + Application application = applicationRepository.findById(applicationId) + .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); + + if (!isVisibleToCompanyInternal(application, company.getId())) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + return new Decision(application.getStudentUserId()); + } + + /** + * 既有布尔语义的授权判定(供 {@code PrivacySettingsService} 委托)。 + * + *

调用方提供的 {@code studentUserId} 只用于一致性校验:必须与投递记录一致, + * 否则视为不可信并拒绝。

+ */ + public boolean isVisible(Long studentUserId, Long companyId, Long applicationId) { + if (studentUserId == null || companyId == null || applicationId == null) { + return false; + } + Application application = applicationRepository.findById(applicationId).orElse(null); + if (application == null || !studentUserId.equals(application.getStudentUserId())) { + return false; + } + return isVisibleToCompanyInternal(application, companyId); + } + + /** + * 核心判定:投递所属岗位必须属于该企业,再按学生隐私设置决定可见性。 + */ + private boolean isVisibleToCompanyInternal(Application application, Long companyId) { + Job job = jobRepository.findById(application.getJobId()).orElse(null); + if (job == null || !companyId.equals(job.getCompanyId())) { + // 岗位不存在或不属于当前企业:一律拒绝(含跨企业投递)。 + return false; + } + + ResumeVisibility visibility = visibilityOf(application.getStudentUserId()); + switch (visibility) { + case APPLIED_COMPANIES: + // 白名单判定(失败关闭):null / 空白 / 未知 / 大小写变体一律拒绝。 + // 先做 null 判断,避免 Set.of(...).contains(null) 抛 NPE。 + return application.getStatus() != null + && APPLIED_COMPANY_VISIBLE_STATUSES.contains(application.getStatus()); + case VERIFIED_COMPANIES: + Company company = companyRepository.findById(companyId).orElse(null); + return company != null && Boolean.TRUE.equals(company.getVerified()); + case PRIVATE: + default: + // 无设置记录、null 或 PRIVATE:按 PRIVATE 处理。 + return false; + } + } + + private ResumeVisibility visibilityOf(Long studentUserId) { + StudentPrivacySettings settings = privacyRepository.findByStudentUserId(studentUserId).orElse(null); + if (settings == null || settings.getResumeVisibility() == null) { + return ResumeVisibility.PRIVATE; + } + return settings.getResumeVisibility(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java new file mode 100644 index 0000000..b68a36d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java @@ -0,0 +1,72 @@ +package com.mydreamjob.application; + +import com.mydreamjob.application.dto.CompanyResumeFile; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; +import com.mydreamjob.profile.storage.ResumeStorageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Set; + +/** + * S03-B 企业读取候选人简历文件。 + * + *

本类只负责「授权已通过之后」的文件读取与响应构建: + * 授权全部由 {@link CompanyResumeAccessPolicy} 完成,**任何拒绝路径都不会走到 + * {@link ResumeStorageService#read}。

+ * + *

简历正文、存储路径、原始文件名与学生标识都不写入日志、审计或异常。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CompanyResumeService { + + /** Content-Type 白名单:只能来自上传时已校验的三种简历类型。 */ + static final Set ALLOWED_CONTENT_TYPES = Set.of( + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + + static final String FALLBACK_CONTENT_TYPE = "application/octet-stream"; + + private final CompanyResumeAccessPolicy accessPolicy; + private final StudentProfileRepository profileRepository; + private final ResumeStorageService resumeStorageService; + + /** + * 读取该投递对应候选人的简历。 + * + * @throws BusinessException 授权失败({@code FORBIDDEN} / {@code APPLICATION_NOT_FOUND}), + * 或授权通过但学生没有简历({@code PROFILE_NOT_FOUND}) + */ + @Transactional(readOnly = true) + public CompanyResumeFile load(Long companyUserId, Long applicationId) { + // 先授权,后读文件:拒绝路径绝不触碰存储。 + CompanyResumeAccessPolicy.Decision decision = + accessPolicy.authorizeForDownload(companyUserId, applicationId); + + StudentProfile profile = profileRepository.findByUserId(decision.studentUserId()) + .orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND)); + + String storagePath = profile.getResumeUrl(); + if (storagePath == null || storagePath.isBlank()) { + throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND); + } + + byte[] content = resumeStorageService.read(storagePath); + String filename = ResumeFilenameSanitizer.forDownload(profile.getResumeOriginalName(), "resume"); + String contentType = resolveContentType(profile.getResumeContentType()); + return new CompanyResumeFile(content, filename, contentType); + } + + private String resolveContentType(String stored) { + return (stored != null && ALLOWED_CONTENT_TYPES.contains(stored)) ? stored : FALLBACK_CONTENT_TYPE; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CompanyResumeFile.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CompanyResumeFile.java new file mode 100644 index 0000000..3d984bf --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CompanyResumeFile.java @@ -0,0 +1,10 @@ +package com.mydreamjob.application.dto; + +/** + * S03-B 企业简历下载的内部响应载体。 + * + *

只携带渲染文件响应所必需的三个字段:文件内容、已净化的下载文件名、白名单内的 + * Content-Type。**不包含**存储路径、学生用户 ID、手机号、邮箱或简历正文文本。

+ */ +public record CompanyResumeFile(byte[] content, String filename, String contentType) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java index f41e68d..ceefda4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java @@ -47,6 +47,7 @@ public class AssessmentAttemptService { private static final int MAX_PAGE_SIZE = 50; + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final AssessmentDefinitionRepository definitionRepository; private final AssessmentQuestionRepository questionRepository; private final AssessmentAttemptRepository attemptRepository; @@ -71,7 +72,7 @@ public class AssessmentAttemptService { */ @Transactional(isolation = Isolation.READ_COMMITTED) public AssessmentAttemptResponse create(Long userId, CreateAssessmentAttemptRequest req, AuditContext ctx) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AssessmentDefinition def = definitionRepository.findByTypeCode(req.getTypeCode()) .orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND)); // 仅 ACTIVE 类型可创建;INACTIVE 统一 404/9701 @@ -142,7 +143,7 @@ public class AssessmentAttemptService { @Transactional(readOnly = true) public AssessmentAttemptResponse get(Long userId, Long attemptId) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AssessmentAttempt attempt = loadOwned(userId, attemptId); return toAttemptResponse(attempt); } @@ -152,7 +153,7 @@ public class AssessmentAttemptService { @Transactional public AssessmentAttemptResponse saveAnswers(Long userId, Long attemptId, SaveAssessmentAnswersRequest req, AuditContext ctx) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AssessmentAttempt attempt = loadOwned(userId, attemptId); if (!isMutable(attempt.getStatus())) { throw new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE); @@ -204,7 +205,7 @@ public class AssessmentAttemptService { @Transactional public AssessmentResultResponse submit(Long userId, Long attemptId, AuditContext ctx) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AssessmentAttempt attempt = loadOwned(userId, attemptId); // 重复提交幂等:返回原结果,不重新生成、不重复审计 @@ -261,7 +262,7 @@ public class AssessmentAttemptService { @Transactional(readOnly = true) public Page listMine(Long userId, int page, int size) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { throw new BusinessException(ErrorCode.BAD_REQUEST); } @@ -315,16 +316,6 @@ public class AssessmentAttemptService { } return a; } - - private void requireStudent(Long userId) { - User u = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isStudent = u.getRoles() != null && u.getRoles().contains(User.Role.STUDENT); - if (!isStudent) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } - } - private AssessmentAttempt findByIdem(Long userId, String idem) { if (idem == null) { return null; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java index f398e3b..931fc29 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java @@ -10,8 +10,8 @@ import com.mydreamjob.assessment.dto.SaveAssessmentAnswersRequest; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.PageResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @@ -19,8 +19,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -39,6 +37,8 @@ public class AssessmentController { private final AssessmentCatalogService catalogService; private final AssessmentAttemptService attemptService; private final AssessmentResultService resultService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "测评类型列表", description = "返回全部 ACTIVE 测评定义。任意已登录用户可访问;未登录 401。") @@ -48,7 +48,7 @@ public class AssessmentController { }) @GetMapping("/assessments/types") public ApiResponse> listTypes(HttpServletRequest request) { - return ApiResponse.ok(catalogService.listTypes(), requestId(request)); + return ApiResponse.ok(catalogService.listTypes(), requestIdAccessor.get(request)); } @Operation(summary = "题目列表", @@ -61,7 +61,7 @@ public class AssessmentController { @GetMapping("/assessments/{type}/questions") public ApiResponse> listQuestions( @PathVariable String type, HttpServletRequest request) { - return ApiResponse.ok(catalogService.listQuestions(type), requestId(request)); + return ApiResponse.ok(catalogService.listQuestions(type), requestIdAccessor.get(request)); } @Operation(summary = "创建答卷", @@ -77,8 +77,8 @@ public class AssessmentController { @PostMapping("/assessments/attempts") public ApiResponse createAttempt( @Valid @RequestBody CreateAssessmentAttemptRequest req, HttpServletRequest request) { - return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)), - requestId(request)); + return ApiResponse.ok(attemptService.create(currentUserAccessor.requireUserId(), req, AuditContext.from(request)), + requestIdAccessor.get(request)); } @Operation(summary = "答卷详情", @@ -92,7 +92,7 @@ public class AssessmentController { @GetMapping("/assessments/attempts/{attemptId}") public ApiResponse getAttempt( @PathVariable Long attemptId, HttpServletRequest request) { - return ApiResponse.ok(attemptService.get(resolveUserId(request), attemptId), requestId(request)); + return ApiResponse.ok(attemptService.get(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request)); } @Operation(summary = "保存答案", @@ -109,8 +109,8 @@ public class AssessmentController { public ApiResponse saveAnswers( @PathVariable Long attemptId, @Valid @RequestBody SaveAssessmentAnswersRequest req, HttpServletRequest request) { - return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req, - AuditContext.from(request)), requestId(request)); + return ApiResponse.ok(attemptService.saveAnswers(currentUserAccessor.requireUserId(), attemptId, req, + AuditContext.from(request)), requestIdAccessor.get(request)); } @Operation(summary = "提交答卷", @@ -127,8 +127,8 @@ public class AssessmentController { @PostMapping("/assessments/attempts/{attemptId}/submit") public ApiResponse submitAttempt( @PathVariable Long attemptId, HttpServletRequest request) { - return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId, - AuditContext.from(request)), requestId(request)); + return ApiResponse.ok(attemptService.submit(currentUserAccessor.requireUserId(), attemptId, + AuditContext.from(request)), requestIdAccessor.get(request)); } @Operation(summary = "查看结果", @@ -143,7 +143,7 @@ public class AssessmentController { @GetMapping("/assessments/attempts/{attemptId}/result") public ApiResponse getResult( @PathVariable Long attemptId, HttpServletRequest request) { - return ApiResponse.ok(resultService.get(resolveUserId(request), attemptId), requestId(request)); + return ApiResponse.ok(resultService.get(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request)); } @Operation(summary = "我的测评历史", @@ -158,21 +158,9 @@ public class AssessmentController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Page result = attemptService.listMine(resolveUserId(request), page, size); + Page result = attemptService.listMine(currentUserAccessor.requireUserId(), page, size); return ApiResponse.ok(PageResponse.of( result.getContent(), - result.getTotalElements(), result.getNumber(), result.getSize()), requestId(request)); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + result.getTotalElements(), result.getNumber(), result.getSize()), requestIdAccessor.get(request)); } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java index aa0968b..8c11813 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java @@ -50,7 +50,10 @@ public class AuditLog { @Column(name = "request_id", length = 32) private String requestId; - @CreatedDate + // S02:不再使用 @CreatedDate。 + // Spring Data 审计会在持久化时用“当前时间”覆盖显式设置的值,而 S02 认证入口 + // (登录/刷新/登出/重放处置/全局吊销)要求审计时间与入口单次捕获的 capturedNow 同源。 + // 所有写入方(AuditService、SecurityAuditService)本就显式设置 createdAt,移除后行为不变。 @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java index 493e525..01ff687 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java @@ -22,7 +22,8 @@ public class AuditService { public void log(Long userId, String action, String entityType, Long entityId, String details, String ipAddress, String userAgent, String requestId) { logWithContext(userId, action, entityType, entityId, details, - AuditContext.builder().requestId(requestId).ipAddress(ipAddress).userAgent(userAgent).build()); + AuditContext.builder().requestId(requestId).ipAddress(ipAddress).userAgent(userAgent).build(), + LocalDateTime.now()); } /** @@ -30,11 +31,22 @@ public class AuditService { */ public void log(Long userId, String action, String entityType, Long entityId, String details, AuditContext context) { - logWithContext(userId, action, entityType, entityId, details, context); + logWithContext(userId, action, entityType, entityId, details, context, LocalDateTime.now()); + } + + /** + * 记录一个审计事件(显式时间戳版本,S02)。 + * + *

供 S02 认证入口使用:登录、刷新、登出、重放处置与全局吊销在各自入口 + * 只捕获一次 {@code capturedNow},审计时间必须与之同源,不得再次读时。

+ */ + public void log(Long userId, String action, String entityType, Long entityId, + String details, AuditContext context, LocalDateTime capturedAt) { + logWithContext(userId, action, entityType, entityId, details, context, capturedAt); } private void logWithContext(Long userId, String action, String entityType, Long entityId, - String details, AuditContext ctx) { + String details, AuditContext ctx, LocalDateTime createdAt) { try { // 脱敏:details 中不得包含 code/password/token/secret 等关键词的值 String safeDetails = sanitize(details); @@ -47,7 +59,7 @@ public class AuditService { .ipAddress(ctx != null ? ctx.getIpAddress() : null) .userAgent(ctx != null ? ctx.getUserAgent() : null) .requestId(ctx != null ? ctx.getRequestId() : null) - .createdAt(LocalDateTime.now()) + .createdAt(createdAt) .build(); auditLogRepository.save(auditLog); } catch (Exception e) { @@ -73,6 +85,32 @@ public class AuditService { log(userId, "LOGOUT", "USER", userId, "User logged out", ctx); } + /** S02:登录会话创建,使用显式 capturedNow。 */ + public void logSessionCreated(Long userId, String sessionRef, AuditContext ctx, LocalDateTime capturedAt) { + log(userId, "AUTH_SESSION_CREATED", "AUTH_SESSION", null, + "{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt); + } + + /** S02:Refresh Token 轮换成功,使用显式 capturedNow。 */ + public void logTokenRefreshed(Long userId, String sessionRef, AuditContext ctx, LocalDateTime capturedAt) { + log(userId, "AUTH_TOKEN_REFRESHED", "AUTH_SESSION", null, + "{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt); + } + + /** S02:会话被撤销(登出、重放处置或全局吊销)。 */ + public void logSessionRevoked(Long userId, String sessionRef, String reason, + AuditContext ctx, LocalDateTime capturedAt) { + log(userId, "AUTH_SESSION_REVOKED", "AUTH_SESSION", null, + "{\"sessionRef\":\"" + sessionRef + "\",\"reason\":\"" + reason + "\"}", ctx, capturedAt); + } + + /** S02:检测到 Refresh Token 重放。 */ + public void logRefreshReuseDetected(Long userId, String sessionRef, + AuditContext ctx, LocalDateTime capturedAt) { + log(userId, "AUTH_REFRESH_REUSE_DETECTED", "AUTH_SESSION", null, + "{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt); + } + /** * 脱敏:不记录可能的敏感字段值 */ diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AccessTokenClaims.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AccessTokenClaims.java new file mode 100644 index 0000000..214cffa --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AccessTokenClaims.java @@ -0,0 +1,17 @@ +package com.mydreamjob.auth; + +import java.time.Instant; + +/** + * 已通过完整校验的 Access Token 声明(S02)。 + * + *

强类型只读视图:调用方不再接触 JJWT 的 Claims 对象,也不依赖 claim 名。

+ */ +public record AccessTokenClaims( + Long userId, + String sessionId, + String tokenId, + String role, + Instant issuedAt, + Instant expiresAt) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapCreator.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapCreator.java new file mode 100644 index 0000000..e11e149 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapCreator.java @@ -0,0 +1,105 @@ +package com.mydreamjob.auth; + +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; +import java.util.Optional; + +/** + * S03 定向整改:管理员账号创建的**独立事务组件**。 + * + *

为什么不用 JPA/Hibernate:并发初始化时数据库唯一约束会成为正常并发控制手段, + * 而 Hibernate 的 {@code SqlExceptionHelper} 会在业务代码捕获异常**之前**以 ERROR 级别 + * 记录整条语句与绑定值 —— 管理员手机号因此进入生产可见日志。

+ * + *

本组件改用 {@link JdbcTemplate} 的参数化语句:

+ *
    + *
  • 冲突在 JDBC 层被捕获,**不经过** Hibernate 日志体系;
  • + *
  • 原始数据库异常与 message/cause **一律丢弃**,对外只返回结果码;
  • + *
  • {@code users} 与 {@code user_roles} 在**同一事务**内提交 —— + * 角色写入失败时用户创建整体回滚;
  • + *
  • 不打印 SQL、手机号或绑定参数。
  • + *
+ */ +@Component +public class AdminBootstrapCreator { + + /** 创建结果:成功创建 / 数据库唯一约束冲突。 */ + public enum CreateResult { + CREATED, + CONFLICT + } + + /** 引导账号的最小状态视图(不含任何敏感字段输出)。 */ + public record BootstrapUser(Long id, String role, boolean active, boolean verified, + boolean passwordAbsent, List roles) { + } + + private final JdbcTemplate jdbcTemplate; + private final TransactionTemplate transactionTemplate; + + /** + * 使用**独立 REQUIRES_NEW 事务**:冲突必须只回滚本次创建,不得把调用方的外层事务 + * 标记为 rollback-only(否则调用方提交时会抛 {@code UnexpectedRollbackException})。 + */ + public AdminBootstrapCreator(JdbcTemplate jdbcTemplate, + org.springframework.transaction.PlatformTransactionManager transactionManager) { + this.jdbcTemplate = jdbcTemplate; + this.transactionTemplate = new TransactionTemplate(transactionManager); + this.transactionTemplate.setPropagationBehavior( + org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + /** + * 在同一事务内写入 {@code users} 与 {@code user_roles}。 + * + * @return {@link CreateResult#CREATED} 或 {@link CreateResult#CONFLICT}; + * 冲突时不抛出任何携带原始数据库信息的异常 + */ + public CreateResult createAdmin(String phone, String nickname) { + try { + transactionTemplate.executeWithoutResult(status -> { + jdbcTemplate.update( + "INSERT INTO users (phone, nickname, password_hash, role, is_active, is_verified, " + + "created_at, updated_at) VALUES (?, ?, NULL, 'ADMIN', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + phone, nickname); + Long userId = jdbcTemplate.queryForObject( + "SELECT id FROM users WHERE phone = ?", Long.class, phone); + jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, 'ADMIN')", userId); + }); + return CreateResult.CREATED; + } catch (DataAccessException e) { + // 唯一约束冲突(并发初始化)、角色写入失败或任何 JDBC 层失败:事务已整体回滚。 + // 刻意**丢弃**原始异常:它的 message 可能包含 SQL 与绑定值(例如手机号)。 + // 调用方据此重新查询,决定是「幂等成功」还是抛出不带 cause 的固定安全异常。 + return CreateResult.CONFLICT; + } + } + + /** 按手机号读取引导所需的最小状态;只读、参数化,不经过 Hibernate。 */ + public Optional findByPhone(String phone) { + List rows = jdbcTemplate.query( + "SELECT id, role, is_active, is_verified, password_hash FROM users WHERE phone = ?", + (rs, i) -> new BootstrapUser( + rs.getLong("id"), + rs.getString("role"), + rs.getInt("is_active") == 1, + rs.getInt("is_verified") == 1, + rs.getString("password_hash") == null, + List.of()), + phone); + if (rows.isEmpty()) { + return Optional.empty(); + } + BootstrapUser base = rows.get(0); + List roles = jdbcTemplate.queryForList( + "SELECT role FROM user_roles WHERE user_id = ?", String.class, base.id()); + return Optional.of(new BootstrapUser(base.id(), base.role(), base.active(), + base.verified(), base.passwordAbsent(), roles)); + } + + /** 是否存在活动 ADMIN(不涉及手机号,可用 JPA 仓储)。 */ +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapRunner.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapRunner.java new file mode 100644 index 0000000..acf4de5 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapRunner.java @@ -0,0 +1,119 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.config.AdminBootstrapProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * S03-A 一次性管理员初始化(显式开关,默认关闭)。 + * + *

仅在 {@code app.bootstrap.admin.enabled} 为字面量 {@code "true"} 时装配; + * 本类还会再校验一次字面量,确保大小写变体不会误启用。

+ * + *

规则(设计 §3.2):

+ *
    + *
  1. 手机号必须通过既有手机号格式校验;不写日志、异常详情或审计 details;
  2. + *
  3. 已有活动 ADMIN → 无副作用跳过;
  4. + *
  5. 无活动 ADMIN 且手机号未被占用 → 创建 active=true / verified=true 的 ADMIN, + * 同时写入 {@code users.role} 与 {@code user_roles};
  6. + *
  7. 手机号已属于任何用户(任意角色 / inactive / 角色不一致)→ 启动失败, + * 禁止提权、复活或覆盖;
  8. + *
  9. 相同配置重复启动幂等;
  10. + *
  11. 多实例并发:数据库唯一手机号约束是最后防线,冲突直接失败,不吞掉。
  12. + *
+ * + *

不签发 Token、不发送短信、不设置默认密码、不提供公网接口。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "app.bootstrap.admin.enabled", havingValue = "true") +public class AdminBootstrapRunner implements ApplicationRunner { + + /** 与登录链路一致的手机号格式:11 位数字。 */ + private static final Pattern PHONE_PATTERN = Pattern.compile("[0-9]{11}"); + + /** 严格字面量:只有 "true" 才算启用。 */ + private static final String STRICTLY_ENABLED = "true"; + + /** 固定安全文案:不包含手机号、用户对象、SQL 或环境变量值。 */ + static final String ERROR_INVALID_PHONE = "admin bootstrap rejected: configured phone is invalid"; + static final String ERROR_PHONE_IN_USE = "admin bootstrap rejected: configured phone is already in use"; + + private final AdminBootstrapProperties properties; + private final UserRepository userRepository; + /** S03 整改:创建走独立的 JdbcTemplate 事务组件,避免 Hibernate 记录绑定手机号。 */ + private final AdminBootstrapCreator creator; + + @Override + public void run(ApplicationArguments args) { + // 兜底的字面量校验:即使 @ConditionalOnProperty 的匹配更宽松,也不允许非 "true" 生效。 + if (!STRICTLY_ENABLED.equals(properties.getEnabled())) { + return; + } + + String phone = properties.getPhone(); + if (phone == null || !PHONE_PATTERN.matcher(phone.trim()).matches()) { + throw new IllegalStateException(ERROR_INVALID_PHONE); + } + phone = phone.trim(); + + // 配置手机号已被任何用户占用(含 inactive、STUDENT、COMPANY、角色不一致)? + // 必须先验证该账号,不能被“已有活动 ADMIN”短路:否则一个主角色为 ADMIN、 + // 但角色表缺失/含附加角色/仍有密码的账号会绕过严格幂等判据。 + Optional existing = creator.findByPhone(phone); + if (existing.isPresent()) { + // 方案 A:仅当现存账号正是本次初始化配置要求的合法 ADMIN 时幂等成功; + // 普通用户 / inactive / 角色不一致一律启动失败,绝不提权、复活或覆盖。 + if (isValidBootstrapAdmin(existing.get())) { + log.info("admin bootstrap skipped: a matching administrator already exists"); + return; + } + throw new IllegalStateException(ERROR_PHONE_IN_USE); + } + + // 配置手机号不存在且已有其他活动 ADMIN:无副作用跳过,绝不创建第二个管理员。 + if (userRepository.countByRoleAndActiveTrue(User.Role.ADMIN) > 0) { + log.info("admin bootstrap skipped: an active administrator already exists"); + return; + } + + AdminBootstrapCreator.CreateResult result = creator.createAdmin(phone, properties.getNickname()); + if (result == AdminBootstrapCreator.CreateResult.CONFLICT) { + // 并发初始化:另一个实例刚创建了同一手机号。按方案 A 重新判定: + // 只有现存账号完全合法才幂等成功,否则一律失败。 + if (creator.findByPhone(phone).filter(this::isValidBootstrapAdmin).isPresent()) { + log.info("admin bootstrap skipped: a matching administrator already exists"); + return; + } + throw new IllegalStateException(ERROR_PHONE_IN_USE); + } + + // 固定事件文本,不含手机号;部署方初始化后必须移除开关。 + log.info("admin bootstrap created an administrator; remove the bootstrap switch now"); + } + + /** + * 方案 A 的幂等判据:现存账号必须**恰好**是本次初始化所要求的合法管理员。 + * + *

要求角色为 ADMIN、active、verified、无默认口令、{@code user_roles} 含 ADMIN, + * 且不含 STUDENT/COMPANY 等附加角色。任一不满足即视为「手机号被占用」,启动失败。

+ */ + private boolean isValidBootstrapAdmin(AdminBootstrapCreator.BootstrapUser user) { + return User.Role.ADMIN.name().equals(user.role()) + && user.active() + && user.verified() + && user.passwordAbsent() + && user.roles().contains(User.Role.ADMIN.name()) + && !user.roles().contains(User.Role.STUDENT.name()) + && !user.roles().contains(User.Role.COMPANY.name()); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java index b3a3bd1..75ca01d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java @@ -1,5 +1,6 @@ package com.mydreamjob.auth; +import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; @@ -12,25 +13,34 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; +import java.time.Instant; import java.util.*; /** - * Mock 认证服务 - 仅用于开发/测试环境登录原型 + * 认证服务(S02):手机号验证码登录,并编排刷新与登出。 + * + *

登录成功后创建服务端登录会话;Access/Refresh 由各自的密钥签发, + * 数据库只保存 Refresh Token 的 HMAC 摘要。

*/ @Slf4j @Service @RequiredArgsConstructor public class AuthService { + /** 登录与刷新响应中的 expiresIn 秒数,保持既有契约不变(设计 D04)。 */ + public static final long EXPIRES_IN_SECONDS = 86400L; + private final UserRepository userRepository; - private final JwtTokenProvider jwtTokenProvider; private final SmsVerificationService smsVerificationService; private final AuditService auditService; private final AccountDeletionRepository accountDeletionRepository; private final AccountDeletionPhoneHasher accountDeletionPhoneHasher; + private final AuthSessionService authSessionService; + private final Clock clock; /** - * Mock 登录:验证短信验证码后自动创建/登录用户 + * Mock 登录:验证短信验证码后自动创建/登录用户,并建立一次登录会话。 */ @Transactional public Map mockLogin(String phone, String code, String purpose, @@ -57,34 +67,62 @@ public class AuthService { .verified(true) .build(); user = userRepository.save(user); - log.info("Auto-created user: id={}, phone={}", user.getId(), phone); + // 日志不得记录完整手机号 + log.info("Auto-created user: id={}", user.getId()); } else { user = userOpt.get(); } // 已注销(COMPLETED)用户禁止登录 - java.util.Optional deletion = accountDeletionRepository.findByUserId(user.getId()); + Optional deletion = accountDeletionRepository.findByUserId(user.getId()); if (deletion.isPresent() && deletion.get().getStatus() == DeletionStatus.COMPLETED) { throw new BusinessException(ErrorCode.ACCOUNT_DELETED); } - // 生成 token - String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getPhone(), Map.of("role", user.getRole().name())); - String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId(), user.getPhone()); + // 认证入口只取一次时间,后续 JWT iat/exp、数据库时间与审计全部同源。 + Instant capturedNow = clock.instant(); + AuditContext ctx = AuditContext.builder() + .requestId(requestId) + .ipAddress(ipAddress) + .userAgent(userAgent) + .build(); + + // 会话记录必须与登录在同一事务内持久化;失败则整个登录失败,不返回 Token。 + AuthSessionService.SessionTokens tokens = authSessionService.createSession(user, capturedNow, ctx); - auditService.logLogin(user.getId(), ipAddress, userAgent, requestId); + auditService.log(user.getId(), "LOGIN", "USER", user.getId(), "User logged in", ctx, toLocal(capturedNow)); Map result = new HashMap<>(); result.put("userId", user.getId()); result.put("phone", user.getPhone()); result.put("nickname", user.getNickname()); result.put("role", user.getRole()); - result.put("accessToken", accessToken); - result.put("refreshToken", refreshToken); - result.put("expiresIn", 86400); + result.put("accessToken", tokens.accessToken()); + result.put("refreshToken", tokens.refreshToken()); + result.put("expiresIn", EXPIRES_IN_SECONDS); return result; } + /** + * 刷新:消费并轮换 Refresh Token。 + * + * @throws InvalidTokenException 任何失败(统一对外 401) + */ + public AuthSessionService.SessionTokens refresh(String refreshToken, + String requestId, String ipAddress, String userAgent) { + Instant capturedNow = clock.instant(); + return authSessionService.refresh(refreshToken, capturedNow, context(requestId, ipAddress, userAgent)); + } + + /** + * 登出:撤销当前会话(幂等)。 + */ + public void logout(Long userId, String sessionId, + String requestId, String ipAddress, String userAgent) { + Instant capturedNow = clock.instant(); + authSessionService.logout(userId, sessionId, capturedNow, context(requestId, ipAddress, userAgent)); + } + /** * 获取当前用户信息 */ @@ -104,4 +142,16 @@ public class AuthService { result.put("createdAt", user.getCreatedAt()); return result; } + + private AuditContext context(String requestId, String ipAddress, String userAgent) { + return AuditContext.builder() + .requestId(requestId) + .ipAddress(ipAddress) + .userAgent(userAgent) + .build(); + } + + private static java.time.LocalDateTime toLocal(Instant instant) { + return AuthSessionService.toLocal(instant); + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java new file mode 100644 index 0000000..e0348d3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java @@ -0,0 +1,332 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.AuditService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/** + * S02 登录会话服务:创建会话、校验会话、轮换与消费 Refresh Token、撤销会话。 + * + *

并发正确性完全由数据库条件更新保证({@link AuthTokenRepository#consumeIfCurrent}), + * 不使用任何 JVM 本地锁。

+ * + *

时间一律由调用方(认证入口)传入的 {@code capturedNow} 决定,本类不自行取时。

+ */ +@Slf4j +@Service +public class AuthSessionService { + + /** 固定业务时区(设计 D13)。 */ + public static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + + /** 撤销原因白名单(设计 D12 / 第十二节)。 */ + public static final String REASON_LOGOUT = "USER_LOGOUT"; + public static final String REASON_REPLAY = "REFRESH_REUSE_DETECTED"; + public static final String REASON_ACCOUNT_DELETED = "ACCOUNT_DELETED"; + public static final String REASON_USER_DISABLED = "USER_DISABLED"; + + private final AuthTokenRepository authTokenRepository; + private final AuthTokenHasher authTokenHasher; + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + private final AuditService auditService; + /** 刷新尝试:消费旧 Token + 写入后继 Token 必须在同一事务内完成。 */ + private final TransactionTemplate refreshTransaction; + /** 重放撤销:必须在刷新尝试事务**结束之后**独立提交,避免与并发胜出方互相持锁。 */ + private final TransactionTemplate replayTransaction; + + public AuthSessionService(AuthTokenRepository authTokenRepository, + AuthTokenHasher authTokenHasher, + JwtTokenProvider jwtTokenProvider, + UserRepository userRepository, + AuditService auditService, + PlatformTransactionManager transactionManager) { + this.authTokenRepository = authTokenRepository; + this.authTokenHasher = authTokenHasher; + this.jwtTokenProvider = jwtTokenProvider; + this.userRepository = userRepository; + this.auditService = auditService; + this.refreshTransaction = new TransactionTemplate(transactionManager); + this.refreshTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + this.replayTransaction = new TransactionTemplate(transactionManager); + this.replayTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + /** 刷新尝试的结果:成功(携带新 Token 对)或识别为重放。 */ + private sealed interface RefreshAttempt { + record Succeeded(SessionTokens tokens) implements RefreshAttempt { + } + + record Replayed(Long userId, String sessionId) implements RefreshAttempt { + } + } + + /** 一次会话签发的结果。Token 原文只在内存中短暂存在。 */ + public record SessionTokens(String accessToken, String refreshToken, String sessionId) { + } + + // ── 登录:创建会话 ─────────────────────────────────────────── + + /** + * 创建一次登录会话并签发 Access/Refresh Token。 + * + *

必须运行在调用方事务内:会话记录持久化失败时,整个登录事务失败,不得返回 Token。

+ */ + @Transactional + public SessionTokens createSession(User user, Instant capturedNow, AuditContext ctx) { + // 失败关闭:不给不可用用户创建活动会话,更不返回 Token。 + // 不能先发 Token 再由过滤器拒绝 —— 那样 Token 已经流出去了。 + if (user == null || !isUsable(user)) { + throw new InvalidTokenException(); + } + String sessionId = UUID.randomUUID().toString(); + String accessJti = UUID.randomUUID().toString(); + String refreshJti = UUID.randomUUID().toString(); + + IssuedToken access = jwtTokenProvider.generateAccessToken( + user.getId(), user.getRole().name(), sessionId, accessJti, capturedNow); + IssuedToken refresh = jwtTokenProvider.generateRefreshToken( + user.getId(), sessionId, refreshJti, capturedNow); + + authTokenRepository.save(AuthToken.builder() + .userId(user.getId()) + .tokenHash(authTokenHasher.hash(refresh.token())) + .tokenType(AuthToken.TYPE_REFRESH) + .expiredAt(toLocal(refresh.expiresAt())) + .revoked(false) + .sessionId(sessionId) + .tokenJti(refreshJti) + // created_at 必须与入口捕获的 capturedNow 同源,不能由审计读取系统时间。 + .createdAt(toLocal(capturedNow)) + .build()); + + auditService.logSessionCreated(user.getId(), sessionRef(sessionId), ctx, toLocal(capturedNow)); + return new SessionTokens(access.token(), refresh.token(), sessionId); + } + + // ── 校验:Access 所属会话是否可用(失败关闭)────────────────── + + /** + * Access Token 的服务端会话校验(设计 D10)。任一条不满足即返回 {@code false}: + * 会话属于该用户、存在未消费未撤销未过期的 Refresh 记录、用户存在且 active。 + * + *

关于账号注销:这里刻意只检查 {@code active},不单独判定注销记录的 + * COMPLETED 状态。账号注销完成时 {@code AccountDeletionCompletionWorker} 会 + * 1) 脱敏并将用户置为 inactive,2) 撤销该用户全部会话 —— 两道处理叠加后, + * 真实注销用户的旧 Token 无论走哪条路径都会失败。把 COMPLETED 的 409/9303 语义 + * 保留给 {@code DeletionGuardInterceptor},可避免改变既有注销端点契约。

+ * + *

任何数据库/运行时异常一律**失败关闭**,不建立认证。

+ */ + public boolean isSessionActive(Long userId, String sessionId, Instant capturedNow) { + if (userId == null || sessionId == null || sessionId.isBlank()) { + return false; + } + try { + if (!isUserUsable(userId)) { + // 固定安全文本:不包含 userId、sessionId、Token 或 claim。 + log.warn("session validation rejected: user not usable"); + return false; + } + if (authTokenRepository.countActiveSessionTokens(userId, sessionId, toLocal(capturedNow)) <= 0) { + log.warn("session validation rejected: no active session"); + return false; + } + return true; + } catch (Exception e) { + // 固定安全文本:不回显异常消息、userId、sessionId 或 Token。 + log.warn("session validation failed"); + return false; + } + } + + // ── 刷新:消费并轮换 ───────────────────────────────────────── + + /** + * 刷新编排入口。**本方法不运行在事务中** —— 这一点是并发重放处置正确性的前提: + * 刷新尝试事务必须先结束(释放其持有的行锁),重放撤销才能在无竞争的事务里生效, + * 从而覆盖并发胜出方刚写入的后继 Token。 + * + * @throws InvalidTokenException 任何失败,包括重放 + */ + public SessionTokens refresh(String rawRefreshToken, Instant capturedNow, AuditContext ctx) { + // 校验与摘要不访问数据库,放在事务外,异常时仍能拿到 sessionId 用于重放撤销。 + RefreshTokenClaims claims = jwtTokenProvider.parseAndValidateRefreshToken(rawRefreshToken, capturedNow); + String hash = authTokenHasher.hash(rawRefreshToken); + + RefreshAttempt attempt; + try { + attempt = refreshTransaction.execute( + status -> executeRefreshAttempt(claims, hash, capturedNow, ctx)); + } catch (DataAccessException e) { + // 并发的另一次刷新正持有该记录的行锁。本次刷新无法完成, + // 按重放处置并撤销整个会话(失败关闭),对调用方仍是统一的 401。 + revokeReplayedSession(claims.userId(), claims.sessionId(), ctx, capturedNow); + throw new InvalidTokenException(); + } + + if (attempt instanceof RefreshAttempt.Replayed replayed) { + // 此时刷新尝试事务已经结束:胜出方要么已提交、要么已被本事务回滚释放, + // 撤销事务不会再与之互相持锁。 + revokeReplayedSession(replayed.userId(), replayed.sessionId(), ctx, capturedNow); + throw new InvalidTokenException(); + } + + return ((RefreshAttempt.Succeeded) attempt).tokens(); + } + + /** + * 一次刷新尝试:校验记录、原子消费旧 Token、写入后继 Token,全部在同一事务内。 + * + *

识别为重放时**不抛异常**,而是返回 {@link RefreshAttempt.Replayed} —— + * 否则事务回滚会连带丢失撤销所需的会话信息。

+ */ + private RefreshAttempt executeRefreshAttempt(RefreshTokenClaims claims, String hash, + Instant capturedNow, AuditContext ctx) { + AuthToken record = authTokenRepository.findTopByTokenHashOrderByIdDesc(hash) + .orElseThrow(InvalidTokenException::new); + + // 声明必须与数据库记录一致,否则视为不可信。 + if (!Objects.equals(record.getUserId(), claims.userId()) + || !Objects.equals(record.getSessionId(), claims.sessionId()) + || !Objects.equals(record.getTokenJti(), claims.tokenId())) { + throw new InvalidTokenException(); + } + + User user = requireUsableUser(claims.userId()); + + int consumed = authTokenRepository.consumeIfCurrent( + record.getId(), record.getVersion(), toLocal(capturedNow), toLocal(capturedNow)); + + if (consumed != 1) { + // 已消费 / 已撤销 / 已过期:交回编排入口做重放处置。 + return new RefreshAttempt.Replayed(record.getUserId(), record.getSessionId()); + } + + String newAccessJti = UUID.randomUUID().toString(); + String newRefreshJti = UUID.randomUUID().toString(); + + IssuedToken access = jwtTokenProvider.generateAccessToken( + user.getId(), user.getRole().name(), claims.sessionId(), newAccessJti, capturedNow); + IssuedToken refresh = jwtTokenProvider.generateRefreshToken( + user.getId(), claims.sessionId(), newRefreshJti, capturedNow); + + authTokenRepository.save(AuthToken.builder() + .userId(user.getId()) + .tokenHash(authTokenHasher.hash(refresh.token())) + .tokenType(AuthToken.TYPE_REFRESH) + .expiredAt(toLocal(refresh.expiresAt())) + .revoked(false) + .sessionId(claims.sessionId()) + .tokenJti(newRefreshJti) + .parentTokenId(record.getId()) + // created_at 与入口捕获的 capturedNow 同源。 + .createdAt(toLocal(capturedNow)) + .build()); + + auditService.logTokenRefreshed(user.getId(), sessionRef(claims.sessionId()), ctx, toLocal(capturedNow)); + return new RefreshAttempt.Succeeded( + new SessionTokens(access.token(), refresh.token(), claims.sessionId())); + } + + /** + * 重放处置:在独立 REQUIRES_NEW 事务中撤销该会话全部记录并写脱敏审计, + * 随后**校验该会话已无活动 Token**(含并发胜出方的后继 Token)。 + * + *

撤销失败不做吞掉处理:先做有限次重试(无 sleep),仍失败则让异常向外传播 —— + * 宁可暴露基础设施故障,也不能留下一个可继续使用的会话。

+ */ + private void revokeReplayedSession(Long userId, String sessionId, AuditContext ctx, Instant capturedNow) { + if (sessionId == null) { + return; + } + for (int attempt = 0; attempt < MAX_REPLAY_REVOKE_ATTEMPTS; attempt++) { + replayTransaction.execute(status -> { + authTokenRepository.revokeSession(userId, sessionId, toLocal(capturedNow), REASON_REPLAY); + auditService.logRefreshReuseDetected(userId, sessionRef(sessionId), ctx, toLocal(capturedNow)); + auditService.logSessionRevoked(userId, sessionRef(sessionId), REASON_REPLAY, ctx, toLocal(capturedNow)); + return null; + }); + if (authTokenRepository.countActiveSessionTokens(userId, sessionId, toLocal(capturedNow)) <= 0) { + return; + } + // 胜出方的后继 Token 可能在本轮撤销之后才提交,重试一轮覆盖它。 + // 固定安全文本:不记录 userId、sessionId 或 Token。 + log.warn("replayed session still had active tokens; retrying revocation"); + } + throw new IllegalStateException("replayed session could not be revoked"); + } + + /** 重放撤销的重试上限(无 sleep;正常情况下第一轮即成功)。 */ + private static final int MAX_REPLAY_REVOKE_ATTEMPTS = 3; + + // ── 登出:撤销当前会话 ─────────────────────────────────────── + + /** + * 撤销当前会话(幂等)。重复登出不报错,也不影响其他设备会话。 + */ + @Transactional + public void logout(Long userId, String sessionId, Instant capturedNow, AuditContext ctx) { + if (userId == null || sessionId == null || sessionId.isBlank()) { + throw new InvalidTokenException(); + } + authTokenRepository.revokeSession(userId, sessionId, toLocal(capturedNow), REASON_LOGOUT); + auditService.logSessionRevoked(userId, sessionRef(sessionId), REASON_LOGOUT, ctx, toLocal(capturedNow)); + } + + // ── 全局吊销 ───────────────────────────────────────────────── + + /** + * 撤销该用户全部会话(账号注销完成、用户禁用等)。 + * + * @return 受影响行数 + */ + @Transactional + public int revokeAllUserSessions(Long userId, String reason, Instant capturedNow) { + if (userId == null) { + return 0; + } + return authTokenRepository.revokeAllForUser(userId, toLocal(capturedNow), reason); + } + + // ── 内部工具 ───────────────────────────────────────────────── + + private User requireUsableUser(Long userId) { + return userRepository.findById(userId) + .filter(this::isUsable) + .orElseThrow(InvalidTokenException::new); + } + + private boolean isUserUsable(Long userId) { + Optional user = userRepository.findById(userId); + return user.isPresent() && isUsable(user.get()); + } + + private boolean isUsable(User user) { + return user.getActive() != null && user.getActive(); + } + + /** + * 审计用的会话标识:sessionId 的 HMAC 截断,不可逆且不泄露原值。 + */ + String sessionRef(String sessionId) { + return authTokenHasher.hash("sid:" + sessionId).substring(0, 16); + } + + static LocalDateTime toLocal(Instant instant) { + return LocalDateTime.ofInstant(instant, BUSINESS_ZONE); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java index 02fe3a7..344e0f2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java @@ -5,13 +5,22 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; /** - * JWT Token 实体(用于 token 吊销和刷新) + * 登录会话中的 Refresh Token 记录(S02)。 + * + *

字段含义(S02 固定):

+ *
    + *
  • {@code tokenHash} —— **Refresh Token 原文的 HMAC-SHA256 摘要**,绝不保存原文;
  • + *
  • {@code tokenType} —— 新记录固定 {@code REFRESH};
  • + *
  • {@code expiredAt} —— Refresh Token 到期时间;
  • + *
  • {@code revoked} —— 兼容既有布尔语义,与 {@code revokedAt} 同步维护;
  • + *
  • {@code refreshTokenHash} —— 旧字段保留,S02 新逻辑不依赖。
  • + *
+ * + *

旧数据({@code sessionId IS NULL})永远不被新鉴权逻辑视为活动会话。

*/ @Data @Builder @@ -19,9 +28,11 @@ import java.time.LocalDateTime; @AllArgsConstructor @Entity @Table(name = "auth_tokens") -@EntityListeners(AuditingEntityListener.class) public class AuthToken { + /** Token 类型:S02 起 auth_tokens 只保存 Refresh 记录。 */ + public static final String TYPE_REFRESH = "REFRESH"; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -34,7 +45,7 @@ public class AuthToken { @Column(name = "token_type", nullable = false, length = 20) @Builder.Default - private String tokenType = "ACCESS"; + private String tokenType = TYPE_REFRESH; @Column(name = "expired_at", nullable = false) private LocalDateTime expiredAt; @@ -46,7 +57,32 @@ public class AuthToken { @Column(name = "refresh_token_hash", length = 255) private String refreshTokenHash; - @CreatedDate + @Column(name = "session_id", length = 36) + private String sessionId; + + @Column(name = "token_jti", length = 36) + private String tokenJti; + + @Column(name = "parent_token_id") + private Long parentTokenId; + + @Column(name = "consumed_at") + private LocalDateTime consumedAt; + + @Column(name = "revoked_at") + private LocalDateTime revokedAt; + + @Column(name = "revocation_reason", length = 40) + private String revocationReason; + + @Version + @Column(name = "version", nullable = false) + @Builder.Default + private Long version = 0L; + + // S02:不使用 @CreatedDate —— Spring Data 审计会在持久化时用“当前时间”覆盖显式值, + // 导致 created_at 与认证入口捕获的 capturedNow 不同源。生产写入路径只有 + // AuthSessionService(创建会话、轮换后继)两处,均显式设置 createdAt。 @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java new file mode 100644 index 0000000..ea26933 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java @@ -0,0 +1,71 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.config.AppJwtProperties; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Refresh Token 摘要器(S02)。 + * + *

数据库只保存 {@code HMAC-SHA256(token-hash-secret, rawToken)} 的十六进制表示, + * 不保存 Token 原文,也不使用可逆加密或无密钥的裸摘要。

+ * + *

本类**不记录**输入、输出或异常原值。比较使用常量时间比较。

+ */ +@Component +public class AuthTokenHasher { + + private static final String ALGORITHM = "HmacSHA256"; + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private final byte[] key; + + public AuthTokenHasher(AppJwtProperties properties) { + this.key = properties.getTokenHashSecret().getBytes(StandardCharsets.UTF_8); + } + + /** + * 计算摘要,返回固定 64 字符的小写十六进制字符串。 + * + * @param rawToken Token 原文;{@code null} 视为空串以保证稳定输出 + */ + public String hash(String rawToken) { + byte[] input = (rawToken == null ? "" : rawToken).getBytes(StandardCharsets.UTF_8); + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(key, ALGORITHM)); + return toHex(mac.doFinal(input)); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + // 固定安全文本:不回显输入、输出或异常原值。 + throw new IllegalStateException("token hash unavailable"); + } + } + + /** + * 常量时间比较两个摘要。任一为 {@code null} 时返回 {@code false}。 + */ + public boolean matches(String expectedHash, String actualHash) { + if (expectedHash == null || actualHash == null) { + return false; + } + return MessageDigest.isEqual( + expectedHash.getBytes(StandardCharsets.UTF_8), + actualHash.getBytes(StandardCharsets.UTF_8)); + } + + private static String toHex(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + int v = bytes[i] & 0xFF; + out[i * 2] = HEX[v >>> 4]; + out[i * 2 + 1] = HEX[v & 0x0F]; + } + return new String(out); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java index b5e74ae..d16ef71 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java @@ -1,12 +1,21 @@ package com.mydreamjob.auth; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +/** + * auth_tokens 数据访问(S02)。 + * + *

并发消费与撤销**一律通过带条件的原子 UPDATE** 完成,并严格检查受影响行数; + * 禁止以 {@code findById -> set -> save} 作为并发保证。

+ */ @Repository public interface AuthTokenRepository extends JpaRepository { @@ -17,4 +26,94 @@ public interface AuthTokenRepository extends JpaRepository { Optional findByRefreshTokenHash(String refreshTokenHash); List findByExpiredAtBefore(LocalDateTime expiredAt); + + // ── S02 会话查询 ──────────────────────────────────────────── + + /** + * 按摘要取候选 Refresh 记录(同摘要理论上唯一,取最新一条以防历史重复)。 + */ + Optional findTopByTokenHashOrderByIdDesc(String tokenHash); + + /** + * 会话是否活动:同一 user + session 下存在未消费、未撤销且未过期的 Refresh 记录。 + * + *

旧数据 {@code sessionId IS NULL} 永远不会命中。

+ */ + @Query(""" + SELECT COUNT(t) FROM AuthToken t + WHERE t.userId = :userId + AND t.sessionId = :sessionId + AND t.revoked = false + AND t.consumedAt IS NULL + AND t.expiredAt > :capturedNow + """) + long countActiveSessionTokens(@Param("userId") Long userId, + @Param("sessionId") String sessionId, + @Param("capturedNow") LocalDateTime capturedNow); + + List findByUserIdAndSessionIdOrderByIdAsc(Long userId, String sessionId); + + // ── S02 原子条件更新(返回值必须被严格检查)──────────────────── + + /** + * 原子消费一条 Refresh 记录。只有满足以下全部条件才会成功: + * id、expectedVersion、未撤销、未消费、未过期。 + * + * @return 受影响行数;0 表示并发冲突、已被消费、已被撤销或已过期 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE AuthToken t + SET t.consumedAt = :consumedAt, + t.version = t.version + 1 + WHERE t.id = :id + AND t.version = :expectedVersion + AND t.revoked = false + AND t.consumedAt IS NULL + AND t.expiredAt > :capturedNow + """) + int consumeIfCurrent(@Param("id") Long id, + @Param("expectedVersion") Long expectedVersion, + @Param("consumedAt") LocalDateTime consumedAt, + @Param("capturedNow") LocalDateTime capturedNow); + + /** + * 撤销某用户某个会话下所有未撤销记录。 + * + * @return 受影响行数 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE AuthToken t + SET t.revoked = true, + t.revokedAt = :revokedAt, + t.revocationReason = :reason, + t.version = t.version + 1 + WHERE t.userId = :userId + AND t.sessionId = :sessionId + AND t.revoked = false + """) + int revokeSession(@Param("userId") Long userId, + @Param("sessionId") String sessionId, + @Param("revokedAt") LocalDateTime revokedAt, + @Param("reason") String reason); + + /** + * 撤销某用户全部未撤销记录(账号注销完成、用户禁用等全局安全事件)。 + * + * @return 受影响行数 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE AuthToken t + SET t.revoked = true, + t.revokedAt = :revokedAt, + t.revocationReason = :reason, + t.version = t.version + 1 + WHERE t.userId = :userId + AND t.revoked = false + """) + int revokeAllForUser(@Param("userId") Long userId, + @Param("revokedAt") LocalDateTime revokedAt, + @Param("reason") String reason); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/InvalidTokenException.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/InvalidTokenException.java new file mode 100644 index 0000000..492f3f5 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/InvalidTokenException.java @@ -0,0 +1,16 @@ +package com.mydreamjob.auth; + +/** + * Token 校验失败(S02)。 + * + *

异常消息固定为安全文本,**绝不包含 Token 原文、claim 原值或摘要**。 + * 对外一律表现为统一的 401,不区分“不存在”“已消费”“已撤销”或“重放”。

+ */ +public class InvalidTokenException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public InvalidTokenException() { + super("invalid token"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/IssuedToken.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/IssuedToken.java new file mode 100644 index 0000000..80338e7 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/IssuedToken.java @@ -0,0 +1,17 @@ +package com.mydreamjob.auth; + +import java.time.Instant; + +/** + * 一次 Token 签发的结果(S02)。 + * + *

{@code token} 为原始 JWT 原文,只在内存中短暂存在;调用方不得将其写入 + * 数据库、日志、审计或错误响应。

+ */ +public record IssuedToken( + String token, + String sessionId, + String tokenId, + Instant issuedAt, + Instant expiresAt) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java index a24a4e9..700b27a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java @@ -9,16 +9,28 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.lang.Nullable; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.time.Clock; +import java.time.Instant; import java.util.Collections; /** - * JWT 认证过滤器 + * S02 认证过滤器:只接受 Access Token。 + * + *

与修复前的关键差异:

+ *
    + *
  • 只调用 {@link JwtTokenProvider#parseAndValidateAccessToken}, + * Refresh Token、无 {@code type}、未知 {@code type}、错误 issuer/audience 一律拒绝;
  • + *
  • 验签之后还必须通过服务端会话校验(会话存在、未消费、未撤销、未过期, + * 且用户 active、注销未完成);
  • + *
  • 任何失败都不建立部分认证,并清理 SecurityContext;
  • + *
  • 数据库或会话校验异常时**失败关闭**,绝不放行;
  • + *
  • 日志只用固定安全文本,不使用异常 {@code getMessage()},不回显 Token、subject、sid、jti。
  • + *
*/ @Slf4j @Component @@ -26,26 +38,47 @@ import java.util.Collections; public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; + private final AuthSessionService authSessionService; + private final Clock clock; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = extractToken(request); - if (token != null && tokenProvider.validateToken(token)) { - try { - Long userId = tokenProvider.parseUserId(token); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); - log.debug("Authenticated user {} from JWT", userId); - } catch (Exception e) { - log.warn("JWT authentication failed for token: {}", e.getMessage()); - } + if (token != null) { + authenticate(request, token); } filterChain.doFilter(request, response); } + private void authenticate(HttpServletRequest request, String token) { + try { + Instant capturedNow = clock.instant(); + AccessTokenClaims claims = tokenProvider.parseAndValidateAccessToken(token, capturedNow); + + if (!authSessionService.isSessionActive(claims.userId(), claims.sessionId(), capturedNow)) { + SecurityContextHolder.clearContext(); + log.warn("JWT authentication rejected: session not active"); + return; + } + + // 资源权限仍以后续服务层校验为准,这里只建立“已认证用户”身份。 + // principal 保持为 userId(既有约 40 处 Long.valueOf(auth.getName()) 依赖该形态), + // 已校验的 sessionId 通过 details 传递,供 /auth/logout 使用。 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(claims.userId(), null, Collections.emptyList()); + authentication.setDetails(new SessionAuthenticationDetails(request, claims.sessionId())); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (InvalidTokenException e) { + // 固定安全文本:不区分过期/类型错误/签名错误,也不回显任何 claim 原值。 + SecurityContextHolder.clearContext(); + } catch (Exception e) { + // 会话校验或数据库异常:失败关闭。 + SecurityContextHolder.clearContext(); + log.warn("JWT authentication rejected: internal check failed"); + } + } + @Nullable private String extractToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java index 7da354d..d820a50 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java @@ -1,84 +1,207 @@ package com.mydreamjob.auth; -import io.jsonwebtoken.*; +import com.mydreamjob.config.AppJwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.Date; -import java.util.Map; +import java.util.UUID; /** - * JWT 工具类 + * S02 Access/Refresh Token 签发与校验。 + * + *

核心变更:

+ *
    + *
  • Access 与 Refresh 使用**不同签名密钥**,交叉验签必然失败;
  • + *
  • 强制校验 {@code type}、{@code iss}、{@code aud},不再用“验签通过即有效”判断用途;
  • + *
  • 新 Token 不再包含手机号;
  • + *
  • {@code iat}/{@code exp} 一律来自调用方传入的 {@code capturedNow},本类不自行取时;
  • + *
  • 过期边界固定为 {@code exp <= capturedNow} 视为过期;
  • + *
  • 对外只返回强类型 record,不暴露 JJWT {@code Claims};
  • + *
  • 异常消息固定为安全文本,不含 Token 或 claim 原值。
  • + *
+ * + *

本类**不访问数据库**。

*/ @Slf4j @Component public class JwtTokenProvider { - private final SecretKey secretKey; + private static final String CLAIM_TYPE = "type"; + private static final String CLAIM_SID = "sid"; + private static final String CLAIM_ROLE = "role"; + private static final String TYPE_ACCESS = "access"; + private static final String TYPE_REFRESH = "refresh"; + + private final SecretKey accessKey; + private final SecretKey refreshKey; private final long expirationMs; private final long refreshExpirationMs; + private final String issuer; + private final String accessAudience; + private final String refreshAudience; - public JwtTokenProvider( - @Value("${app.jwt.secret}") String secret, - @Value("${app.jwt.expiration-ms:86400000}") long expirationMs, - @Value("${app.jwt.refresh-expiration-ms:604800000}") long refreshExpirationMs) { - this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); - this.expirationMs = expirationMs; - this.refreshExpirationMs = refreshExpirationMs; + public JwtTokenProvider(AppJwtProperties properties) { + this.accessKey = Keys.hmacShaKeyFor(properties.getAccessSecret().getBytes(StandardCharsets.UTF_8)); + this.refreshKey = Keys.hmacShaKeyFor(properties.getRefreshSecret().getBytes(StandardCharsets.UTF_8)); + this.expirationMs = properties.getExpirationMs(); + this.refreshExpirationMs = properties.getRefreshExpirationMs(); + this.issuer = properties.getIssuer(); + this.accessAudience = properties.getAccessAudience(); + this.refreshAudience = properties.getRefreshAudience(); } - public String generateAccessToken(Long userId, String phone, Map claims) { - return generateToken(userId, phone, claims, expirationMs); - } + // ── 签发 ──────────────────────────────────────────────────── - public String generateRefreshToken(Long userId, String phone) { - return generateToken(userId, phone, Map.of("type", "refresh"), refreshExpirationMs); + public IssuedToken generateAccessToken(Long userId, String role, String sessionId, + String tokenId, Instant capturedNow) { + Instant expiresAt = capturedNow.plusMillis(expirationMs); + String token = Jwts.builder() + .subject(String.valueOf(userId)) + .id(tokenId) + .claim(CLAIM_TYPE, TYPE_ACCESS) + .claim(CLAIM_SID, sessionId) + .claim(CLAIM_ROLE, role) + .issuer(issuer) + .audience().add(accessAudience).and() + .issuedAt(Date.from(capturedNow)) + .expiration(Date.from(expiresAt)) + .signWith(accessKey) + .compact(); + return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt); } - private String generateToken(Long userId, String phone, Map claims, long expirationMs) { - Date now = new Date(); - Date expiry = new Date(now.getTime() + expirationMs); - - return Jwts.builder() + public IssuedToken generateRefreshToken(Long userId, String sessionId, + String tokenId, Instant capturedNow) { + Instant expiresAt = capturedNow.plusMillis(refreshExpirationMs); + String token = Jwts.builder() .subject(String.valueOf(userId)) - .claim("phone", phone) - .claims(claims) - .issuedAt(now) - .expiration(expiry) - .signWith(secretKey) + .id(tokenId) + .claim(CLAIM_TYPE, TYPE_REFRESH) + .claim(CLAIM_SID, sessionId) + .issuer(issuer) + .audience().add(refreshAudience).and() + .issuedAt(Date.from(capturedNow)) + .expiration(Date.from(expiresAt)) + .signWith(refreshKey) .compact(); + return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt); } - public Long parseUserId(String token) { - String subject = Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token) - .getPayload() - .getSubject(); - return Long.parseLong(subject); + // ── 校验 ──────────────────────────────────────────────────── + + /** + * 校验并解析 Access Token。任何失败(签名、类型、issuer、audience、subject、 + * sid、jti、role、过期)统一抛出 {@link InvalidTokenException}。 + */ + public AccessTokenClaims parseAndValidateAccessToken(String rawToken, Instant capturedNow) { + Claims claims = parse(rawToken, accessKey, TYPE_ACCESS, accessAudience, capturedNow); + Long userId = requirePositiveSubject(claims); + String sid = requireCanonicalUuid(claims.get(CLAIM_SID, String.class)); + String jti = requireCanonicalUuid(claims.getId()); + String role = claims.get(CLAIM_ROLE, String.class); + if (role == null || role.isBlank()) { + throw new InvalidTokenException(); + } + return new AccessTokenClaims(userId, sid, jti, role, + toInstant(claims.getIssuedAt()), toInstant(claims.getExpiration())); } - public boolean validateToken(String token) { + /** + * 校验并解析 Refresh Token。Refresh **不含** role,也不接受 role 参与判断。 + */ + public RefreshTokenClaims parseAndValidateRefreshToken(String rawToken, Instant capturedNow) { + Claims claims = parse(rawToken, refreshKey, TYPE_REFRESH, refreshAudience, capturedNow); + Long userId = requirePositiveSubject(claims); + String sid = requireCanonicalUuid(claims.get(CLAIM_SID, String.class)); + String jti = requireCanonicalUuid(claims.getId()); + return new RefreshTokenClaims(userId, sid, jti, + toInstant(claims.getIssuedAt()), toInstant(claims.getExpiration())); + } + + private Claims parse(String rawToken, SecretKey key, String expectedType, + String expectedAudience, Instant capturedNow) { + if (rawToken == null || rawToken.isBlank()) { + throw new InvalidTokenException(); + } + Claims claims; try { - Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); - return true; - } catch (JwtException e) { - log.debug("JWT validation failed: {}", e.getMessage()); - return false; + claims = Jwts.parser() + .verifyWith(key) + // 过期判定由下面显式完成:这里把解析时钟固定在很早的时间, + // 避免依赖 jjwt 内部边界语义,从而严格执行本文档的契约。 + .clock(() -> Date.from(Instant.EPOCH)) + .require(CLAIM_TYPE, expectedType) + .requireIssuer(issuer) + .requireAudience(expectedAudience) + .build() + .parseSignedClaims(rawToken) + .getPayload(); + } catch (JwtException | IllegalArgumentException e) { + // 固定安全文本:不回显 Token、claim 原值或异常消息。 + throw new InvalidTokenException(); } + + // 时间契约(设计第七节):iat <= capturedNow < exp。 + // + // 过期判定:exp 缺失或 exp <= capturedNow 一律拒绝。 + Date expiration = claims.getExpiration(); + if (expiration == null || !expiration.toInstant().isAfter(capturedNow)) { + throw new InvalidTokenException(); + } + + // 签发时间判定:iat 缺失或 iat > capturedNow 一律拒绝。 + // 缺 iat 的 Token 无法界定有效窗口;iat 在未来说明 Token 被伪造或时钟被绕过。 + Date issuedAt = claims.getIssuedAt(); + if (issuedAt == null || issuedAt.toInstant().isAfter(capturedNow)) { + throw new InvalidTokenException(); + } + + return claims; + } + + private Long requirePositiveSubject(Claims claims) { + String subject = claims.getSubject(); + if (subject == null || subject.isBlank()) { + throw new InvalidTokenException(); + } + long userId; + try { + userId = Long.parseLong(subject); + } catch (NumberFormatException e) { + throw new InvalidTokenException(); + } + if (userId <= 0) { + throw new InvalidTokenException(); + } + return userId; } - public boolean isTokenExpired(String token) { + private String requireCanonicalUuid(String value) { + if (value == null || value.isBlank()) { + throw new InvalidTokenException(); + } + UUID parsed; try { - return Jwts.parser().verifyWith(secretKey).build() - .parseSignedClaims(token).getPayload().getExpiration().before(new Date()); - } catch (JwtException e) { - return true; + parsed = UUID.fromString(value); + } catch (IllegalArgumentException e) { + throw new InvalidTokenException(); } + // 只接受规范小写连字符形式,避免宽松解析放行异常写法。 + if (!parsed.toString().equals(value)) { + throw new InvalidTokenException(); + } + return value; + } + + private static Instant toInstant(Date date) { + return date == null ? null : date.toInstant(); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/RefreshTokenClaims.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/RefreshTokenClaims.java new file mode 100644 index 0000000..15cff0f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/RefreshTokenClaims.java @@ -0,0 +1,16 @@ +package com.mydreamjob.auth; + +import java.time.Instant; + +/** + * 已通过完整校验的 Refresh Token 声明(S02)。 + * + *

Refresh Token 不含 role,也不含手机号。

+ */ +public record RefreshTokenClaims( + Long userId, + String sessionId, + String tokenId, + Instant issuedAt, + Instant expiresAt) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SessionAuthenticationDetails.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SessionAuthenticationDetails.java new file mode 100644 index 0000000..7924c0e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SessionAuthenticationDetails.java @@ -0,0 +1,25 @@ +package com.mydreamjob.auth; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.web.authentication.WebAuthenticationDetails; + +/** + * 携带已验证会话标识的认证详情(S02)。 + * + *

{@code sessionId} 只可能由 {@link JwtAuthenticationFilter} 在 Access Token + * 通过完整校验(签名、类型、issuer、audience、claim 与服务端会话状态)之后写入, + * 因此可作为 {@code /auth/logout} 的可信会话来源,不接受任何客户端参数。

+ */ +public class SessionAuthenticationDetails extends WebAuthenticationDetails { + + private final String sessionId; + + public SessionAuthenticationDetails(HttpServletRequest request, String sessionId) { + super(request); + this.sessionId = sessionId; + } + + public String getSessionId() { + return sessionId; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java index 4316d42..5910845 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java @@ -1,6 +1,7 @@ package com.mydreamjob.auth; import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.sms.SmsCodeSender; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -18,8 +19,10 @@ import java.util.regex.Pattern; import java.util.concurrent.TimeUnit; /** - * Mock 短信验证码服务 - * 不接入真实短信网关,验证码直接生成并存入 Redis + * 短信验证码服务。 + * + *

验证码只写入短信验证码表,并通过 {@link SmsCodeSender} 投递; + * 任何级别的日志都不得包含验证码或完整手机号。

*/ @Slf4j @Service @@ -29,6 +32,7 @@ public class SmsVerificationService { private final SmsVerificationCodeRepository codeRepository; private final RedisTemplate redisTemplate; private final AuditService auditService; + private final SmsCodeSender smsCodeSender; @Value("${app.sms.mock.code-length:6}") private int codeLength; @@ -49,7 +53,10 @@ public class SmsVerificationService { private static final Pattern CODE_PATTERN = Pattern.compile("[0-9]{6}"); /** - * 发送验证码(Mock) + * 发送验证码。 + * + *

发送动作委派给 {@link SmsCodeSender}: + * {@code app.sms.mock.enabled=false} 或未配置时使用不可用实现,安全失败并回滚本次验证码落库。

*/ @Transactional public void sendCode(String phone, String purpose, @@ -59,14 +66,14 @@ public class SmsVerificationService { // 检查锁定 String lockoutKey = LOCKOUT_KEY_PREFIX + phone; if (redisTemplate.hasKey(lockoutKey)) { - log.warn("Phone {} is locked out", phone); + log.warn("SMS send rejected: phone locked out, phone={}", maskPhone(phone)); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); } // 检查频率限制(60秒内最多1次) String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone; if (redisTemplate.hasKey(rateLimitKey)) { - log.warn("Phone {} rate limited", phone); + log.warn("SMS send rejected: rate limited, phone={}", maskPhone(phone)); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); } @@ -89,14 +96,26 @@ public class SmsVerificationService { // 设置频率限制 redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS); - // Mock: 打印验证码到日志(不发送真实短信) - log.info("MOCK SMS CODE: phone={}, purpose={}, code={}", phone, purpose, code); + // 投递验证码:实现方保证不把验证码/完整手机号写入日志。 + // 不可用实现会在此抛出 SERVICE_UNAVAILABLE,令本次事务回滚(不产生虚假的“发送成功”)。 + smsCodeSender.sendVerificationCode(phone, purpose, code); + // 审计明细同样不得记录完整手机号 auditService.log(null, "SMS_SEND", "PHONE", null, - "Mock SMS sent to " + phone + " for " + purpose, + "SMS send accepted; phone=" + maskPhone(phone) + "; purpose=" + purpose, ipAddress, userAgent, requestId); } + /** + * 手机号脱敏:仅保留前 3 位与后 4 位,用于日志与审计明细。 + */ + private String maskPhone(String phone) { + if (phone == null || phone.length() < 7) { + return "***"; + } + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + /** * 验证验证码 * 按手机号+用途查询最新一条未使用记录,原子性标记已使用。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/RefreshTokenRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/RefreshTokenRequest.java new file mode 100644 index 0000000..59cbe7d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/RefreshTokenRequest.java @@ -0,0 +1,21 @@ +package com.mydreamjob.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * S02 刷新请求(白名单 DTO)。 + * + *

只允许 {@code refreshToken} 一个字段;Token 不接受 query/path 参数传递。

+ */ +@Data +@Schema(name = "RefreshTokenRequest", description = "刷新请求") +public class RefreshTokenRequest { + + @NotBlank(message = "refreshToken is required") + @Size(max = 4096, message = "refreshToken is too long") + @Schema(description = "登录或上一次刷新返回的 Refresh Token", requiredMode = Schema.RequiredMode.REQUIRED) + private String refreshToken; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/TokenPairResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/TokenPairResponse.java new file mode 100644 index 0000000..23b3724 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/TokenPairResponse.java @@ -0,0 +1,15 @@ +package com.mydreamjob.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * S02 刷新响应(白名单 DTO)。 + * + *

只包含新 Token 对与有效期;不包含 userId、手机号、sessionId 或任何数据库字段。

+ */ +@Schema(name = "TokenPairResponse", description = "刷新后的 Token 对") +public record TokenPairResponse( + @Schema(description = "新的 Access Token") String accessToken, + @Schema(description = "新的 Refresh Token") String refreshToken, + @Schema(description = "Access Token 有效期(秒)") long expiresIn) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/MockSmsCodeSender.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/MockSmsCodeSender.java new file mode 100644 index 0000000..c2ba83d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/MockSmsCodeSender.java @@ -0,0 +1,23 @@ +package com.mydreamjob.auth.sms; + +import lombok.extern.slf4j.Slf4j; + +/** + * Mock 短信发送实现。 + * + *

由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 显式解析为 + * {@code true} 时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。

+ * + *

不接入真实短信网关,仅记录一条固定文本的发送事件。 + * 不得把验证码或完整手机号写入日志。

+ */ +@Slf4j +public class MockSmsCodeSender implements SmsCodeSender { + + @Override + public void sendVerificationCode(String phone, String purpose, String code) { + // 固定安全文本:不包含验证码、手机号、Token 或任何配置细节。 + // 本地开发如需验证码,请从 sms_verification_codes 表读取(见 docs 说明),不要重新写回日志。 + log.info("SMS verification code generated (mock sender)"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java new file mode 100644 index 0000000..41f1655 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java @@ -0,0 +1,24 @@ +package com.mydreamjob.auth.sms; + +/** + * 短信验证码发送抽象。 + * + *

由 {@code app.sms.mock.enabled} 决定加载哪一个实现: + *

    + *
  • {@code true} → {@link MockSmsCodeSender}(本地/测试环境,不调用外部网关)
  • + *
  • {@code false} 或未配置 → {@link UnavailableSmsCodeSender}(安全失败,不伪造成功)
  • + *
+ * + *

实现方禁止把验证码或完整手机号写入任何级别的日志。 + */ +public interface SmsCodeSender { + + /** + * 发送验证码。 + * + * @param phone 接收手机号(实现方不得写入日志) + * @param purpose 验证码用途 + * @param code 验证码(实现方不得写入日志) + */ + void sendVerificationCode(String phone, String purpose, String code); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java new file mode 100644 index 0000000..351a9d9 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java @@ -0,0 +1,37 @@ +package com.mydreamjob.auth.sms; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 短信发送实现装配(单 Bean 方案)。 + * + *

由本配置类始终且仅产生一个 {@link SmsCodeSender},从而彻底避免 + * “两个互斥 {@code @ConditionalOnProperty} 在空值/空白值下都不匹配 → 0 个 Bean → + * 模糊 Spring 装配错误导致启动失败”的问题。

+ * + *

取值语义(大小写不敏感,两侧空白忽略):

+ *
    + *
  • {@code true} → {@link MockSmsCodeSender}
  • + *
  • {@code false} / 缺失 / 空字符串 / 仅空白 / 其他非法值 → {@link UnavailableSmsCodeSender}
  • + *
+ * + *

安全约束:只有显式 {@code true} 才启用 Mock;非法值一律安全回退到不可用实现, + * 既不启用 Mock,也不进入发送流程。本类不输出配置原值到日志。

+ */ +@Configuration +public class SmsSenderConfiguration { + + /** 显式启用 Mock 的唯一取值。 */ + private static final String MOCK_ENABLED_VALUE = "true"; + + @Bean + public SmsCodeSender smsCodeSender( + @Value("${app.sms.mock.enabled:false}") String rawEnabled) { + if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) { + return new MockSmsCodeSender(); + } + return new UnavailableSmsCodeSender(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java new file mode 100644 index 0000000..f0e5ee0 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java @@ -0,0 +1,25 @@ +package com.mydreamjob.auth.sms; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; + +/** + * 不可用短信发送实现。 + * + *

由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 为 false、缺失、 + * 空值、仅空白或其他非法值时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。

+ * + *

当前未接入真实短信网关,因此本实现安全失败: + * 抛出既有的“服务不可用”业务异常,绝不返回虚假的“发送成功”。 + * 异常消息为固定安全文本,不含手机号、验证码或任何配置细节。

+ */ +public class UnavailableSmsCodeSender implements SmsCodeSender { + + /** 固定安全文本:不含手机号、验证码、Token、内网地址或配置项。 */ + static final String SAFE_MESSAGE = "sms sender unavailable"; + + @Override + public void sendVerificationCode(String phone, String purpose, String code) { + throw new BusinessException(ErrorCode.SERVICE_UNAVAILABLE, SAFE_MESSAGE); + } +} 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/bidirectionalmatch/MatchRuleService.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java index c25cf01..dedeb07 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java @@ -6,6 +6,7 @@ import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.security.UserRoleGuard; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +22,7 @@ import java.util.Map; @RequiredArgsConstructor public class MatchRuleService { + private final UserRoleGuard userRoleGuard; private final CompanyMatchRuleRepository companyRuleRepository; private final StudentMatchRuleRepository studentRuleRepository; private final CompanyRepository companyRepository; @@ -51,7 +53,7 @@ public class MatchRuleService { @Transactional(readOnly = true) public Map getStudentRule(Long userId) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); StudentMatchRule rule = studentRuleRepository.findByStudentUserId(userId).orElse(null); int required = rule != null ? rule.getRequiredHrScore() : StudentMatchRule.DEFAULT_REQUIRED_HR_SCORE; @@ -60,7 +62,7 @@ public class MatchRuleService { @Transactional public Map updateStudentRule(Long userId, int requiredHrScore) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); StudentMatchRule rule = studentRuleRepository.findByStudentUserId(userId) .orElseGet(() -> StudentMatchRule.builder().studentUserId(userId).build()); rule.setRequiredHrScore(requiredHrScore); @@ -94,11 +96,4 @@ public class MatchRuleService { .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); } - private void requireStudent(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isStudent = user.getRoles() != null - && user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT); - if (!isStudent) throw new BusinessException(ErrorCode.FORBIDDEN); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicy.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicy.java new file mode 100644 index 0000000..2ebcff6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicy.java @@ -0,0 +1,51 @@ +package com.mydreamjob.candidatescore; + +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +/** Single pure policy for candidate overview status aggregation. */ +public final class CandidateOverviewStatusPolicy { + + private CandidateOverviewStatusPolicy() { + } + + public static String fromComputedResults( + List results, int expectedDimensions) { + Objects.requireNonNull(results, "results"); + return evaluate(results, expectedDimensions, + result -> isComputable(result.status, result.score != null), + result -> "PENDING".equals(result.status), + "PENDING"); + } + + public static String fromSnapshots(List snapshots) { + if (snapshots == null || snapshots.isEmpty()) { + return "INSUFFICIENT_DATA"; + } + return evaluate(snapshots, snapshots.size(), + snapshot -> isComputable(snapshot.getStatus(), true), + snapshot -> "PENDING".equals(snapshot.getStatus()), + "INSUFFICIENT_DATA"); + } + + private static String evaluate(List values, int completeCount, + Predicate computable, Predicate pending, + String emptyStatus) { + if (values == null || values.isEmpty()) { + return emptyStatus; + } + long computableCount = values.stream().filter(computable).count(); + if (computableCount == completeCount) { + return "OK"; + } + if (computableCount > 0) { + return "PARTIAL"; + } + return values.stream().allMatch(pending) ? "PENDING" : "INSUFFICIENT_DATA"; + } + + private static boolean isComputable(String status, boolean valueAvailable) { + return valueAvailable && ("OK".equals(status) || "ESTIMATED".equals(status)); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardController.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardController.java index 9ec913d..dcf0a9c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardController.java +++ b/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> 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> 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))); - } - - private Long resolveUserId() { - 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(percentile, 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/candidatescore/CandidateScoringCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java index 6a82b64..b5670d7 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java @@ -5,15 +5,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; +import com.mydreamjob.candidatescore.rules.CandidateScoringRuleSet; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.preference.StudentPreference; import com.mydreamjob.preference.StudentPreferenceRepository; import com.mydreamjob.profile.StudentProfile; import com.mydreamjob.profile.StudentProfileRepository; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -39,8 +37,9 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class CandidateScoringCalculator { - public static final String RULES_VERSION = "candidate-scoring-v0.1"; - public static final BigDecimal DEFAULT_WEIGHT = new BigDecimal("12.50"); + /** R01-C:版本与默认权重来自唯一规则源 CandidateScoringRuleSet。 */ + public static final String RULES_VERSION = CandidateScoringRuleSet.VERSION; + public static final BigDecimal DEFAULT_WEIGHT = CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT; public static boolean isValidDimension(String dimension) { return DIMENSIONS.contains(dimension); @@ -71,30 +70,6 @@ public class CandidateScoringCalculator { private static final ObjectMapper MAPPER = new ObjectMapper(); - // 内置学校档次名单(v0.1 硬编码,非数据库表) - private static final Set C985 = new HashSet<>(List.of( - "北京大学", "清华大学", "中国人民大学", "北京航空航天大学", "北京理工大学", - "中国农业大学", "北京师范大学", "中央民族大学", "南开大学", "天津大学", - "大连理工大学", "吉林大学", "哈尔滨工业大学", "复旦大学", "同济大学", - "上海交通大学", "华东师范大学", "南京大学", "东南大学", "浙江大学", - "中国科学技术大学", "厦门大学", "山东大学", "中国海洋大学", "武汉大学", - "华中科技大学", "湖南大学", "中南大学", "中山大学", "华南理工大学", - "四川大学", "重庆大学", "电子科技大学", "西安交通大学", "西北工业大学", - "兰州大学", "东北大学", "西北农林科技大学")); - private static final Set C211 = new HashSet<>(List.of( - "北京交通大学", "北京工业大学", "北京科技大学", "北京化工大学", "北京邮电大学", - "北京林业大学", "北京中医药大学", "北京外国语大学", "中国传媒大学", "中央财经大学", - "对外经济贸易大学", "中国政法大学", "华北电力大学", "天津医科大学", "河北工业大学", - "太原理工大学", "内蒙古大学", "辽宁大学", "大连海事大学", "延边大学", - "哈尔滨工程大学", "东北农业大学", "东北林业大学", "东华大学", "上海大学", - "上海财经大学", "上海外国语大学", "苏州大学", "南京航空航天大学", "南京理工大学", - "中国矿业大学", "河海大学", "江南大学", "南京农业大学", "中国药科大学", - "南京师范大学", "安徽大学", "合肥工业大学", "福州大学", "南昌大学", - "郑州大学", "武汉理工大学", "华中农业大学", "华中师范大学", "中南财经政法大学", - "湖南师范大学", "暨南大学", "华南师范大学", "广西大学", "海南大学", - "西南交通大学", "西南财经大学", "贵州大学", "云南大学", "西北大学", - "西安电子科技大学", "长安大学", "新疆大学", "石河子大学", "宁夏大学", - "青海大学", "北京科技大学")); private final StudentProfileRepository profileRepository; private final StudentPreferenceRepository preferenceRepository; @@ -102,6 +77,8 @@ public class CandidateScoringCalculator { private final JdbcTemplate jdbcTemplate; private final AbilityAssessmentScoreSource abilityScoreSource; private final AbilityScoreMapper abilityScoreMapper; + /** R01-D4-1:唯一快照 Writer(普通物化幂等 / Governance 强制版本双模式)。 */ + private final com.mydreamjob.candidatescore.refresh.CandidateScoringSnapshotWriter snapshotWriter; /** 可测试的时间源;未提供 Spring Clock 时保持系统时区语义。 */ private Clock clock = Clock.systemDefaultZone(); @@ -109,9 +86,6 @@ public class CandidateScoringCalculator { @Value("${scoring.ability-assessment.enabled:false}") private boolean abilityAssessmentEnabled; - @PersistenceContext - private EntityManager entityManager; - @Autowired(required = false) public void setClock(Clock clock) { if (clock != null) this.clock = clock; @@ -164,7 +138,9 @@ public class CandidateScoringCalculator { DimensionResult r = computeAll(studentUserId, capturedNow).stream() .filter(x -> x.dimension.equals(dimension)).findFirst() .orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension")); - upsertSnapshots(studentUserId, List.of(r), period, capturedNow); + // R01-D4-1:Governance(申诉决定/回滚)强制版本推进路径 —— 与普通物化(值变化才 +1)显式区分, + // 保持 afterVersion = beforeVersion + 1 与 snapshot_history 审计语义不变。 + snapshotWriter.upsertForcingVersion(studentUserId, r, period, capturedNow, currentRulesVersion()); return r; } @@ -225,50 +201,50 @@ public class CandidateScoringCalculator { if (profile == null || isBlank(profile.getEducation())) { r.status = "INSUFFICIENT_DATA"; r.summary = "未填写学历信息"; - addContribution(r, "EDUCATION_MISSING", "MISSING", null, new BigDecimal("100"), "教育资料", + addContribution(r, "EDUCATION_MISSING", "MISSING", null, CandidateScoringRuleSet.EDUCATION_MISSING_SCORE, "教育资料", "学历资料未填写,当前维度不计算"); return r; } BigDecimal eduLevel = switch (profile.getEducation()) { - case "博士" -> new BigDecimal("100"); - case "硕士" -> new BigDecimal("85"); - case "本科" -> new BigDecimal("70"); - case "大专" -> new BigDecimal("55"); - default -> new BigDecimal("50"); + case "博士" -> CandidateScoringRuleSet.EDUCATION_LEVEL_DOCTOR; + case "硕士" -> CandidateScoringRuleSet.EDUCATION_LEVEL_MASTER; + case "本科" -> CandidateScoringRuleSet.EDUCATION_LEVEL_BACHELOR; + case "大专" -> CandidateScoringRuleSet.EDUCATION_LEVEL_JUNIOR_COLLEGE; + default -> CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN; }; String school = profile.getSchool(); boolean estimated = false; BigDecimal schoolTier; if (isBlank(school)) { - schoolTier = new BigDecimal("50"); + schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_MISSING; estimated = true; r.reasons.add("学校未填写,按兜底档位估算"); - } else if (C985.contains(school)) { - schoolTier = new BigDecimal("85"); - } else if (C211.contains(school)) { - schoolTier = new BigDecimal("75"); + } else if (CandidateScoringRuleSet.is985(school)) { + schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_985; + } else if (CandidateScoringRuleSet.is211(school)) { + schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_211; } else if (school.endsWith("大学") || school.endsWith("学院")) { - schoolTier = new BigDecimal("65"); + schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_OTHER; } else { - schoolTier = new BigDecimal("50"); + schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_MISSING; estimated = true; r.reasons.add("学校不在内置名单,按兜底档位估算"); } - r.score = eduLevel.multiply(new BigDecimal("0.55")) - .add(schoolTier.multiply(new BigDecimal("0.45"))) + r.score = eduLevel.multiply(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT) + .add(schoolTier.multiply(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT)) .setScale(2, RoundingMode.HALF_UP); - addContribution(r, "EDUCATION_LEVEL", "APPLIED", eduLevel.multiply(new BigDecimal("0.55")), new BigDecimal("55"), + addContribution(r, "EDUCATION_LEVEL", "APPLIED", eduLevel.multiply(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT), CandidateScoringRuleSet.EDUCATION_LEVEL_FULL_SCORE, "学历层次", profile.getEducation(), "学历字段按固定映射形成贡献"); if (isBlank(school)) { addContribution(r, "EDUCATION_MISSING", "MISSING", null, null, "教育资料缺失", "学校资料未填写,学校档位使用估算兜底"); } - addContribution(r, "EDUCATION_SCHOOL_TIER", "APPLIED", schoolTier.multiply(new BigDecimal("0.45")), new BigDecimal("45"), + addContribution(r, "EDUCATION_SCHOOL_TIER", "APPLIED", schoolTier.multiply(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT), CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_FULL_SCORE, "学校分类", isBlank(school) ? "未填写" : "已按学校分类规则处理", "学校分类按固定规则形成贡献,不能代表能力"); r.status = estimated ? "ESTIMATED" : "OK"; r.summary = "学历层次与学校档次的加权综合"; - r.subScores.add(sub("学历层次", eduLevel, new BigDecimal("55"), profile.getEducation())); - r.subScores.add(sub("学校档次", schoolTier, new BigDecimal("45"), isBlank(school) ? "未填写" : school)); + r.subScores.add(sub("学历层次", eduLevel, CandidateScoringRuleSet.EDUCATION_LEVEL_FULL_SCORE, profile.getEducation())); + r.subScores.add(sub("学校档次", schoolTier, CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_FULL_SCORE, isBlank(school) ? "未填写" : school)); r.dataSources.add(ds("个人教育资料", profile.getUpdatedAt(), "学历与学校分类信息")); return r; } @@ -574,42 +550,20 @@ public class CandidateScoringCalculator { return r; } - // ── 快照批量 upsert(原生 MySQL)──────────────────────────── + // ── 快照写入(R01-D4-1:全部委托唯一 Writer)──────────────────── public void upsertSnapshots(Long studentUserId, List results, String period) { upsertSnapshots(studentUserId, results, period, LocalDateTime.now(clock)); } + /** + * 普通物化入口(含 GET 兜底):委托 {@code CandidateScoringSnapshotWriter#upsertIfChanged}。 + * 值完全相同时不增 version/updated_at;业务值或 rulesVersion 变化才 version+1 → + * 重复读取不再产生无意义的版本噪声,为 D4-2 GET 停写做准备。 + */ public void upsertSnapshots(Long studentUserId, List results, String period, LocalDateTime capturedNow) { - if (results.isEmpty()) return; - StringBuilder sql = new StringBuilder( - "INSERT INTO candidate_dimension_scores (student_user_id, dimension, period, score, status, " + - "sub_scores_json, weight, rules_version, calculated_at, version, created_at, updated_at) VALUES "); - List params = new ArrayList<>(); - for (int i = 0; i < results.size(); i++) { - if (i > 0) sql.append(","); - sql.append("(?,?,?,?,?,?,?,?,?,0,NOW(),NOW())"); - DimensionResult r = results.get(i); - params.add(studentUserId); - params.add(r.dimension); - params.add(period); - params.add(r.score); - params.add(r.status); - params.add(subScoresPayload(r)); - params.add(DEFAULT_WEIGHT); - params.add(currentRulesVersion()); - params.add(capturedNow); - } - sql.append(" ON DUPLICATE KEY UPDATE score=VALUES(score), status=VALUES(status), " + - "sub_scores_json=VALUES(sub_scores_json), weight=VALUES(weight), " + - "rules_version=VALUES(rules_version), calculated_at=VALUES(calculated_at), " + - "version=version+1, updated_at=NOW()"); - Query q = entityManager.createNativeQuery(sql.toString()); - for (int i = 0; i < params.size(); i++) { - q.setParameter(i + 1, params.get(i)); - } - q.executeUpdate(); + snapshotWriter.upsertIfChanged(studentUserId, results, period, capturedNow, currentRulesVersion()); } // ── 工具 ──────────────────────────────────────────────────── @@ -647,42 +601,6 @@ public class CandidateScoringCalculator { return !isBlank(profile.getRealName()) && !isBlank(profile.getEducation()) && !isBlank(profile.getSchool()); } - private String toJson(List subs) { - if (subs == null || subs.isEmpty()) return null; - try { - return MAPPER.writeValueAsString(subs); - } catch (Exception e) { - return null; - } - } - - /** - * ABILITY 行的 sub_scores_json 保存最小溯源 JSON(§6.1),其他七维保持既有数组格式。 - * 溯源 JSON 仅供内部解释/重算,任何 HTTP 响应都不得返回其原文或 sourceResultId。 - */ - private String subScoresPayload(DimensionResult r) { - if ("ABILITY".equals(r.dimension) && r.abilitySnapshotTrace != null) { - try { - Map m = new LinkedHashMap<>(); - m.put("source", "V36_ABILITY_ASSESSMENT"); - m.put("sourceResultId", r.abilitySnapshotTrace.resultId()); - m.put("sourceCompletedAt", r.abilitySnapshotTrace.completedAt() == null - ? null : r.abilitySnapshotTrace.completedAt().toString()); - m.put("validUntil", r.abilitySnapshotTrace.validUntil() == null - ? null : r.abilitySnapshotTrace.validUntil().toString()); - m.put("questionSetVersion", r.abilitySnapshotTrace.questionSetVersion()); - m.put("assessmentRulesVersion", r.abilitySnapshotTrace.assessmentRulesVersion()); - m.put("mappingRulesVersion", AbilityScoreMapper.MAPPING_RULES_VERSION); - m.put("weights", r.abilitySnapshotTrace.weights()); - m.put("dimensionScores", r.abilitySnapshotTrace.dimensionScores()); - return MAPPER.writeValueAsString(m); - } catch (Exception e) { - return null; - } - } - return toJson(r.subScores); - } - private BigDecimal cap(BigDecimal v) { return v.max(ZERO).min(HUNDRED).setScale(2, RoundingMode.HALF_UP); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java index 2e37082..fbc0952 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java +++ b/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> 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> 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> 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> 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))); - } - - private Long resolveUserId() { - 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"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java index d4e99c9..fd5776d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java @@ -49,24 +49,22 @@ public class CandidateScoringEngineService { // ── 综合评分卡(学生自查)──────────────────────────────────── - @Transactional + @Transactional(readOnly = true) public CandidateScoringOverviewResponse getOverview(Long studentUserId) { LocalDateTime capturedNow = LocalDateTime.now(clock); List results = calculator.computeAll(studentUserId, capturedNow); - calculator.upsertSnapshots(studentUserId, results, yearMonth(capturedNow.toLocalDate()), capturedNow); return buildOverview(studentUserId, results, capturedNow); } // ── 单维度明细 ─────────────────────────────────────────────── - @Transactional + @Transactional(readOnly = true) public DimensionScoreDetailResponse getDimensionDetail(Long studentUserId, String dimension) { LocalDateTime capturedNow = LocalDateTime.now(clock); List results = calculator.computeAll(studentUserId, capturedNow); CandidateScoringCalculator.DimensionResult r = results.stream() .filter(x -> x.dimension.equals(dimension)) .findFirst().orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension")); - calculator.upsertSnapshots(studentUserId, List.of(r), yearMonth(capturedNow.toLocalDate()), capturedNow); return toDetail(r, capturedNow); } @@ -105,7 +103,7 @@ public class CandidateScoringEngineService { // ── 企业只读评分卡 ─────────────────────────────────────────── - @Transactional + @Transactional(readOnly = true) public CompanyCandidateScoringResponse getCompanyScoring(Long companyUserId, Long applicationId) { Company company = companyRepository.findByOwnerUserId(companyUserId) .orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN)); @@ -117,13 +115,12 @@ public class CandidateScoringEngineService { } LocalDateTime capturedNow = LocalDateTime.now(clock); List results = calculator.computeAll(app.getStudentUserId(), capturedNow); - calculator.upsertSnapshots(app.getStudentUserId(), results, yearMonth(capturedNow.toLocalDate()), capturedNow); List computable = results.stream() .filter(r -> ("OK".equals(r.status) || "ESTIMATED".equals(r.status)) && r.score != null) .toList(); BigDecimal overall = computable.isEmpty() ? null : mean(computable.stream().map(r -> r.score).toList()); - String status = overviewStatus(results); + String status = CandidateOverviewStatusPolicy.fromComputedResults(results, TOTAL_DIMENSIONS); List dims = results.stream() .map(r -> CompanyCandidateScoringResponse.CompanyDimensionItem.builder() .dimension(r.dimension).score(r.score).status(r.status).build()) @@ -160,7 +157,9 @@ public class CandidateScoringEngineService { } } return CandidateScoringOverviewResponse.builder() - .candidateId(studentUserId).status(overviewStatus(results)).overallScore(overall) + .candidateId(studentUserId) + .status(CandidateOverviewStatusPolicy.fromComputedResults(results, TOTAL_DIMENSIONS)) + .overallScore(overall) .weights(weights).dimensions(items).rulesVersion(calculator.currentRulesVersion()) .generatedAt(capturedNow.atZone(clock.getZone()).toInstant()) .explanationVersion(com.mydreamjob.candidatescore.explanation.CandidateScoringExplanationCatalog.VERSION) @@ -177,15 +176,6 @@ public class CandidateScoringEngineService { r, calculator.currentRulesVersion(), capturedNow); } - private String overviewStatus(List results) { - long computable = results.stream() - .filter(x -> ("OK".equals(x.status) || "ESTIMATED".equals(x.status)) && x.score != null).count(); - if (computable == TOTAL_DIMENSIONS) return "OK"; - if (computable > 0) return "PARTIAL"; - boolean allPending = results.stream().allMatch(x -> "PENDING".equals(x.status)); - return allPending ? "PENDING" : "INSUFFICIENT_DATA"; - } - private BigDecimal mean(List scores) { BigDecimal sum = scores.stream().reduce(ZERO, BigDecimal::add); return sum.divide(BigDecimal.valueOf(scores.size()), 2, RoundingMode.HALF_UP); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculator.java new file mode 100644 index 0000000..02325f7 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculator.java @@ -0,0 +1,45 @@ +package com.mydreamjob.candidatescore.dashboard; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +/** Pure O(n log n) percentile calculator preserving the dashboard's historical lower-rank formula. */ +public final class CandidatePercentileCalculator { + + private CandidatePercentileCalculator() { + } + + public static List calculate(List scores) { + Objects.requireNonNull(scores, "scores"); + List scored = scores.stream().filter(Objects::nonNull).sorted().toList(); + if (scored.size() < 2) { + return scores.stream().map(ignored -> (BigDecimal) null).toList(); + } + + Map lowerCountByScore = new TreeMap<>(); + for (int index = 0; index < scored.size(); index++) { + BigDecimal score = scored.get(index); + lowerCountByScore.putIfAbsent(score, (long) index); + } + + BigDecimal denominator = BigDecimal.valueOf(scored.size() - 1L); + List result = new ArrayList<>(scores.size()); + for (BigDecimal score : scores) { + if (score == null) { + result.add(null); + continue; + } + result.add(BigDecimal.valueOf(lowerCountByScore.get(score)) + .divide(denominator, 10, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .setScale(2, RoundingMode.HALF_UP)); + } + return Collections.unmodifiableList(result); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java index 79f1ecf..1f9df3c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java +++ b/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> 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> 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> 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> 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> getAdminOverview(HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - dashboardService.getAdminOverview(userId), getRequestId(request))); - } - - private Long resolveUserId() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); + dashboardService.getAdminOverview(userId), requestIdAccessor.get(request))); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java index b45d78d..bc2c28e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java @@ -44,6 +44,7 @@ public class CompanyScoringDashboardService { private static final String REVIEW_FILTER = "jr.status='PUBLISHED' AND jr.moderation_status IN ('AUTO_APPROVED','APPROVED')"; + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final CompanyRepository companyRepository; private final ApplicationRepository applicationRepository; private final JobService jobService; @@ -276,7 +277,7 @@ public class CompanyScoringDashboardService { @Transactional(readOnly = true) public AdminDashboardOverviewResponse getAdminOverview(Long adminUserId) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); Integer totalCompanies = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM companies", Integer.class); Integer totalJobs = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM jobs", Integer.class); Integer totalCandidates = jdbcTemplate.queryForObject( @@ -423,19 +424,10 @@ public class CompanyScoringDashboardService { } private void computePercentiles(List candidates) { - List scored = candidates.stream() - .map(c -> c.overallScore).filter(Objects::nonNull).collect(Collectors.toList()); - if (scored.size() < 2) { - candidates.forEach(c -> c.percentile = null); - return; - } - for (CandidateWithScore c : candidates) { - if (c.overallScore == null) { c.percentile = null; continue; } - long lower = scored.stream().filter(s -> s.compareTo(c.overallScore) < 0).count(); - c.percentile = BigDecimal.valueOf(lower) - .divide(BigDecimal.valueOf(scored.size() - 1), 10, RoundingMode.HALF_UP) - .multiply(BigDecimal.valueOf(100)) - .setScale(2, RoundingMode.HALF_UP); + List percentiles = CandidatePercentileCalculator.calculate( + candidates.stream().map(c -> c.overallScore).toList()); + for (int index = 0; index < candidates.size(); index++) { + candidates.get(index).percentile = percentiles.get(index); } } @@ -461,15 +453,6 @@ public class CompanyScoringDashboardService { return companyRepository.findByOwnerUserId(userId) .orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN)); } - - private void assertAdminRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - if (user.getRoles().stream().noneMatch(r -> r == User.Role.ADMIN)) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } - } - private void validatePage(int page, int size) { if (page < 0 || size < 1 || size > 100) { throw new BusinessException(ErrorCode.BAD_REQUEST); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java index 570362f..d3c2f17 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java @@ -9,4 +9,6 @@ import java.util.List; public interface CandidateScoringAppealHistoryRepository extends JpaRepository { List findByAppealIdOrderByCreatedAtAsc(Long appealId); + + List findByAppealIdInOrderByAppealIdAscCreatedAtAsc(List appealIds); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java index 722bc22..2d99f29 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java @@ -7,8 +7,8 @@ import com.mydreamjob.candidatescore.governance.dto.AppealResponse; import com.mydreamjob.candidatescore.governance.dto.CreateAppealRequest; import com.mydreamjob.candidatescore.governance.dto.SnapshotHistoryItem; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -16,8 +16,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.HashMap; @@ -33,14 +31,16 @@ import java.util.Map; public class CandidateScoringGovernanceController { private final CandidateScoringGovernanceService governanceService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "提交评分申诉", description = "仅 STUDENT 本人;同一学生+维度+周期 PENDING/APPROVED 唯一") @PostMapping("/profile/me/candidate-scoring/appeals") public ResponseEntity> createAppeal( @Valid @RequestBody CreateAppealRequest req, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); AppealResponse data = governanceService.createAppeal(userId, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "我的申诉列表", description = "仅 STUDENT 本人") @@ -50,27 +50,27 @@ public class CandidateScoringGovernanceController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); Page result = governanceService.listMyAppeals(userId, status, page, size); - return ResponseEntity.ok(ApiResponse.ok(pageView(result), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(pageView(result), requestIdAccessor.get(request))); } @Operation(summary = "申诉详情", description = "本人或 ADMIN") @GetMapping("/profile/me/candidate-scoring/appeals/{appealId}") public ResponseEntity> getAppeal( @PathVariable Long appealId, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); AppealResponse data = governanceService.getAppeal(userId, appealId); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "撤销申诉", description = "仅本人;仅 PENDING 可撤销") @PostMapping("/profile/me/candidate-scoring/appeals/{appealId}/cancel") public ResponseEntity> cancelAppeal( @PathVariable Long appealId, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); AppealResponse data = governanceService.cancelAppeal(userId, appealId, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "全部申诉列表", description = "仅 ADMIN") @@ -80,9 +80,9 @@ public class CandidateScoringGovernanceController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); Page result = governanceService.listAllAppeals(userId, status, page, size); - return ResponseEntity.ok(ApiResponse.ok(pageView(result), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(pageView(result), requestIdAccessor.get(request))); } @Operation(summary = "审核决定", description = "仅 ADMIN;APPROVE/REJECT,REJECT 必须填 reason") @@ -90,27 +90,27 @@ public class CandidateScoringGovernanceController { public ResponseEntity> decideAppeal( @PathVariable Long appealId, @Valid @RequestBody AppealDecisionRequest req, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); AppealResponse data = governanceService.decideAppeal(userId, appealId, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "执行回滚", description = "仅 ADMIN;记录前态+确定性重算+新版本;年 3 次上限") @PostMapping("/admin/candidate-scoring/appeals/{appealId}/rollback") public ResponseEntity> rollbackAppeal( @PathVariable Long appealId, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); AppealResponse data = governanceService.rollbackAppeal(userId, appealId, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "申诉历史", description = "本人(脱敏,无 actorUserId)或 ADMIN(全量);此路径的本人放行先于 ADMIN 通用规则") @GetMapping("/admin/candidate-scoring/appeals/{appealId}/history") public ResponseEntity>> getAppealHistory( @PathVariable Long appealId, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); List data = governanceService.getAppealHistory(userId, appealId); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "快照变更审计", description = "仅 ADMIN;按学生+维度+周期查看回滚前后完整变化") @@ -120,10 +120,10 @@ public class CandidateScoringGovernanceController { @PathVariable String dimension, @PathVariable String period, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); List data = governanceService.getSnapshotHistory( userId, candidateUserId, dimension, period); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } // ── 辅助 ──────────────────────────────────────────────────── @@ -138,15 +138,4 @@ public class CandidateScoringGovernanceController { return data; } - private Long resolveUserId() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java index 4d1ebdc..627e63e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java @@ -41,6 +41,7 @@ public class CandidateScoringGovernanceService { public static final int MAX_ROLLBACK_PER_YEAR = 3; private static final Pattern PERIOD_PATTERN = Pattern.compile("^\\d{4}-(0[1-9]|1[0-2])$"); + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final CandidateScoringAppealRepository appealRepository; private final CandidateScoringAppealHistoryRepository appealHistoryRepository; private final CandidateScoringSnapshotHistoryRepository snapshotHistoryRepository; @@ -62,23 +63,6 @@ public class CandidateScoringGovernanceService { private EntityManager entityManager; // ── 角色校验 ──────────────────────────────────────────────── - - private void assertStudentRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - if (user.getRoles().stream().noneMatch(r -> r == User.Role.STUDENT)) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } - } - - private void assertAdminRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - if (user.getRoles().stream().noneMatch(r -> r == User.Role.ADMIN)) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } - } - private boolean isAdmin(Long userId) { return userRepository.findById(userId) .map(u -> u.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN)) @@ -89,7 +73,7 @@ public class CandidateScoringGovernanceService { @Transactional public AppealResponse createAppeal(Long studentUserId, CreateAppealRequest req, AuditContext ctx) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); if (!CandidateScoringCalculator.isValidDimension(req.getDimension())) { throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension: " + req.getDimension()); } @@ -158,7 +142,7 @@ public class CandidateScoringGovernanceService { // ── 我的申诉列表 ──────────────────────────────────────────── public Page listMyAppeals(Long studentUserId, String status, int page, int size) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); validatePage(page, size); PageRequest pr = PageRequest.of(page, size); Page p = (status != null && !status.isBlank()) @@ -182,7 +166,7 @@ public class CandidateScoringGovernanceService { @Transactional public AppealResponse cancelAppeal(Long studentUserId, Long appealId, AuditContext ctx) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); CandidateScoringAppeal appeal = requireAppeal(appealId); if (!appeal.getCandidateUserId().equals(studentUserId)) { throw new BusinessException(ErrorCode.FORBIDDEN); @@ -204,7 +188,7 @@ public class CandidateScoringGovernanceService { // ── 全部申诉列表(ADMIN) ─────────────────────────────────── public Page listAllAppeals(Long adminUserId, String status, int page, int size) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); validatePage(page, size); PageRequest pr = PageRequest.of(page, size); Page p = (status != null && !status.isBlank()) @@ -217,7 +201,7 @@ public class CandidateScoringGovernanceService { @Transactional public AppealResponse decideAppeal(Long adminUserId, Long appealId, AppealDecisionRequest req, AuditContext ctx) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); CandidateScoringAppeal appeal = requireAppeal(appealId); String target = req.getDecision(); if (!"APPROVE".equals(target) && !"REJECT".equals(target)) { @@ -261,7 +245,7 @@ public class CandidateScoringGovernanceService { @Transactional public AppealResponse rollbackAppeal(Long adminUserId, Long appealId, AuditContext ctx) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); CandidateScoringAppeal appeal = requireAppeal(appealId); if (CandidateScoringAppeal.STATUS_ROLLED_BACK.equals(appeal.getStatus())) { return toView(appeal, true); // 幂等,不重复写历史 @@ -343,7 +327,7 @@ public class CandidateScoringGovernanceService { public List getSnapshotHistory(Long adminUserId, Long candidateUserId, String dimension, String period) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); return snapshotHistoryRepository .findByCandidateUserIdAndDimensionAndPeriodOrderByCreatedAtAsc(candidateUserId, dimension, period) .stream().map(s -> SnapshotHistoryItem.builder() diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshCompensationScanner.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshCompensationScanner.java new file mode 100644 index 0000000..5e38be4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshCompensationScanner.java @@ -0,0 +1,101 @@ +package com.mydreamjob.candidatescore.refresh; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; + +/** + * R01-D4-1 补偿扫描(基础版)。 + * + *

仅登记刷新任务,不直接写快照:分页(LIMIT 固定预算)扫描近期有评分输入活动的学生, + * 统一调用 {@link CandidateScoringRefreshRequestService#requestRefresh}(任务唯一键自动合并)。 + * 用于覆盖无业务写入但评分自然变化的时间窗口(近 90 天投递窗口、近 30 天审计窗口、 + * 能力测评有效期、rulesVersion 切换),由定时调度触发。

+ * + *

查询预算:本扫描 = 1 次聚合查询 + ≤limit 次登记 upsert;不逐用户 N+1。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CandidateScoringRefreshCompensationScanner { + + static final String ACTIVE_STUDENTS_SCAN = "ACTIVE_STUDENTS"; + + private final JdbcTemplate jdbcTemplate; + private final CandidateScoringRefreshRequestService requestService; + private Clock clock = Clock.systemDefaultZone(); + + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setClock(Clock clock) { + if (clock != null) this.clock = clock; + } + + /** + * 返回本轮登记的学生数。游标与任务登记在同一事务中提交:失败时二者一起回滚, + * 多实例通过 checkpoint 行的 FOR UPDATE 串行推进,同一轮最多处理 {@code limit} 人。 + */ + @Transactional + public int scanDueStudents(int limit) { + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime appsSince = now.minusDays(90); + LocalDateTime auditSince = now.minusDays(30); + int budget = Math.max(1, Math.min(limit, 500)); + ensureCheckpoint(); + long cursor = lockCursor(); + List ids = findEligibleAfter(cursor, appsSince, auditSince, budget); + if (ids.isEmpty() && cursor > 0L) { + cursor = 0L; + ids = findEligibleAfter(cursor, appsSince, auditSince, budget); + } + for (Long studentId : ids) { + requestService.requestRefresh(studentId, now); + } + long nextCursor = ids.size() < budget || ids.isEmpty() ? 0L : ids.get(ids.size() - 1); + jdbcTemplate.update(""" + UPDATE candidate_scoring_refresh_scan_checkpoints + SET last_user_id=?, version=version+1, updated_at=CURRENT_TIMESTAMP + WHERE scan_key=? + """, nextCursor, ACTIVE_STUDENTS_SCAN); + log.debug("compensation scan registered {} students (budget {}, next cursor {})", + ids.size(), budget, nextCursor); + return ids.size(); + } + + private void ensureCheckpoint() { + jdbcTemplate.update(""" + INSERT INTO candidate_scoring_refresh_scan_checkpoints + (scan_key, last_user_id, version, updated_at) + VALUES (?, 0, 0, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE scan_key=scan_key + """, ACTIVE_STUDENTS_SCAN); + } + + private long lockCursor() { + Long cursor = jdbcTemplate.queryForObject(""" + SELECT last_user_id FROM candidate_scoring_refresh_scan_checkpoints + WHERE scan_key=? FOR UPDATE + """, Long.class, ACTIVE_STUDENTS_SCAN); + return cursor == null ? 0L : cursor; + } + + private List findEligibleAfter(long cursor, LocalDateTime appsSince, + LocalDateTime auditSince, int budget) { + return jdbcTemplate.query(""" + SELECT DISTINCT u.id + FROM users u + WHERE u.id > ? AND u.role = 'STUDENT' AND u.is_active = 1 + AND (EXISTS (SELECT 1 FROM applications a + WHERE a.student_user_id = u.id AND a.created_at >= ?) + OR EXISTS (SELECT 1 FROM audit_logs al + WHERE al.user_id = u.id AND al.created_at >= ?)) + ORDER BY u.id + LIMIT ? + """, (rs, i) -> rs.getLong(1), cursor, appsSince, auditSince, budget); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshRequestService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshRequestService.java new file mode 100644 index 0000000..5d3d783 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshRequestService.java @@ -0,0 +1,81 @@ +package com.mydreamjob.candidatescore.refresh; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * R01-D4-1 评分快照刷新任务登记组件。 + * + *

职责仅限"登记":把某个学生当前周期的物化请求合并进任务表。 + * 使用原生参数化 upsert 维护 {@code uk_refresh_task_student_period} 唯一性:

+ *
    + *
  • 同学生同周期重复登记:不新增行,更新 {@code requested_at};
  • + *
  • 任务 COMPLETED/FAILED:重新打开为 PENDING,attempts 归零;
  • + *
  • 任务 PROCESSING:保留 PROCESSING(不覆盖),推进 requested_at, + * Worker 完成时若 {@code requested_at} 晚于 {@code processing_started_at} 则回到 PENDING;
  • + *
  • 任务 RETRY:回到 PENDING(保留 attempts,不重复计分重试次数)。
  • + *
+ * + *

登记会加入调用方现有业务事务(默认 REQUIRED):源业务提交失败时,登记一并回滚; + * 登记本身不触发任何线程内评分计算、不发送 RabbitMQ、不写成功审计、不记录敏感值。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CandidateScoringRefreshRequestService { + + private final JdbcTemplate jdbcTemplate; + + /** + * 登记当前周期的一次刷新(period = capturedNow 所在 yyyy-MM)。 + * + * @param studentUserId 学生用户 ID;null 直接忽略 + * @param capturedNow 请求捕获时间(本方法内部只读取一次) + */ + @Transactional(propagation = Propagation.REQUIRED) + public void requestRefresh(Long studentUserId, LocalDateTime capturedNow) { + if (studentUserId == null || capturedNow == null) { + return; + } + String period = String.format("%04d-%02d", + capturedNow.getYear(), capturedNow.getMonthValue()); + jdbcTemplate.update(""" + INSERT INTO candidate_scoring_refresh_tasks + (student_user_id, target_period, status, requested_at, attempts, version, created_at, updated_at) + VALUES (?, ?, 'PENDING', ?, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE + requested_at = GREATEST(requested_at, VALUES(requested_at)), + attempts = CASE + WHEN status IN ('COMPLETED', 'FAILED') THEN 0 + ELSE attempts + END, + last_error_code = CASE + WHEN status = 'PROCESSING' THEN last_error_code + ELSE NULL + END, + next_attempt_at = CASE + WHEN status = 'PROCESSING' THEN next_attempt_at + ELSE NULL + END, + status = CASE + WHEN status = 'PROCESSING' THEN status + ELSE 'PENDING' + END, + version = version + 1, + updated_at = CURRENT_TIMESTAMP + """, + studentUserId, period, capturedNow); + } + + public String periodOf(LocalDateTime capturedNow) { + LocalDate d = capturedNow.toLocalDate(); + return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshScheduler.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshScheduler.java new file mode 100644 index 0000000..889b8d9 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshScheduler.java @@ -0,0 +1,77 @@ +package com.mydreamjob.candidatescore.refresh; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.LocalDateTime; + +/** + * R01-D4-1 定时调度:Worker 领取刷新 + 补偿扫描。 + * + *

默认关闭({@code app.scoring.refresh.enabled=false}),生产启用时负责周期驱动; + * 测试一律直接调用 Worker/Scanner,不依赖真实等待。调度不发送 RabbitMQ、不写通知。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CandidateScoringRefreshScheduler { + + private final CandidateScoringRefreshWorker worker; + private final CandidateScoringRefreshCompensationScanner compensationScanner; + private Clock clock = Clock.systemDefaultZone(); + + @Value("${app.scoring.refresh.enabled:false}") + private boolean enabled; + + @Value("${app.scoring.refresh.compensation-enabled:false}") + private boolean compensationEnabled; + + @Value("${app.scoring.refresh.batch-size:20}") + private int batchSize; + + @Value("${app.scoring.refresh.compensation-limit:100}") + private int compensationLimit; + + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setClock(Clock clock) { + if (clock != null) this.clock = clock; + } + + @Scheduled(fixedDelayString = "${app.scoring.refresh.worker-interval-ms:60000}") + public void workerTick() { + if (!enabled) { + return; + } + try { + int processed = worker.processDue(batchSize); + if (processed > 0) { + log.debug("scoring refresh worker processed {} tasks", processed); + } + } catch (Exception e) { + // 调度兜底:单轮失败只告警,不影响后续轮次;具体任务失败在 Worker 内已分类登记。 + log.warn("scoring refresh worker tick failed: {}", e.getClass().getSimpleName()); + } + } + + @Scheduled(fixedDelayString = "${app.scoring.refresh.compensation-interval-ms:3600000}") + public void compensationTick() { + if (!enabled || !compensationEnabled) { + return; + } + try { + int registered = compensationScanner.scanDueStudents(compensationLimit); + log.debug("compensation scan registered {} students", registered); + } catch (Exception e) { + log.warn("compensation scan tick failed: {}", e.getClass().getSimpleName()); + } + } + + /** 测试用:暴露当前时钟时刻(无副作用)。 */ + LocalDateTime nowForTest() { + return LocalDateTime.now(clock); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTask.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTask.java new file mode 100644 index 0000000..7537a5d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTask.java @@ -0,0 +1,102 @@ +package com.mydreamjob.candidatescore.refresh; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * R01-D4-1 评分快照刷新任务(持久化物化登记)。 + * + *

同一学生同一周期只有一条任务({@code uk_refresh_task_student_period}); + * 状态机:PENDING → PROCESSING → COMPLETED / RETRY → (…) → FAILED。 + * PROCESSING 期间的新请求通过 {@code requested_at} 推进保留,Worker 完成时 + * 若 {@code requested_at} 晚于 {@code processing_started_at} 则回到 PENDING。

+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "candidate_scoring_refresh_tasks", + uniqueConstraints = @UniqueConstraint(name = "uk_refresh_task_student_period", + columnNames = {"student_user_id", "target_period"})) +@EntityListeners(AuditingEntityListener.class) +public class CandidateScoringRefreshTask { + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_PROCESSING = "PROCESSING"; + public static final String STATUS_RETRY = "RETRY"; + public static final String STATUS_COMPLETED = "COMPLETED"; + public static final String STATUS_FAILED = "FAILED"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "student_user_id", nullable = false) + private Long studentUserId; + + /** yyyy-MM */ + @Column(name = "target_period", nullable = false, length = 7) + private String targetPeriod; + + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private String status = STATUS_PENDING; + + @Column(name = "requested_at", nullable = false) + private LocalDateTime requestedAt; + + /** 仅用于超时扫描与观测;秒级精度不足以作为所有权证明。 */ + @Column(name = "processing_started_at") + private LocalDateTime processingStartedAt; + + /** + * R01-D4-2 claim fence:本次领取的不可复用令牌(UUID)。 + * 每次成功领取写入新值,进入 COMPLETED/PENDING/RETRY/FAILED 时清空。 + */ + @Column(name = "claim_token", length = 36) + private String claimToken; + + @Column(name = "processed_at") + private LocalDateTime processedAt; + + @Column(name = "next_attempt_at") + private LocalDateTime nextAttemptAt; + + @Column(name = "attempts", nullable = false) + @Builder.Default + private Integer attempts = 0; + + /** 固定错误分类(如 COMPUTE_FAILED / WRITE_FAILED),不保存异常原文、SQL、手机号或堆栈。 */ + @Column(name = "last_error_code", length = 40) + private String lastErrorCode; + + @Version + @Column(name = "version", nullable = false) + @Builder.Default + private Long version = 0L; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java new file mode 100644 index 0000000..1b6a62f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java @@ -0,0 +1,153 @@ +package com.mydreamjob.candidatescore.refresh; + +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 单任务事务执行器。领取、评分写入、失败状态分别使用独立事务,避免批次共享事务, + * 也避免捕获数据库异常后在 rollback-only 事务中记录 RETRY。 + */ +@Component +@RequiredArgsConstructor +public class CandidateScoringRefreshTaskExecutor { + + private final JdbcTemplate jdbcTemplate; + private final CandidateScoringCalculator calculator; + private final CandidateScoringSnapshotWriter writer; + + /** + * 回收因进程退出而遗留的 PROCESSING 租约。恢复与领取分属独立事务,任何实例均可执行; + * 条件更新保证同一行只会被一次恢复。失败次数达到上限时进入 FAILED,避免永久热循环。 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int recoverStale(LocalDateTime staleBefore, LocalDateTime now, int maxAttempts) { + return jdbcTemplate.update(""" + UPDATE candidate_scoring_refresh_tasks + SET status=CASE WHEN attempts+1 >= ? THEN 'FAILED' ELSE 'RETRY' END, + next_attempt_at=NULL, + attempts=attempts+1, + last_error_code='WORKER_TIMEOUT', + processing_started_at=NULL, + claim_token=NULL, + version=version+1, updated_at=CURRENT_TIMESTAMP + WHERE status='PROCESSING' AND processing_started_at < ? + """, maxAttempts, staleBefore); + } + + /** + * 领取任务。 + * + *

每次成功领取都生成一个全新的随机 UUID 作为所有权令牌(claim token), + * 在领取 SQL 中原子写入。令牌不可复用、与时间无关,因此不受 + * {@code processing_started_at} 秒级精度影响。

+ */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ClaimedTask claim(Long taskId, LocalDateTime now) { + String claimToken = java.util.UUID.randomUUID().toString(); + int updated = jdbcTemplate.update(""" + UPDATE candidate_scoring_refresh_tasks + SET status='PROCESSING', processing_started_at=?, claim_token=?, + version=version+1, updated_at=CURRENT_TIMESTAMP + WHERE id=? AND status IN ('PENDING','RETRY') + AND (next_attempt_at IS NULL OR next_attempt_at <= ?) + """, now, claimToken, taskId, now); + if (updated != 1) { + return null; + } + return jdbcTemplate.query(""" + SELECT id, student_user_id, target_period, processing_started_at, claim_token, version + FROM candidate_scoring_refresh_tasks WHERE id=? + """, (rs, i) -> new ClaimedTask( + rs.getLong("id"), rs.getLong("student_user_id"), + rs.getString("target_period"), + rs.getObject("processing_started_at", LocalDateTime.class), + rs.getString("claim_token"), + rs.getLong("version")), taskId) + .stream().findFirst().orElse(null); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void materialize(ClaimedTask task, LocalDateTime capturedNow) { + List results = + calculator.computeAll(task.studentUserId(), capturedNow); + writer.upsertIfChanged(task.studentUserId(), results, task.targetPeriod(), capturedNow, + calculator.currentRulesVersion()); + + // 所有权闸门(claim fence):claim_token 是本次领取写入的不可复用令牌, + // 只有它仍与领取时一致,才说明任务仍由本次领取持有。 + // version 额外参与 COMPLETED 判定:处理期间的新请求会推进 version, + // 此时应回到 PENDING 再算一次(而不是错误地置为 COMPLETED)。 + int completed = jdbcTemplate.update(""" + UPDATE candidate_scoring_refresh_tasks + SET status='COMPLETED', processing_started_at=NULL, claim_token=NULL, processed_at=?, + last_error_code=NULL, next_attempt_at=NULL, + version=version+1, updated_at=CURRENT_TIMESTAMP + WHERE id=? AND status='PROCESSING' AND claim_token=? AND version=? + """, capturedNow, task.id(), task.claimToken(), task.claimedVersion()); + if (completed == 1) { + return; + } + + // 同一 claim 期间有新请求推进 version → 回到 PENDING;闸门仍校验所有权令牌, + // 因此已被其他 Worker 重新领取时不会误改新持有者的状态。 + int pending = jdbcTemplate.update(""" + UPDATE candidate_scoring_refresh_tasks + SET status='PENDING', processing_started_at=NULL, claim_token=NULL, processed_at=?, + next_attempt_at=NULL, version=version+1, updated_at=CURRENT_TIMESTAMP + WHERE id=? AND status='PROCESSING' AND claim_token=? + """, capturedNow, task.id(), task.claimToken()); + if (pending == 0) { + // stale claim:任务已被租约回收并由其他 Worker 重新领取。 + // 安静退出,绝不覆盖新持有者的状态(这不是系统异常)。 + return; + } + } + + /** + * 记录失败并安排重试/终止。 + * + *

所有权闸门:只有 {@code claim_token} 仍等于本次领取写入的令牌时才更新。 + * 更新 0 行表示本次领取已失效(任务被租约回收并由其他 Worker 持有)—— + * 这属于正常竞争,安静退出而不是抛异常,以免中断同批其余任务。

+ * + * @param claimToken 本次领取的不可复用令牌 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordFailure(Long taskId, String category, LocalDateTime now, + int maxAttempts, long retryBackoffMs, String claimToken) { + LocalDateTime nextAttempt = now.plusNanos(Math.max(1L, retryBackoffMs) * 1_000_000L); + int updated = jdbcTemplate.update(""" + UPDATE candidate_scoring_refresh_tasks + SET status=CASE WHEN attempts+1 >= ? THEN 'FAILED' ELSE 'RETRY' END, + next_attempt_at=CASE WHEN attempts+1 >= ? THEN NULL ELSE ? END, + attempts=attempts+1, + last_error_code=?, + processing_started_at=NULL, + claim_token=NULL, + version=version+1, updated_at=CURRENT_TIMESTAMP + WHERE id=? AND status='PROCESSING' AND claim_token=? + """, maxAttempts, maxAttempts, nextAttempt, category, taskId, claimToken); + if (updated != 1) { + // stale claim:不覆盖新持有者,也不视为系统异常。 + return; + } + } + + /** + * 领取结果。 + * + * @param claimedAt 领取时写入 processing_started_at 的值 —— 仅用于超时扫描与观测, + * 不再承担所有权证明(秒级精度不足以区分同秒内的两次领取) + * @param claimToken 本次领取的不可复用所有权令牌(UUID),所有终态更新都必须携带它 + */ + public record ClaimedTask(Long id, Long studentUserId, String targetPeriod, + LocalDateTime claimedAt, String claimToken, long claimedVersion) { + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java new file mode 100644 index 0000000..aa84c4b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java @@ -0,0 +1,117 @@ +package com.mydreamjob.candidatescore.refresh; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +/** + * R01-D4-1 评分快照刷新 Worker。 + * + *

并发模型:数据库条件更新领取——{@code claim} 只在 + * {@code status IN ('PENDING','RETRY') AND next_attempt_at 到期} 时把行置为 PROCESSING, + * 第二个 Worker 对同一任务 claim 更新 0 行(略过),因此同一任务并发只能有一个有效处理者; + * 不使用 synchronized/ReentrantLock/Thread.sleep。

+ * + *

失败语义:捕获并转为固定错误分类(COMPUTE_FAILED/WRITE_FAILED/UNKNOWN_REFRESH_FAILURE), + * attempts+1 进入 RETRY、设置 next_attempt_at;超过最大次数进入 FAILED(定时补偿仍可重新登记)。 + * 异常不回抛,不中断其他任务;{@code last_error_code} 不保存异常原文、SQL、手机号或堆栈。

+ * + *

普通刷新不写审计(规避 刷新→审计→审计触发刷新 的循环);处理不发送 RabbitMQ、 + * 不写 notification、不写 snapshot_history。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CandidateScoringRefreshWorker { + + public static final int DEFAULT_MAX_ATTEMPTS = 3; + public static final int DEFAULT_BATCH_SIZE = 20; + + private final JdbcTemplate jdbcTemplate; + private final CandidateScoringRefreshTaskExecutor taskExecutor; + private Clock clock = Clock.systemDefaultZone(); + + @Value("${app.scoring.refresh.max-attempts:3}") + private int maxAttempts = DEFAULT_MAX_ATTEMPTS; + + @Value("${app.scoring.refresh.processing-timeout-ms:600000}") + private long processingTimeoutMs; + + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setClock(Clock clock) { + if (clock != null) this.clock = clock; + } + + /** 领取一批到期任务并逐个处理;返回处理数。 */ + public int processDue(int batchSize) { + LocalDateTime now = LocalDateTime.now(clock); + long timeoutMs = processingTimeoutMs <= 0 ? 600_000L : processingTimeoutMs; + taskExecutor.recoverStale(now.minus(Duration.ofMillis(timeoutMs)), now, + Math.max(1, maxAttempts)); + List due = jdbcTemplate.queryForList( + "SELECT id FROM candidate_scoring_refresh_tasks " + + "WHERE status IN ('PENDING','RETRY') AND (next_attempt_at IS NULL OR next_attempt_at <= ?) " + + "ORDER BY id LIMIT ?", Long.class, now, batchSize); + int processed = 0; + for (Long id : due) { + try { + if (processOne(id, LocalDateTime.now(clock))) { + processed++; + } + } catch (Exception e) { + // 单任务异常不得阻断同批其余任务;具体分类已在 processOne 内登记。 + log.warn("refresh task processing aborted: taskId={}, cause={}", + id, e.getClass().getSimpleName()); + } + } + return processed; + } + + /** 处理单条任务;领取失败(被并发 Worker 领取)直接返回 false。 */ + public boolean processOne(Long taskId, LocalDateTime capturedNow) { + CandidateScoringRefreshTaskExecutor.ClaimedTask task = taskExecutor.claim(taskId, capturedNow); + if (task == null) { + return false; + } + try { + taskExecutor.materialize(task, capturedNow); + return true; + } catch (Exception e) { + String category = classify(e); + log.warn("refresh task failed: taskId={}, category={}", taskId, category); + try { + taskExecutor.recordFailure(taskId, category, capturedNow, + Math.max(1, maxAttempts), nextBackoffMs(), task.claimToken()); + } catch (Exception inner) { + // 失败登记本身出问题也不得吞掉整个批次;本轮不改状态,由租约超时兜底恢复。 + log.warn("refresh task failure recording skipped: taskId={}, cause={}", + taskId, inner.getClass().getSimpleName()); + } + return false; + } + } + + @Value("${app.scoring.refresh.retry-backoff-ms:300000}") + private long retryBackoffMs; + + private long nextBackoffMs() { + return retryBackoffMs <= 0 ? 300_000L : retryBackoffMs; + } + + private String classify(Throwable t) { + if (t instanceof org.springframework.dao.DataAccessException) { + return "WRITE_FAILED"; + } + if (t instanceof com.mydreamjob.common.exception.BusinessException) { + return "COMPUTE_FAILED"; + } + return "UNKNOWN_REFRESH_FAILURE"; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringSnapshotWriter.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringSnapshotWriter.java new file mode 100644 index 0000000..1ff32fd --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringSnapshotWriter.java @@ -0,0 +1,224 @@ +package com.mydreamjob.candidatescore.refresh; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * R01-D4-1 唯一快照 Writer:集中写入 {@code candidate_dimension_scores}。 + * + *

两种写入模式:

+ *
    + *
  • {@link #upsertIfChanged}:普通物化——score/status/sub_scores_json/weight/rules_version + * 完全相同时不改写 version 与 updated_at(幂等);任一业务字段变化或 rulesVersion + * 变化才 {@code version+1};{@code calculated_at} 不单独触发 version 增长。
  • + *
  • {@link #upsertForcingVersion}:Governance 申诉决定/回滚的强制版本推进路径—— + * 每次无条件 version+1,保持 {@code GovernanceService} 的 + * {@code afterVersion = beforeVersion + 1} 与历史审计语义不变。
  • + *
+ * + *

sub_scores_json 写入前做 canonical 化(字段按字典序重排),避免字段顺序制造伪变化。 + * 不依赖 JVM 锁:唯一键 + 条件更新保证并发安全;MySQL 与 H2(MODE=MySQL)都应通过。

+ */ +@Component +@RequiredArgsConstructor +public class CandidateScoringSnapshotWriter { + + public static final BigDecimal DEFAULT_WEIGHT = new BigDecimal("12.50"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final JdbcTemplate jdbcTemplate; + + /** + * 普通物化:值变化才推进 version。 + * + * @param rulesVersion 当前规则版本(由调用方 {@code Calculator#currentRulesVersion()} 提供) + */ + @Transactional + public void upsertIfChanged(Long studentUserId, List results, + String period, java.time.LocalDateTime capturedNow, String rulesVersion) { + if (results == null || results.isEmpty() || studentUserId == null) { + return; + } + for (CandidateScoringCalculator.DimensionResult r : results) { + upsertRow(studentUserId, r, period, capturedNow, rulesVersion, false); + } + } + + /** + * 单条强制版本推进(Governance 申诉决定/回滚专用)。 + */ + @Transactional + public void upsertForcingVersion(Long studentUserId, CandidateScoringCalculator.DimensionResult r, + String period, java.time.LocalDateTime capturedNow, String rulesVersion) { + upsertRow(studentUserId, r, period, capturedNow, rulesVersion, true); + } + + private void upsertRow(Long studentUserId, CandidateScoringCalculator.DimensionResult r, + String period, java.time.LocalDateTime capturedNow, + String rulesVersion, boolean forceVersion) { + String json = canonicalJson(subScoresPayload(r)); + if (forceVersion) { + jdbcTemplate.update(""" + INSERT INTO candidate_dimension_scores + (student_user_id, dimension, period, score, status, sub_scores_json, + weight, rules_version, calculated_at, version, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,0,NOW(),NOW()) + ON DUPLICATE KEY UPDATE + score=?, status=?, sub_scores_json=?, weight=?, rules_version=?, calculated_at=?, + version=version+1, updated_at=NOW() + """, studentUserId, r.dimension, period, r.score, r.status, json, + DEFAULT_WEIGHT, rulesVersion, capturedNow, + r.score, r.status, json, DEFAULT_WEIGHT, rulesVersion, capturedNow); + return; + } + // 先做并发安全的“若不存在则插入”。冲突时只做主键自赋值,不覆盖业务字段, + // 随后的锁定读负责比较旧值;避免先 SELECT 后 INSERT 的首次物化竞争。 + jdbcTemplate.update(""" + INSERT INTO candidate_dimension_scores + (student_user_id, dimension, period, score, status, sub_scores_json, + weight, rules_version, calculated_at, version, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,0,NOW(),NOW()) + ON DUPLICATE KEY UPDATE id=id + """, studentUserId, r.dimension, period, r.score, r.status, json, + DEFAULT_WEIGHT, rulesVersion, capturedNow); + ExistingRow existing = findForUpdate(studentUserId, r.dimension, period); + if (existing == null) { + throw new org.springframework.dao.DataIntegrityViolationException( + "candidate scoring snapshot insert was not visible"); + } + + boolean changed = !same(existing.score(), r.score) + || !java.util.Objects.equals(existing.status(), r.status) + || !java.util.Objects.equals(canonicalJson(existing.subScoresJson()), json) + || !same(existing.weight(), DEFAULT_WEIGHT) + || !java.util.Objects.equals(existing.rulesVersion(), rulesVersion); + if (!changed) { + return; + } + int updated = jdbcTemplate.update(""" + UPDATE candidate_dimension_scores + SET score=?, status=?, sub_scores_json=?, weight=?, rules_version=?, calculated_at=?, + version=version+1, updated_at=NOW() + WHERE id=? AND version=? + """, r.score, r.status, json, DEFAULT_WEIGHT, rulesVersion, capturedNow, + existing.id(), existing.version()); + if (updated != 1) { + throw new org.springframework.dao.OptimisticLockingFailureException( + "candidate scoring snapshot changed concurrently"); + } + } + + private ExistingRow findForUpdate(Long studentUserId, String dimension, String period) { + return jdbcTemplate.query(""" + SELECT id, score, status, sub_scores_json, weight, rules_version, version + FROM candidate_dimension_scores + WHERE student_user_id=? AND dimension=? AND period=? + FOR UPDATE + """, (rs, i) -> new ExistingRow( + rs.getLong("id"), rs.getBigDecimal("score"), rs.getString("status"), + rs.getString("sub_scores_json"), rs.getBigDecimal("weight"), + rs.getString("rules_version"), rs.getLong("version")), + studentUserId, dimension, period).stream().findFirst().orElse(null); + } + + private boolean same(BigDecimal left, BigDecimal right) { + return left == null ? right == null : right != null && left.compareTo(right) == 0; + } + + private record ExistingRow(long id, BigDecimal score, String status, String subScoresJson, + BigDecimal weight, String rulesVersion, long version) { + } + + // ── payload(与 CandidateScoringCalculator.subScoresPayload 等价的内部实现) ── + + private String subScoresPayload(CandidateScoringCalculator.DimensionResult r) { + if ("ABILITY".equals(r.dimension) && r.abilitySnapshotTrace != null) { + try { + Map m = new LinkedHashMap<>(); + m.put("source", "V36_ABILITY_ASSESSMENT"); + m.put("sourceResultId", r.abilitySnapshotTrace.resultId()); + m.put("sourceCompletedAt", r.abilitySnapshotTrace.completedAt() == null + ? null : r.abilitySnapshotTrace.completedAt().toString()); + m.put("validUntil", r.abilitySnapshotTrace.validUntil() == null + ? null : r.abilitySnapshotTrace.validUntil().toString()); + m.put("questionSetVersion", r.abilitySnapshotTrace.questionSetVersion()); + m.put("assessmentRulesVersion", r.abilitySnapshotTrace.assessmentRulesVersion()); + m.put("mappingRulesVersion", com.mydreamjob.candidatescore.AbilityScoreMapper.MAPPING_RULES_VERSION); + m.put("weights", r.abilitySnapshotTrace.weights()); + m.put("dimensionScores", r.abilitySnapshotTrace.dimensionScores()); + return MAPPER.writeValueAsString(m); + } catch (Exception e) { + return null; + } + } + return toJson(r.subScores); + } + + private String toJson(List subs) { + if (subs == null || subs.isEmpty()) { + return null; + } + try { + return MAPPER.writeValueAsString(subs); + } catch (Exception e) { + return null; + } + } + + static String canonicalJson(String raw) { + if (raw == null || raw.isBlank()) { + return raw; + } + try { + JsonNode node = MAPPER.readTree(raw); + return MAPPER.writeValueAsString(sortKeys(node)); + } catch (Exception e) { + return raw; + } + } + + private static JsonNode sortKeys(JsonNode node) { + if (node instanceof ObjectNode obj) { + ObjectNode sorted = MAPPER.createObjectNode(); + List names = new ArrayList<>(); + obj.fieldNames().forEachRemaining(names::add); + names.sort(Comparator.naturalOrder()); + for (String name : names) { + sorted.set(name, sortKeys(obj.get(name))); + } + return sorted; + } + if (node.isArray()) { + com.fasterxml.jackson.databind.node.ArrayNode array = MAPPER.createArrayNode(); + for (JsonNode child : node) { + array.add(sortKeys(child)); + } + return array; + } + return node; + } + + /** 仅测试用:暴露时区无关的 canonic 化。 */ + static String canonicalForTest(Map raw) { + try { + return canonicalJson(MAPPER.writeValueAsString(new TreeMap<>(raw))); + } catch (Exception e) { + return null; + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java new file mode 100644 index 0000000..22ded7b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java @@ -0,0 +1,137 @@ +package com.mydreamjob.candidatescore.rules; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 求职者评分规则的**唯一代码来源**(R01-C)。 + * + *

本类只承载"规则值本身":版本标识、高校档次名单、教育背景分数与权重、默认维度权重。 + * 规则随**代码发布**变更,不接数据库、不接外部动态配置、无 setter、不暴露可变集合。

+ * + *

不变量

+ *
    + *
  • {@link #VERSION} 必须与重构前 {@code CandidateScoringCalculator.RULES_VERSION} 完全一致 + * ({@code candidate-scoring-v0.1})—— 本轮**不新增版本、不改变响应中的版本值**;
  • + *
  • 高校名单内容、数量、字符串与判定优先级逐项不变;
  • + *
  • 所有 BigDecimal 使用**字符串构造**,scale 与重构前一致;
  • + *
  • 不同业务语义的相同数字**不合并**(例如学历层次 85 与学校档次 85 是两个命名常量)。
  • + *
+ */ +public final class CandidateScoringRuleSet { + + private CandidateScoringRuleSet() { + } + + // ── 版本与汇总 ──────────────────────────────────────────── + + /** 评分规则版本。原值取自 {@code CandidateScoringCalculator.RULES_VERSION},逐字不变。 */ + public static final String VERSION = "candidate-scoring-v0.1"; + + /** 各维度默认等权重(参与维度重新归一化后等价于算术平均)。 */ + public static final BigDecimal DEFAULT_DIMENSION_WEIGHT = new BigDecimal("12.50"); + + /** 百分制上界,用于分数归一化。 */ + public static final BigDecimal FULL_SCORE = new BigDecimal("100"); + + // ── 教育背景:学历层次 ──────────────────────────────────── + + public static final BigDecimal EDUCATION_LEVEL_DOCTOR = new BigDecimal("100"); + public static final BigDecimal EDUCATION_LEVEL_MASTER = new BigDecimal("85"); + public static final BigDecimal EDUCATION_LEVEL_BACHELOR = new BigDecimal("70"); + public static final BigDecimal EDUCATION_LEVEL_JUNIOR_COLLEGE = new BigDecimal("55"); + public static final BigDecimal EDUCATION_LEVEL_UNKNOWN = new BigDecimal("50"); + + /** 教育维度内:学历层次与学校档次的合成权重(相加为 1.00)。 */ + public static final BigDecimal EDUCATION_LEVEL_WEIGHT = new BigDecimal("0.55"); + public static final BigDecimal EDUCATION_SCHOOL_TIER_WEIGHT = new BigDecimal("0.45"); + + /** 教育维度子项满分(用于解释与贡献校验)。 */ + public static final BigDecimal EDUCATION_LEVEL_FULL_SCORE = new BigDecimal("55"); + public static final BigDecimal EDUCATION_SCHOOL_TIER_FULL_SCORE = new BigDecimal("45"); + + // ── 教育背景:学校档次 ──────────────────────────────────── + + /** 985 高校档次得分。 */ + public static final BigDecimal SCHOOL_TIER_985 = new BigDecimal("85"); + /** 211(非 985)高校档次得分。 */ + public static final BigDecimal SCHOOL_TIER_211 = new BigDecimal("75"); + /** 已填写但不在两个名单内的学校。 */ + public static final BigDecimal SCHOOL_TIER_OTHER = new BigDecimal("65"); + /** 学校档次缺失(与"学历层次缺失"的 50 语义不同,故为独立常量)。 */ + public static final BigDecimal SCHOOL_TIER_MISSING = new BigDecimal("50"); + /** 教育资料整体缺失时的维度得分。 */ + public static final BigDecimal EDUCATION_MISSING_SCORE = new BigDecimal("100"); + + // ── 高校名单(v0.1 硬编码,非数据库表)──────────────────── + + /** + * 985 高校名单(38 项)。 + * + *

内容逐项抄录自重构前的 {@code CandidateScoringCalculator.C985},顺序与字符串均未改动。

+ */ + private static final Set C985 = immutableSet(List.of( + "北京大学", "清华大学", "中国人民大学", "北京航空航天大学", "北京理工大学", + "中国农业大学", "北京师范大学", "中央民族大学", "南开大学", "天津大学", + "大连理工大学", "吉林大学", "哈尔滨工业大学", "复旦大学", "同济大学", + "上海交通大学", "华东师范大学", "南京大学", "东南大学", "浙江大学", + "中国科学技术大学", "厦门大学", "山东大学", "中国海洋大学", "武汉大学", + "华中科技大学", "湖南大学", "中南大学", "中山大学", "华南理工大学", + "四川大学", "重庆大学", "电子科技大学", "西安交通大学", "西北工业大学", + "兰州大学", "东北大学", "西北农林科技大学")); + + /** + * 211(非 985)高校名单:字面量 **62 条**、唯一项 **61**。 + * + *

末项 {@code 北京科技大学} 与首段重复 —— 这是重构前名单的**既有事实**, + * 按"名单内容逐项不变、数量不变"的要求**原样保留**,不擅自去重、不补充、不删除。 + * 集合语义下判定行为与重构前一致({@code HashSet} 去重)。

+ */ + private static final Set C211 = immutableSet(List.of( + "北京交通大学", "北京工业大学", "北京科技大学", "北京化工大学", "北京邮电大学", + "北京林业大学", "北京中医药大学", "北京外国语大学", "中国传媒大学", "中央财经大学", + "对外经济贸易大学", "中国政法大学", "华北电力大学", "天津医科大学", "河北工业大学", + "太原理工大学", "内蒙古大学", "辽宁大学", "大连海事大学", "延边大学", + "哈尔滨工程大学", "东北农业大学", "东北林业大学", "东华大学", "上海大学", + "上海财经大学", "上海外国语大学", "苏州大学", "南京航空航天大学", "南京理工大学", + "中国矿业大学", "河海大学", "江南大学", "南京农业大学", "中国药科大学", + "南京师范大学", "安徽大学", "合肥工业大学", "福州大学", "南昌大学", + "郑州大学", "武汉理工大学", "华中农业大学", "华中师范大学", "中南财经政法大学", + "湖南师范大学", "暨南大学", "华南师范大学", "广西大学", "海南大学", + "西南交通大学", "西南财经大学", "贵州大学", "云南大学", "西北大学", + "西安电子科技大学", "长安大学", "新疆大学", "石河子大学", "宁夏大学", + "青海大学", "北京科技大学")); + + /** 只读视图:985 名单。 */ + public static Set c985() { + return C985; + } + + /** 只读视图:211(非 985)名单。 */ + public static Set c211() { + return C211; + } + + /** 该校是否为 985。 */ + public static boolean is985(String school) { + return school != null && C985.contains(school); + } + + /** 该校是否为 211(非 985)。 */ + public static boolean is211(String school) { + return school != null && C211.contains(school); + } + + /** + * 构造不可变集合。 + * + *

刻意使用 {@code unmodifiableSet(new HashSet<>(...))} 而非 {@code Set.copyOf}: + * 后者遇到重复元素会抛 {@code IllegalArgumentException},而 211 名单的重复项必须原样保留。

+ */ + private static Set immutableSet(List values) { + return Collections.unmodifiableSet(new HashSet<>(values)); + } +} 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/careerprogress/CareerProgressService.java b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java index 9f72571..ba4f954 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java @@ -66,6 +66,7 @@ public class CareerProgressService { private static final Set NEXT_INTERVIEW_STATUSES = Set.of("SCHEDULED", "CONFIRMED"); private static final String REPORT_DECISION_TYPE = "REPORT_DECISION"; + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final ApplicationRepository applicationRepository; private final InterviewRepository interviewRepository; private final InterviewScoreRepository interviewScoreRepository; @@ -79,7 +80,7 @@ public class CareerProgressService { @Transactional(readOnly = true) public CareerProgressResponse getCareerProgress(Long studentUserId, int page, int size, String status) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); validateParams(page, size, status); LocalDateTime now = LocalDateTime.now(); @@ -309,16 +310,6 @@ public class CareerProgressService { } // ── 权限 / 参数 / 工具 ───────────────────────────────── - - private void assertStudentRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isStudent = user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT); - if (!isStudent) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } - } - private void validateParams(int page, int size, String status) { if (page < 0 || size < 1 || size > 50) { throw new BusinessException(ErrorCode.BAD_REQUEST); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java new file mode 100644 index 0000000..86adb78 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java @@ -0,0 +1,261 @@ +package com.mydreamjob.common.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * 业务错误码 → HTTP 状态的**唯一**集中映射(R01-B/B2)。 + * + *

职责边界:只回答"这个业务错误码应该对应哪个 HTTP 状态"。不做日志、不读请求、 + * 不生成响应体、不解析 requestId、不缓存请求级状态。

+ * + *

契约(与重构前逐项一致)

+ *
    + *
  • 显式映射只在 {@link #explicitMappings()} 一处定义,全部使用 + * {@code ErrorCode.X} 符号,不出现手写数字;
  • + *
  • **未显式映射的错误码回退 {@link #DEFAULT_STATUS}(400 BAD_REQUEST)**, + * 与重构前 {@code getOrDefault(code, BAD_REQUEST)} 完全一致;
  • + *
  • **映射键是 {@link ErrorCode} 本身,不是数字**。这一点是必须的: + * {@code ErrorCode} 现有 166 个常量却只有 165 个唯一数字码 + * ({@code REVIEW_ALREADY_DECIDED} 与 {@code DUPLICATE_FEEDBACK} 同为 9103), + * 若以数字为键,"禁止重复键"将无法在不修改 {@code ErrorCode} 数字的前提下成立。 + * 数字入口 {@link #resolve(int)} 由该映射派生;若两个不同常量被映射到 + * **同一个数字码且状态不同**,初始化即抛错(这是不可接受的歧义);
  • + *
  • 映射表初始化后不可变({@link Map#copyOf}),运行期无法增删改;
  • + *
  • 同一个 {@link ErrorCode} 常量被注册两次即为**初始化错误** + * (无论目标状态是否相同),由 {@link Builder#put} 立即抛出 + * {@link IllegalStateException},使"重复键"在启动期而不是请求期暴露;
  • + *
  • 不使用反射扫描,不引入任何运行时可变的 Map。
  • + *
+ * + *

注意:{@code UNAUTHORIZED(2001)} 与 {@code SERVICE_UNAVAILABLE} + * 在本映射中**故意不显式登记**,因此既有语义是回退 400 —— 这是重构前就存在的行为, + * 本次收束不得擅自把它改成 401/503。

+ */ +@Component +public final class ErrorHttpStatusRegistry { + + /** 未显式映射时的回退状态(与重构前一致)。 */ + public static final HttpStatus DEFAULT_STATUS = HttpStatus.BAD_REQUEST; + + private static final Map EXPLICIT = explicitMappings(); + private static final Map BY_CODE = indexByCode(EXPLICIT); + + /** 按 {@link ErrorCode} 解析。{@code null} 走默认回退,不做 NPE 抛出。 */ + public HttpStatus resolve(ErrorCode errorCode) { + return errorCode == null ? DEFAULT_STATUS : EXPLICIT.getOrDefault(errorCode, DEFAULT_STATUS); + } + + /** 按数字业务码解析({@code BusinessException} 只暴露数字 code)。 */ + public HttpStatus resolve(int errorCode) { + return BY_CODE.getOrDefault(errorCode, DEFAULT_STATUS); + } + + /** 未映射错误码的默认状态。 */ + public HttpStatus defaultStatus() { + return DEFAULT_STATUS; + } + + /** 显式登记的 {@link ErrorCode} 常量数量(用于一致性校验与报告)。 */ + public int explicitMappingCount() { + return EXPLICIT.size(); + } + + /** 显式映射覆盖的唯一数字码数量(小于等于 {@link #explicitMappingCount()})。 */ + public int explicitNumericCodeCount() { + return BY_CODE.size(); + } + + /** 只读视图:显式映射的 {@link ErrorCode} → 状态。 */ + public static Map explicitMappingsView() { + return EXPLICIT; + } + + /** 只读视图:数字业务码 → 状态(由 {@link #explicitMappingsView()} 派生)。 */ + public static Map numericMappingsView() { + return BY_CODE; + } + + /** 某个错误码是否被显式登记。 */ + public static boolean isExplicitlyMapped(ErrorCode errorCode) { + return errorCode != null && EXPLICIT.containsKey(errorCode); + } + + /** + * 由 {@link ErrorCode} 键映射派生数字键索引。 + * + *

同一数字码对应多个常量是**既有事实**(9103)。只要它们被映射到相同状态, + * 数字入口就是无歧义的,保持既有行为;一旦状态不同,说明存在无法自动裁决的冲突, + * 初始化必须失败而不是任选其一。

+ */ + private static Map indexByCode(Map explicit) { + Map byCode = new LinkedHashMap<>(); + for (Map.Entry entry : explicit.entrySet()) { + HttpStatus previous = byCode.putIfAbsent(entry.getKey().getCode(), entry.getValue()); + if (previous != null && previous != entry.getValue()) { + throw new IllegalStateException("conflicting HTTP status mapping for numeric error code " + + entry.getKey().getCode() + ": " + previous + " then " + entry.getValue()); + } + } + return Map.copyOf(byCode); + } + + /** + * 显式映射定义(唯一位置)。仅登记与重构前完全一致的 (错误码, 状态) 组合。 + */ + private static Map explicitMappings() { + return new Builder() + .put(HttpStatus.FORBIDDEN, + ErrorCode.APPLICATION_OWNER_MISMATCH, + ErrorCode.FORBIDDEN, + ErrorCode.INTERVIEW_ACCESS_DENIED, + ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION, + ErrorCode.OFFER_COMPARE_NOT_OWNER) + .put(HttpStatus.NOT_FOUND, + ErrorCode.ABILITY_ATTEMPT_NOT_FOUND, + ErrorCode.ABILITY_RESULT_NOT_FOUND, + ErrorCode.ABILITY_TYPE_NOT_FOUND, + ErrorCode.APPEAL_NOT_FOUND, + ErrorCode.APPLICATION_NOT_FOUND, + ErrorCode.ASSESSMENT_ATTEMPT_NOT_FOUND, + ErrorCode.ASSESSMENT_TYPE_NOT_FOUND, + ErrorCode.COMPANY_NOT_FOUND, + ErrorCode.CONVERSATION_NOT_FOUND, + ErrorCode.DATA_EXPORT_TASK_NOT_FOUND, + ErrorCode.FEEDBACK_NOT_FOUND, + ErrorCode.INTERVIEW_SCORE_NOT_FOUND, + ErrorCode.JOB_NOT_FOUND, + ErrorCode.MESSAGE_NOT_FOUND, + ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND, + ErrorCode.OFFER_NOT_FOUND, + ErrorCode.PROFILE_NOT_FOUND, + ErrorCode.REPORT_NOT_FOUND, + ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND, + ErrorCode.RESUME_VERSION_NOT_FOUND, + ErrorCode.REVIEW_NOT_FOUND, + ErrorCode.REVIEW_REPLY_NOT_FOUND, + ErrorCode.RISK_EVENT_NOT_FOUND, + ErrorCode.RISK_RULE_NOT_FOUND, + ErrorCode.TALENT_INVITATION_NOT_FOUND, + ErrorCode.USER_NOT_FOUND, + ErrorCode.VERIFICATION_NOT_FOUND) + .put(HttpStatus.CONFLICT, + ErrorCode.ABILITY_ATTEMPT_INVALID_STATE, + ErrorCode.ABILITY_CONCURRENT_UPDATE, + ErrorCode.ABILITY_DUPLICATE_ACTIVE, + ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT, + ErrorCode.ABILITY_RETAKE_COOLDOWN, + ErrorCode.ACCOUNT_DELETED, + ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED, + ErrorCode.ACCOUNT_DELETION_CONCURRENT_UPDATE, + ErrorCode.ACCOUNT_DELETION_PENDING, + ErrorCode.APPEAL_ALREADY_DECIDED, + ErrorCode.APPEAL_DUPLICATE, + ErrorCode.APPEAL_IDEMPOTENCY_CONFLICT, + ErrorCode.APPEAL_INVALID_TRANSITION, + ErrorCode.APPEAL_ROLLBACK_LIMIT_EXCEEDED, + ErrorCode.APPLICATION_DUPLICATE, + ErrorCode.APPLICATION_NOT_MATCHED, + ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE, + ErrorCode.ASSESSMENT_CONCURRENT_UPDATE, + ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE, + ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT, + ErrorCode.COMPANY_ALREADY_EXISTS, + ErrorCode.DATA_EXPORT_DUPLICATE, + ErrorCode.DATA_EXPORT_EXPIRED, + ErrorCode.DATA_EXPORT_FAILED, + ErrorCode.DATA_EXPORT_FILE_NOT_FOUND, + ErrorCode.DATA_EXPORT_NOT_READY, + ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED, + ErrorCode.DUPLICATE_DEFAULT_RESUME, + ErrorCode.DUPLICATE_FEEDBACK, + ErrorCode.DUPLICATE_OFFER, + ErrorCode.DUPLICATE_REVIEW, + ErrorCode.INTERVIEW_ALREADY_RESPONDED, + ErrorCode.INTERVIEW_COMPLETE_NOT_READY, + ErrorCode.INTERVIEW_NOT_COMPLETED, + ErrorCode.INTERVIEW_SCORE_CONCURRENT_UPDATE, + ErrorCode.INTERVIEW_SCORE_SUBMITTED, + ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION, + ErrorCode.INVALID_DELETION_STATE, + ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION, + ErrorCode.INVALID_JOB_STATUS_TRANSITION, + ErrorCode.INVALID_OFFER_STATUS_TRANSITION, + ErrorCode.INVALID_VERIFICATION_STATUS_TRANSITION, + ErrorCode.LAST_ADMIN_CANNOT_DELETE, + ErrorCode.MATCH_CLOSED, + ErrorCode.MATCH_SCORES_LOCKED, + ErrorCode.MESSAGE_RECALL_CONCURRENT_UPDATE, + ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED, + ErrorCode.OFFER_COUNTER_OFFER_EXISTS, + ErrorCode.OFFER_COUNTER_OFFER_EXPIRED, + ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING, + ErrorCode.OFFER_EXPIRED, + ErrorCode.OFFER_NOT_EDITABLE, + ErrorCode.OFFER_REMINDER_DUPLICATE, + ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE, + ErrorCode.REPORT_ALREADY_DECIDED, + ErrorCode.REPORT_DUPLICATE, + ErrorCode.REPORT_IDEMPOTENCY_CONFLICT, + ErrorCode.REPORT_INVALID_TRANSITION, + ErrorCode.RESUME_EXPORT_IN_PROGRESS, + ErrorCode.RESUME_EXPORT_NOT_READY, + ErrorCode.REVIEW_ALREADY_DECIDED, + ErrorCode.REVIEW_REPLY_ALREADY_EXISTS, + ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE, + ErrorCode.REVIEW_REPLY_IDEMPOTENCY_CONFLICT, + ErrorCode.REVIEW_REPLY_INVALID_STATE, + ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE, + ErrorCode.RISK_EVENT_CONCURRENT_UPDATE, + ErrorCode.RISK_INVALID_ACTION, + ErrorCode.RISK_RULE_CONCURRENT_UPDATE, + ErrorCode.TALENT_INVITATION_ALREADY_ACTIVE, + ErrorCode.TALENT_INVITATION_CANDIDATE_UNAVAILABLE, + ErrorCode.TALENT_INVITATION_CONCURRENT_UPDATE, + ErrorCode.TALENT_INVITATION_EXPIRED, + ErrorCode.TALENT_INVITATION_IDEMPOTENCY_CONFLICT, + ErrorCode.TALENT_INVITATION_INVALID_STATE, + ErrorCode.VERIFICATION_ALREADY_APPROVED, + ErrorCode.VERIFICATION_ALREADY_PENDING) + .put(HttpStatus.TOO_MANY_REQUESTS, + ErrorCode.ABILITY_RETAKE_LIMIT, + ErrorCode.REPORT_RATE_LIMITED, + ErrorCode.TALENT_INVITATION_RATE_LIMITED) + .build(); + } + + /** + * 显式映射构建器。 + * + *

重复键检测:同一个数字错误码第二次注册即抛 {@link IllegalStateException}, + * **不区分目标状态是否相同** —— "同码同状态的重复登记"同样是不可审计的配置错误。

+ */ + public static final class Builder { + + private final Map entries = new LinkedHashMap<>(); + + public Builder put(HttpStatus status, ErrorCode... errorCodes) { + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(errorCodes, "errorCodes"); + for (ErrorCode errorCode : errorCodes) { + Objects.requireNonNull(errorCode, "errorCode"); + HttpStatus previous = entries.putIfAbsent(errorCode, status); + if (previous != null) { + throw new IllegalStateException( + "duplicate HTTP status mapping for error code " + errorCode.getCode() + + " (" + errorCode.name() + "): " + previous + " then " + status); + } + } + return this; + } + + /** 构建不可变映射(键为 {@link ErrorCode})。 */ + public Map build() { + return Map.copyOf(entries); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index fc1eafc..573fb2e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -1,9 +1,12 @@ package com.mydreamjob.common.exception; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.logging.RequestIdAccessor; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Import; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -16,17 +19,26 @@ import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.HashMap; import java.util.Map; /** * 全局异常处理器 + * + *

R01-B/B2:业务错误码 → HTTP 状态的映射已收束到 {@link ErrorHttpStatusRegistry}, + * 本类只负责调用它,不再持有任何映射表或条件链。

*/ @Slf4j @RestControllerAdvice +@RequiredArgsConstructor +@Import(ErrorHttpStatusRegistry.class) public class GlobalExceptionHandler { + private final RequestIdAccessor requestIdAccessor; + private final ErrorHttpStatusRegistry errorHttpStatusRegistry; + @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity> handleConstraintViolation( ConstraintViolationException ex, HttpServletRequest request) { @@ -42,200 +54,12 @@ public class GlobalExceptionHandler { int errorCode = e.getErrorCode(); log.warn("BusinessException: code={}, message={}, path={}", errorCode, e.getMessage(), request.getRequestURI()); - HttpStatus status = resolveHttpStatus(errorCode); + HttpStatus status = errorHttpStatusRegistry.resolve(errorCode); return ResponseEntity .status(status) .body(ApiResponse.error(errorCode, e.getMessage(), getRequestId(request))); } - /** - * 按业务错误码段映射 HTTP 状态码(与 API v0.2 草案一致): - * - FORBIDDEN(2002) → 403 - * - 不存在类(5001/6001/7001/8002/4001/9102) → 404 - * - 冲突/非法流转类(5003/7003/8001/8003/9101) → 409 - * - 其余 → 400 - */ - private HttpStatus resolveHttpStatus(int errorCode) { - // 权限 → 403 - if (errorCode == ErrorCode.FORBIDDEN.getCode()) return HttpStatus.FORBIDDEN; - // 面试访问/越权 → 403 - if (errorCode == ErrorCode.INTERVIEW_ACCESS_DENIED.getCode() - || errorCode == ErrorCode.APPLICATION_OWNER_MISMATCH.getCode()) - return HttpStatus.FORBIDDEN; - // 不存在 → 404 - if (errorCode == ErrorCode.COMPANY_NOT_FOUND.getCode() - || errorCode == ErrorCode.PROFILE_NOT_FOUND.getCode() - || errorCode == ErrorCode.JOB_NOT_FOUND.getCode() - || errorCode == ErrorCode.APPLICATION_NOT_FOUND.getCode() - || errorCode == ErrorCode.OFFER_NOT_FOUND.getCode() - || errorCode == ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND.getCode() - || errorCode == ErrorCode.RESUME_VERSION_NOT_FOUND.getCode() - || errorCode == ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND.getCode() - || errorCode == ErrorCode.USER_NOT_FOUND.getCode() - || errorCode == ErrorCode.REVIEW_NOT_FOUND.getCode() - || errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode() - || errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode() - || errorCode == ErrorCode.MESSAGE_NOT_FOUND.getCode() - || errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode() - || errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode() - || errorCode == ErrorCode.REVIEW_REPLY_NOT_FOUND.getCode() - || errorCode == ErrorCode.TALENT_INVITATION_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; - // 冲突 → 409 - if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode() - || errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode() - || errorCode == ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION.getCode() - || errorCode == ErrorCode.INVALID_JOB_STATUS_TRANSITION.getCode() - || errorCode == ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION.getCode() - || errorCode == ErrorCode.DUPLICATE_REVIEW.getCode() - || errorCode == ErrorCode.DUPLICATE_FEEDBACK.getCode() - || errorCode == ErrorCode.DUPLICATE_OFFER.getCode() - || errorCode == ErrorCode.INVALID_OFFER_STATUS_TRANSITION.getCode() - || errorCode == ErrorCode.OFFER_EXPIRED.getCode() - || errorCode == ErrorCode.OFFER_NOT_EDITABLE.getCode() - || errorCode == ErrorCode.OFFER_REMINDER_DUPLICATE.getCode() - || errorCode == ErrorCode.OFFER_COUNTER_OFFER_EXISTS.getCode() - || errorCode == ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING.getCode() - || errorCode == ErrorCode.OFFER_COUNTER_OFFER_EXPIRED.getCode() - || errorCode == ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED.getCode() - || errorCode == ErrorCode.RESUME_EXPORT_IN_PROGRESS.getCode() - || errorCode == ErrorCode.DUPLICATE_DEFAULT_RESUME.getCode() - || errorCode == ErrorCode.RESUME_EXPORT_NOT_READY.getCode() - || errorCode == ErrorCode.MATCH_CLOSED.getCode() - || errorCode == ErrorCode.MATCH_SCORES_LOCKED.getCode() - || errorCode == ErrorCode.APPLICATION_NOT_MATCHED.getCode() - || errorCode == ErrorCode.REVIEW_ALREADY_DECIDED.getCode() - // 面试冲突 → 409 - || errorCode == ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION.getCode() - || errorCode == ErrorCode.INTERVIEW_COMPLETE_NOT_READY.getCode() - || errorCode == ErrorCode.INTERVIEW_ALREADY_RESPONDED.getCode() - || errorCode == ErrorCode.REVIEW_REPLY_ALREADY_EXISTS.getCode() - || errorCode == ErrorCode.REVIEW_REPLY_INVALID_STATE.getCode() - || errorCode == ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE.getCode() - || errorCode == ErrorCode.REVIEW_REPLY_IDEMPOTENCY_CONFLICT.getCode() - || errorCode == ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED.getCode() - || errorCode == ErrorCode.MESSAGE_RECALL_CONCURRENT_UPDATE.getCode() - || errorCode == ErrorCode.TALENT_INVITATION_CANDIDATE_UNAVAILABLE.getCode() - || errorCode == ErrorCode.TALENT_INVITATION_ALREADY_ACTIVE.getCode() - || errorCode == ErrorCode.TALENT_INVITATION_INVALID_STATE.getCode() - || errorCode == ErrorCode.TALENT_INVITATION_EXPIRED.getCode() - || errorCode == ErrorCode.TALENT_INVITATION_CONCURRENT_UPDATE.getCode() - || errorCode == ErrorCode.TALENT_INVITATION_IDEMPOTENCY_CONFLICT.getCode()) - return HttpStatus.CONFLICT; - // 越权 → 403(新增:企业-求职者建会话需匹配) - if (errorCode == ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode()) - return HttpStatus.FORBIDDEN; - // Offer 比较越权(包含非本人 Offer) → 403 - if (errorCode == ErrorCode.OFFER_COMPARE_NOT_OWNER.getCode()) - return HttpStatus.FORBIDDEN; - // 认证不存在 → 404 - if (errorCode == ErrorCode.VERIFICATION_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; - // 认证冲突 → 409 - if (errorCode == ErrorCode.VERIFICATION_ALREADY_PENDING.getCode() - || errorCode == ErrorCode.VERIFICATION_ALREADY_APPROVED.getCode() - || errorCode == ErrorCode.INVALID_VERIFICATION_STATUS_TRANSITION.getCode()) - return HttpStatus.CONFLICT; - // 权重并发冲突 → 409 - if (errorCode == ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE.getCode()) - return HttpStatus.CONFLICT; - // 隐私设置并发冲突 → 409 - if (errorCode == ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE.getCode()) - return HttpStatus.CONFLICT; - // 账号注销状态冲突 → 409(9301-9306) - if (errorCode == ErrorCode.INVALID_DELETION_STATE.getCode() - || errorCode == ErrorCode.ACCOUNT_DELETION_PENDING.getCode() - || errorCode == ErrorCode.ACCOUNT_DELETED.getCode() - || errorCode == ErrorCode.ACCOUNT_DELETION_CONCURRENT_UPDATE.getCode() - || errorCode == ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED.getCode() - || errorCode == ErrorCode.LAST_ADMIN_CANNOT_DELETE.getCode()) - return HttpStatus.CONFLICT; - // 评分治理冲突 → 409 - if (errorCode == ErrorCode.APPEAL_DUPLICATE.getCode() - || errorCode == ErrorCode.APPEAL_ALREADY_DECIDED.getCode() - || errorCode == ErrorCode.APPEAL_ROLLBACK_LIMIT_EXCEEDED.getCode() - || errorCode == ErrorCode.APPEAL_INVALID_TRANSITION.getCode() - || errorCode == ErrorCode.APPEAL_IDEMPOTENCY_CONFLICT.getCode()) - return HttpStatus.CONFLICT; - // 个人数据导出 → 409(9402-9406,除 9401 已映射 404) - if (errorCode == ErrorCode.DATA_EXPORT_DUPLICATE.getCode() - || errorCode == ErrorCode.DATA_EXPORT_NOT_READY.getCode() - || errorCode == ErrorCode.DATA_EXPORT_EXPIRED.getCode() - || errorCode == ErrorCode.DATA_EXPORT_FAILED.getCode() - || errorCode == ErrorCode.DATA_EXPORT_FILE_NOT_FOUND.getCode()) - return HttpStatus.CONFLICT; - // 结构化面试评分 → 404(9504 评分不存在) - if (errorCode == ErrorCode.INTERVIEW_SCORE_NOT_FOUND.getCode()) - return HttpStatus.NOT_FOUND; - // 结构化面试评分 → 409(9501 并发 / 9502 未完成 / 9503 已提交) - if (errorCode == ErrorCode.INTERVIEW_SCORE_CONCURRENT_UPDATE.getCode() - || errorCode == ErrorCode.INTERVIEW_NOT_COMPLETED.getCode() - || errorCode == ErrorCode.INTERVIEW_SCORE_SUBMITTED.getCode()) - return HttpStatus.CONFLICT; - // 举报与屏蔽:不存在 → 404 - if (errorCode == ErrorCode.REPORT_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; - // 举报与屏蔽:冲突/重复/非法流转 → 409 - if (errorCode == ErrorCode.REPORT_DUPLICATE.getCode() - || errorCode == ErrorCode.REPORT_ALREADY_DECIDED.getCode() - || errorCode == ErrorCode.REPORT_INVALID_TRANSITION.getCode() - || errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode()) - return HttpStatus.CONFLICT; - // 举报限流 → 429 - if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) - return HttpStatus.TOO_MANY_REQUESTS; - // RIASEC 测评:不存在 → 404 - if (errorCode == ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode() - || errorCode == ErrorCode.ASSESSMENT_ATTEMPT_NOT_FOUND.getCode()) - return HttpStatus.NOT_FOUND; - // RIASEC 测评:状态/并发/幂等冲突 → 409 - if (errorCode == ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE.getCode() - || errorCode == ErrorCode.ASSESSMENT_CONCURRENT_UPDATE.getCode() - || errorCode == ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE.getCode() - || errorCode == ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT.getCode()) - return HttpStatus.CONFLICT; - // RIASEC 测评:答案/未完成 → 400 - if (errorCode == ErrorCode.ASSESSMENT_ANSWER_INVALID.getCode() - || errorCode == ErrorCode.ASSESSMENT_INCOMPLETE.getCode()) - return HttpStatus.BAD_REQUEST; - // 四维能力测评:不存在 → 404 - if (errorCode == ErrorCode.ABILITY_TYPE_NOT_FOUND.getCode() - || errorCode == ErrorCode.ABILITY_ATTEMPT_NOT_FOUND.getCode() - || errorCode == ErrorCode.ABILITY_RESULT_NOT_FOUND.getCode()) - return HttpStatus.NOT_FOUND; - // 四维能力测评:状态/并发/幂等/冷却冲突 → 409 - if (errorCode == ErrorCode.ABILITY_ATTEMPT_INVALID_STATE.getCode() - || errorCode == ErrorCode.ABILITY_CONCURRENT_UPDATE.getCode() - || errorCode == ErrorCode.ABILITY_DUPLICATE_ACTIVE.getCode() - || errorCode == ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT.getCode() - || errorCode == ErrorCode.ABILITY_RETAKE_COOLDOWN.getCode()) - return HttpStatus.CONFLICT; - // 四维能力测评:答案/未完成 → 400 - if (errorCode == ErrorCode.ABILITY_ANSWER_INVALID.getCode() - || errorCode == ErrorCode.ABILITY_INCOMPLETE.getCode()) - return HttpStatus.BAD_REQUEST; - // 四维能力测评:重测次数限制 → 429 - if (errorCode == ErrorCode.ABILITY_RETAKE_LIMIT.getCode()) - return HttpStatus.TOO_MANY_REQUESTS; - // Offer 进阶契约 → 400(不可撤销、不可还价、比较数量超限) - if (errorCode == ErrorCode.OFFER_NOT_WITHDRAWABLE.getCode() - || errorCode == ErrorCode.OFFER_NOT_NEGOTIABLE.getCode() - || errorCode == ErrorCode.OFFER_COMPARE_TOO_MANY.getCode()) - return HttpStatus.BAD_REQUEST; - // 风险概览与运营规则配置:不存在 → 404 - if (errorCode == ErrorCode.RISK_RULE_NOT_FOUND.getCode() - || errorCode == ErrorCode.RISK_EVENT_NOT_FOUND.getCode()) - return HttpStatus.NOT_FOUND; - // 风险概览与运营规则配置:并发/非法流转 → 409 - if (errorCode == ErrorCode.RISK_RULE_CONCURRENT_UPDATE.getCode() - || errorCode == ErrorCode.RISK_EVENT_CONCURRENT_UPDATE.getCode() - || errorCode == ErrorCode.RISK_INVALID_ACTION.getCode()) - return HttpStatus.CONFLICT; - // 风险概览与运营规则配置:非法筛选 → 400(默认即 400,显式声明保证 9906) - if (errorCode == ErrorCode.RISK_INVALID_FILTER.getCode()) - return HttpStatus.BAD_REQUEST; - if (errorCode == ErrorCode.TALENT_INVITATION_RATE_LIMITED.getCode()) - return HttpStatus.TOO_MANY_REQUESTS; - return HttpStatus.BAD_REQUEST; - } - @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidationException( MethodArgumentNotValidException ex, HttpServletRequest request) { @@ -296,6 +120,17 @@ public class GlobalExceptionHandler { .body(ApiResponse.forbidden("access denied", getRequestId(request))); } + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResourceFound( + NoResourceFoundException ex, HttpServletRequest request) { + // S03-C 整改:业务端口 /actuator/** 等不存在资源统一返回 404。 + // 只记录固定文案与归一化 requestId,不回显异常 message、资源路径或堆栈。 + log.warn("No resource found: requestId={}", getRequestId(request)); + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(404, "not found", getRequestId(request))); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleGenericException( Exception ex, HttpServletRequest request) { @@ -306,6 +141,6 @@ public class GlobalExceptionHandler { } private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + return requestIdAccessor.get(request); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java new file mode 100644 index 0000000..a822e3e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java @@ -0,0 +1,37 @@ +package com.mydreamjob.common.logging; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +/** + * 请求 ID 读取器:读取 requestId 的**唯一入口**,保证响应头、MDC、Controller 响应体、 + * {@code AuditContext} 与异常响应使用同一个值。 + * + *

取值优先级:

+ *
    + *
  1. {@link RequestIdFilter} 写入的请求属性(规范化后的值);
  2. + *
  3. 请求头 {@code X-Request-Id} —— 过滤器已通过请求包装器把它替换为规范化值, + * 因此这里得到的仍是同一个值;若调用路径绕过了过滤器,则由 + * {@link RequestIdFilter#normalizeRequestId(String)} 按同一规则收敛, + * **不会**把超长或非法客户端头原样返回。
  4. + *
+ * + *

本类不产生第二套生成规则:缺失或空白时复用过滤器同一处 + * {@code normalizeRequestId} 逻辑。

+ */ +@Component +public class RequestIdAccessor { + + /** 读取当前请求的规范化 requestId。 */ + public String get(HttpServletRequest request) { + if (request == null) { + return RequestIdFilter.normalizeRequestId(null); + } + Object attribute = request.getAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE); + if (attribute instanceof String value && !value.isBlank()) { + return value; + } + // 过滤器未执行时的兜底:仍按过滤器同一规则收敛,绝不直接回显客户端原值。 + return RequestIdFilter.normalizeRequestId(request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java index 43f4bbf..42997c6 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java @@ -11,20 +11,32 @@ import java.util.Collections; import java.util.UUID; /** - * 请求 ID 过滤器:为每个请求生成/传递唯一 ID,便于日志追踪 + * 请求 ID 过滤器:为每个请求生成/传递唯一 ID,便于日志追踪。 + * + *

本过滤器是 requestId 的**唯一产出点**:规范化后的值同时写入请求属性 + * {@link #REQUEST_ID_ATTRIBUTE}、MDC、响应头,并覆盖请求包装器的 {@code X-Request-Id} + * 头,使 Controller、{@code AuditContext} 与异常响应读到同一个值。

*/ @Component public class RequestIdFilter extends OncePerRequestFilter { - private static final String REQUEST_ID_HEADER = "X-Request-Id"; - private static final String REQUEST_ID_ATTR = "requestId"; + /** 客户端可传入的请求头名;同时是响应头名。 */ + static final String REQUEST_ID_HEADER = "X-Request-Id"; + + /** + * 规范化 requestId 的请求属性名。下游(Controller、异常处理器)应通过 + * {@link RequestIdAccessor} 读取,而不是各自解析请求头。 + */ + public static final String REQUEST_ID_ATTRIBUTE = "requestId"; + private static final int MAX_REQUEST_ID_LENGTH = 32; @Override protected void doFilterInternal(HttpServletRequest request, jakarta.servlet.http.HttpServletResponse response, jakarta.servlet.FilterChain filterChain) throws IOException, jakarta.servlet.ServletException { String requestId = normalizeRequestId(request.getHeader(REQUEST_ID_HEADER)); - MDC.put(REQUEST_ID_ATTR, requestId); + request.setAttribute(REQUEST_ID_ATTRIBUTE, requestId); + MDC.put(REQUEST_ID_ATTRIBUTE, requestId); response.setHeader(REQUEST_ID_HEADER, requestId); try { // Controllers and AuditContext read the request header directly. Wrap the @@ -50,17 +62,35 @@ public class RequestIdFilter extends OncePerRequestFilter { }; filterChain.doFilter(normalizedRequest, response); } finally { - MDC.remove(REQUEST_ID_ATTR); + MDC.remove(REQUEST_ID_ATTRIBUTE); } } static String normalizeRequestId(String candidate) { String value = candidate == null ? "" : candidate.trim(); - if (value.isEmpty()) { - return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + if (value.isEmpty() || !hasOnlySafeCharacters(value)) { + return generatedRequestId(); } return value.length() <= MAX_REQUEST_ID_LENGTH ? value : value.substring(0, MAX_REQUEST_ID_LENGTH); } + + private static boolean hasOnlySafeCharacters(String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + boolean safe = c >= 'a' && c <= 'z' + || c >= 'A' && c <= 'Z' + || c >= '0' && c <= '9' + || c == '.' || c == '_' || c == ':' || c == '-'; + if (!safe) { + return false; + } + } + return true; + } + + private static String generatedRequestId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java index fadc773..14d9bcf 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java +++ b/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>> 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>> getMyCompany(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map 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>> updateMyCompany( @Valid @RequestBody CompanyUpdateRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map 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"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java index d1265b9..dcfd781 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java @@ -25,37 +25,28 @@ import java.util.Map; @Service public class CompanyService { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final CompanyRepository companyRepository; private final UserRepository userRepository; private final JobService jobService; public CompanyService(CompanyRepository companyRepository, UserRepository userRepository, - JobService jobService) { + JobService jobService, + com.mydreamjob.security.UserRoleGuard userRoleGuard) { this.companyRepository = companyRepository; this.userRepository = userRepository; this.jobService = jobService; + this.userRoleGuard = userRoleGuard; } // ── 角色校验(最先执行)─────────────────────────────────── public void assertCompanyRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isCompany = user.getRoles().stream() - .anyMatch(r -> r == User.Role.COMPANY); - if (!isCompany) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } + userRoleGuard.requireCompany(userId); } public void assertStudentRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isStudent = user.getRoles().stream() - .anyMatch(r -> r == User.Role.STUDENT); - if (!isStudent) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } + userRoleGuard.requireStudent(userId); } // ── 业务方法 ────────────────────────────────────────────── diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java index 5827ef7..2e509e0 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java @@ -4,6 +4,7 @@ import com.mydreamjob.application.Application; import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.candidatescore.CandidateOverviewStatusPolicy; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -99,7 +100,7 @@ public class CompetitionRankingCalculator { BigDecimal percentile = computePercentile(rows, me); List meSnaps = latestByStudent.getOrDefault(meId, List.of()); - String status = overviewStatus(meSnaps); + String status = CandidateOverviewStatusPolicy.fromSnapshots(meSnaps); String myPeriod = me == null ? null : me.snapshotPeriod; return new CohortRanking(rows.size(), rows, me, rank, @@ -123,17 +124,6 @@ public class CompetitionRankingCalculator { .setScale(2, RoundingMode.HALF_UP); } - /** 综合卡状态:OK / PARTIAL / INSUFFICIENT_DATA / PENDING(复用引擎语义) */ - public String overviewStatus(List snaps) { - if (snaps == null || snaps.isEmpty()) return "INSUFFICIENT_DATA"; - long computable = snaps.stream() - .filter(s -> "OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())).count(); - if (computable == snaps.size()) return "OK"; - if (computable > 0) return "PARTIAL"; - boolean allPending = snaps.stream().allMatch(s -> "PENDING".equals(s.getStatus())); - return allPending ? "PENDING" : "INSUFFICIENT_DATA"; - } - /** 排序载体(不参与响应序列化) */ public static class Row { public final Long studentUserId; @@ -178,4 +168,4 @@ public class CompetitionRankingCalculator { this.meSnaps = meSnaps; } } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java index 84d1a1f..d18c313 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java @@ -1,17 +1,15 @@ package com.mydreamjob.competition; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.competition.dto.CompetitionRankingResponse; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; 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.PathVariable; import org.springframework.web.bind.annotation.RestController; @@ -28,6 +26,8 @@ import org.springframework.web.bind.annotation.RestController; public class CompetitionRankingController { private final CompetitionRankingService competitionRankingService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "同岗位竞争力排名", description = "仅 STUDENT 且已投递该岗位;返回本人排名/综合分/百分位/维度/缺口及匿名 Top 5。" @@ -42,20 +42,8 @@ public class CompetitionRankingController { @GetMapping("/jobs/{jobId}/competition-ranking") public ResponseEntity> getRanking( @PathVariable Long jobId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); CompetitionRankingResponse data = competitionRankingService.getRanking(userId, jobId); - 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()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java index db7c903..c53a24e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java @@ -39,6 +39,7 @@ public class CompetitionRankingService { private static final int TOP_N = 5; + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final UserRepository userRepository; private final JobService jobService; private final ApplicationRepository applicationRepository; @@ -50,7 +51,7 @@ public class CompetitionRankingService { if (jobId == null || jobId <= 0) { throw new BusinessException(ErrorCode.BAD_REQUEST); } - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); // 仅 PUBLISHED 岗位可查看未公开状态统一 404,避免暴露岗位候选人池 Job job = jobService.getJob(jobId); @@ -146,16 +147,6 @@ public class CompetitionRankingService { } // ── 身份与工具 ───────────────────────────────────────────── - - private void assertStudentRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isStudent = user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT); - if (!isStudent) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } - } - private String yearMonth(LocalDate d) { return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/AdminBootstrapProperties.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/AdminBootstrapProperties.java new file mode 100644 index 0000000..38c3d7c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/AdminBootstrapProperties.java @@ -0,0 +1,28 @@ +package com.mydreamjob.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * S03-A 一次性管理员初始化配置。 + * + *

默认关闭;只有显式把开关设为字面量 {@code "true"} 才会装配初始化器。 + * 手机号只能来自环境变量,**没有默认值**,因此不会误建管理员。

+ */ +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.bootstrap.admin") +public class AdminBootstrapProperties { + + /** 是否启用一次性管理员初始化(严格 true 才生效)。 */ + private String enabled = "false"; + + /** 管理员手机号;无默认值,必须由部署方显式提供。 */ + private String phone = ""; + + /** 管理员昵称。 */ + private String nickname = "平台管理员"; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/AppJwtProperties.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/AppJwtProperties.java new file mode 100644 index 0000000..0a86b9a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/AppJwtProperties.java @@ -0,0 +1,90 @@ +package com.mydreamjob.config; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * S02 Access/Refresh Token 隔离配置。 + * + *

Access 与 Refresh 使用独立签名密钥,Token 摘要另用独立密钥;三项密钥 + * 均不得缺失、不得为空、不得互相回退,且必须两两不同。

+ * + *

校验失败时抛出固定安全文本的异常,**不回显密钥值本身**。

+ */ +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.jwt") +public class AppJwtProperties { + + /** HMAC-SHA256 最小密钥长度(256 bit)。 */ + static final int MIN_SECRET_LENGTH = 32; + + /** + * 旧配置迁移项。S02 新签发的 Token 不使用该密钥,保留仅为兼容既有部署的环境变量, + * 因此不参与下面的长度与互异性校验。 + */ + private String secret; + + private String accessSecret; + private String refreshSecret; + private String tokenHashSecret; + + private long expirationMs = 86_400_000L; + private long refreshExpirationMs = 604_800_000L; + + private String issuer = "mydreamjob"; + private String accessAudience = "mydreamjob-api"; + private String refreshAudience = "mydreamjob-auth"; + + @PostConstruct + void validate() { + requireStrong("JWT_ACCESS_SECRET", accessSecret); + requireStrong("JWT_REFRESH_SECRET", refreshSecret); + requireStrong("JWT_TOKEN_HASH_SECRET", tokenHashSecret); + + // 三项必须两两不同:任一回退或被复用都会削弱隔离。 + requireDistinct("JWT_ACCESS_SECRET", accessSecret, "JWT_REFRESH_SECRET", refreshSecret); + requireDistinct("JWT_ACCESS_SECRET", accessSecret, "JWT_TOKEN_HASH_SECRET", tokenHashSecret); + requireDistinct("JWT_REFRESH_SECRET", refreshSecret, "JWT_TOKEN_HASH_SECRET", tokenHashSecret); + + requirePositive("JWT_EXPIRATION_MS", expirationMs); + requirePositive("JWT_REFRESH_EXPIRATION_MS", refreshExpirationMs); + + requireText("app.jwt.issuer", issuer); + requireText("app.jwt.access-audience", accessAudience); + requireText("app.jwt.refresh-audience", refreshAudience); + } + + private void requireStrong(String name, String value) { + if (value == null || value.isBlank()) { + throw new IllegalStateException("invalid jwt configuration: " + name + " must be set"); + } + if (value.length() < MIN_SECRET_LENGTH) { + throw new IllegalStateException("invalid jwt configuration: " + name + + " must be at least " + MIN_SECRET_LENGTH + " characters"); + } + } + + private void requireDistinct(String nameA, String valueA, String nameB, String valueB) { + if (valueA.equals(valueB)) { + throw new IllegalStateException("invalid jwt configuration: " + nameA + + " and " + nameB + " must differ"); + } + } + + private void requirePositive(String name, long value) { + if (value <= 0) { + throw new IllegalStateException("invalid jwt configuration: " + name + " must be positive"); + } + } + + private void requireText(String name, String value) { + if (value == null || value.isBlank()) { + throw new IllegalStateException("invalid jwt configuration: " + name + " must be set"); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index eb4f259..14614b3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -60,9 +60,14 @@ public class SecurityConfig { "{\"code\":403,\"message\":\"access denied\",\"timestamp\":\"\"}"); })) .authorizeHttpRequests(auth -> auth - // Actuator 健康检查 - .requestMatchers("/actuator/health/**").permitAll() - .requestMatchers("/actuator/info").permitAll() + // S03-C 整改:业务端口统一放行 /actuator/**。 + // Actuator 端点仍只注册在独立管理端口(默认 127.0.0.1),业务端口上 + // /actuator/** 全部落入 MVC 的不存在资源处理,由 GlobalExceptionHandler 的 + // NoResourceFoundException 精确映射为 404 —— 避免匿名请求先被认证层 + // 拦截成 401(未放行路径)或落入通用异常映射成 500(放行路径)。 + // permitAll 不代表暴露指标,只保证所有 /actuator/** 都走到同一 404 语义; + // 业务端口不得新增任何 Actuator Controller 或端点映射。 + .requestMatchers("/actuator/**").permitAll() // Swagger UI .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**").permitAll() // 健康检查 API @@ -74,8 +79,13 @@ public class SecurityConfig { .requestMatchers("/auth/sms/send").permitAll() .requestMatchers("/auth/login").permitAll() .requestMatchers("/auth/register").permitAll() + // S02:刷新允许匿名进入 Controller;是否成功仍取决于 Refresh Token 校验。 + // permitAll 只表示可进入,不代表 Token 有效。 + .requestMatchers("/auth/refresh").permitAll() // 当前用户 .requestMatchers("/auth/me").authenticated() + // S02:登出必须已认证 + .requestMatchers("/auth/logout").authenticated() // 企业资料(需登录) .requestMatchers("/companies/me/**").authenticated() // V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验) @@ -115,7 +125,8 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/jobs/*/personalized-review-score").authenticated() // 岗位公开查询 .requestMatchers("/jobs/public/**").permitAll() - .requestMatchers("/jobs").permitAll() + // 仅公开岗位列表为匿名可读;写操作必须命中下方的 /jobs/** → authenticated + .requestMatchers(HttpMethod.GET, "/jobs").permitAll() // 岗位搜索与筛选(公开) .requestMatchers(HttpMethod.GET, "/jobs/search").permitAll() .requestMatchers(HttpMethod.GET, "/jobs/filters").permitAll() diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java index bc1b704..3a75f3c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java @@ -4,6 +4,7 @@ import com.mydreamjob.common.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -16,9 +17,15 @@ import java.util.Map; */ @Tag(name = "API", description = "API 信息入口") @RestController +@RequiredArgsConstructor @RequestMapping public class ApiController { + /** R01-A6:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A6:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + @Operation(summary = "API 信息", description = "返回 API v0.1 基本信息") @GetMapping("/") public ResponseEntity>> index(HttpServletRequest request) { @@ -35,10 +42,7 @@ public class ApiController { ), "docs", "http://localhost:8080/swagger-ui.html" ); - return ResponseEntity.ok(ApiResponse.ok(info, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(info, requestIdAccessor.get(request))); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java index c77d012..3da68e2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java @@ -1,12 +1,16 @@ package com.mydreamjob.controller; import com.mydreamjob.auth.AuthService; -import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.AuthSessionService; +import com.mydreamjob.auth.InvalidTokenException; import com.mydreamjob.auth.LoginRequest; import com.mydreamjob.auth.SmsVerificationService; +import com.mydreamjob.auth.dto.RefreshTokenRequest; +import com.mydreamjob.auth.dto.TokenPairResponse; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -16,29 +20,31 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -import java.util.HashMap; import java.util.Map; /** * 认证相关接口 + * + *

S02:新增 {@code /auth/refresh} 与 {@code /auth/logout}。Controller 只做参数接收、 + * 身份解析和响应包装,不计算摘要、不解析 JWT、不访问 Repository,也不重复实现会话逻辑。

*/ -@Tag(name = "认证", description = "登录、验证码、用户信息") +@Tag(name = "认证", description = "登录、验证码、Token 刷新与登出、用户信息") @RestController @RequestMapping("/auth") @RequiredArgsConstructor @Validated public class AuthController { + /** R01-A6:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; private final AuthService authService; private final SmsVerificationService smsVerificationService; - private final JwtTokenProvider jwtTokenProvider; + private final CurrentUserAccessor currentUserAccessor; - @Operation(summary = "发送短信验证码(Mock)", description = "开发环境 Mock:验证码会打印到日志") + @Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。") @PostMapping("/sms/send") public ResponseEntity>> sendSmsCode( @RequestParam @NotBlank(message = "phone is required") @@ -47,22 +53,22 @@ public class AuthController { HttpServletRequest request) { try { smsVerificationService.sendCode(phone, purpose, - getRequestId(request), + requestIdAccessor.get(request), request.getRemoteAddr(), request.getHeader("User-Agent")); return ResponseEntity.ok(ApiResponse.ok( Map.of("message", "验证码已发送(Mock)"), - getRequestId(request))); + requestIdAccessor.get(request))); } catch (ConstraintViolationException e) { return ResponseEntity.badRequest().body( - ApiResponse.badRequest(e.getMessage(), getRequestId(request))); + ApiResponse.badRequest(e.getMessage(), requestIdAccessor.get(request))); } catch (Exception e) { return ResponseEntity.badRequest().body( - ApiResponse.error(400, e.getMessage(), getRequestId(request))); + ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request))); } } - @Operation(summary = "Mock 登录", description = "使用手机号+验证码登录,返回 JWT token") + @Operation(summary = "Mock 登录", description = "使用手机号+验证码登录,返回 Access/Refresh Token 并创建服务端登录会话") @PostMapping("/login") public ResponseEntity>> mockLogin( @Valid @RequestBody LoginRequest body, @@ -73,10 +79,10 @@ public class AuthController { ? "login" : body.getPurpose(); try { Map result = authService.mockLogin(phone, code, purpose, - getRequestId(request), + requestIdAccessor.get(request), request.getRemoteAddr(), request.getHeader("User-Agent")); - return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request))); } catch (BusinessException e) { // 已注销手机号:保留 HTTP 409 / 业务 code=9303,交由全局异常处理器映射。 // 其余业务异常(如验证码错误、参数校验)维持既有 400/code=400 行为。 @@ -84,26 +90,60 @@ public class AuthController { throw e; } return ResponseEntity.badRequest().body( - ApiResponse.error(400, e.getMessage(), getRequestId(request))); + ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request))); } catch (Exception e) { return ResponseEntity.badRequest().body( - ApiResponse.error(400, e.getMessage(), getRequestId(request))); + ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request))); } } + @Operation(summary = "刷新 Token", + description = "使用 Refresh Token 换取新的 Access/Refresh Token 对;旧 Refresh Token 立即失效") + @PostMapping("/refresh") + public ResponseEntity> refresh( + @Valid @RequestBody RefreshTokenRequest body, + HttpServletRequest request) { + try { + AuthSessionService.SessionTokens tokens = authService.refresh( + body.getRefreshToken(), + requestIdAccessor.get(request), + request.getRemoteAddr(), + request.getHeader("User-Agent")); + return ResponseEntity.ok(ApiResponse.ok( + new TokenPairResponse(tokens.accessToken(), tokens.refreshToken(), + AuthService.EXPIRES_IN_SECONDS), + requestIdAccessor.get(request))); + } catch (InvalidTokenException e) { + // 统一 401:不说明 Token 不存在、已消费、已撤销还是重放,也不回显任何 Token。 + return unauthorized(request); + } + } + + @Operation(summary = "登出", + description = "撤销当前登录会话;需要 Access Token,不接受任何请求体") + @PostMapping("/logout") + public ResponseEntity>> logout(HttpServletRequest request) { + // 身份与会话一律来自通过校验的 Access Token,不接受客户端传入 userId/sessionId。 + Long userId = currentUserAccessor.requireUserId(); + String sessionId = currentUserAccessor.requireSessionId(); + authService.logout(userId, sessionId, + requestIdAccessor.get(request), + request.getRemoteAddr(), + request.getHeader("User-Agent")); + return ResponseEntity.ok(ApiResponse.ok( + Map.of("message", "logged out"), requestIdAccessor.get(request))); + } + @Operation(summary = "当前用户信息", description = "需要 JWT 认证") @GetMapping("/me") public ResponseEntity>> me(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - return ResponseEntity.status(401).body( - ApiResponse.unauthorized("not authenticated", getRequestId(request))); - } - Long userId = Long.valueOf(auth.getName()); - return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), getRequestId(request))); + Long userId = currentUserAccessor.requireUserId(); + return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), requestIdAccessor.get(request))); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + private ResponseEntity> unauthorized(HttpServletRequest request) { + return ResponseEntity.status(401).body( + ApiResponse.unauthorized("invalid or expired token", requestIdAccessor.get(request))); } + } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java index 338684d..884d662 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java @@ -4,6 +4,7 @@ import com.mydreamjob.common.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -16,9 +17,15 @@ import java.util.Map; */ @Tag(name = "系统", description = "健康检查、应用信息") @RestController +@RequiredArgsConstructor @RequestMapping public class SystemController { + /** R01-A6:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A6:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + @Operation(summary = "应用健康检查", description = "返回应用及依赖服务状态") @GetMapping("/health") public ResponseEntity>> health(HttpServletRequest request) { @@ -27,7 +34,7 @@ public class SystemController { "application", "mydreamjob-server", "version", "0.0.1-SNAPSHOT" ); - return ResponseEntity.ok(ApiResponse.ok(details, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(details, requestIdAccessor.get(request))); } @Operation(summary = "应用信息", description = "返回应用版本、运行时信息") @@ -40,10 +47,7 @@ public class SystemController { "uptimeSeconds", (System.currentTimeMillis() - java.lang.management.ManagementFactory.getRuntimeMXBean().getStartTime()) / 1000, "springProfile", System.getProperty("spring.profiles.active", "dev") ); - return ResponseEntity.ok(ApiResponse.ok(info, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(info, requestIdAccessor.get(request))); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java index 29e03a9..bd17491 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java @@ -52,6 +52,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; /** * 个人数据导出收集器:汇总当前用户自己的业务数据,用于打包 ZIP。 @@ -97,13 +99,14 @@ public class PersonalDataCollector { public CollectResult collect(Long userId) { CollectResult r = new CollectResult(); + List applications = applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId); r.getSections().put("profile", buildProfile(userId)); r.getSections().put("preferences", buildPreferences(userId)); buildResumes(r, userId); - buildApplications(r, userId); + buildApplications(r, applications); buildInterviews(r, userId); buildOffers(r, userId); - buildFeedback(r, userId); + buildFeedback(r, applications); buildFavorites(r, userId); buildNotifications(r, userId); buildConversationsAndMessages(r, userId); @@ -187,16 +190,17 @@ public class PersonalDataCollector { } // ── applications + timelines ──────────────────────────── - private void buildApplications(CollectResult r, Long userId) { + private void buildApplications(CollectResult r, List applications) { List> apps = new ArrayList<>(); List> timelines = new ArrayList<>(); - applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId) - .forEach(a -> { - apps.add(applicationView(a)); - applicationStatusHistoryRepository - .findByApplicationIdOrderByCreatedAtAsc(a.getId()) - .forEach(h -> timelines.add(applicationTimelineView(h))); - }); + Map> histories = groupBy( + applicationStatusHistoryRepository.findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc( + ids(applications, Application::getId)), ApplicationStatusHistory::getApplicationId); + applications.forEach(a -> { + apps.add(applicationView(a)); + histories.getOrDefault(a.getId(), List.of()) + .forEach(h -> timelines.add(applicationTimelineView(h))); + }); r.getSections().put("applications", apps); r.getSections().put("application-timelines", timelines); } @@ -226,16 +230,22 @@ public class PersonalDataCollector { private void buildInterviews(CollectResult r, Long userId) { List> ivs = new ArrayList<>(); List> timelines = new ArrayList<>(); - interviewRepository.findByStudentUserIdOrderByStartAtDesc(userId, PageRequest.of(0, MAX_ROWS)) - .forEach(iv -> addInterview(r, iv, ivs, timelines)); - interviewRepository.findByCompanyUserIdOrderByStartAtDesc(userId, PageRequest.of(0, MAX_ROWS)) - .forEach(iv -> addInterview(r, iv, ivs, timelines)); + List interviews = new ArrayList<>(); + interviews.addAll(interviewRepository.findByStudentUserIdOrderByStartAtDesc( + userId, PageRequest.of(0, MAX_ROWS)).getContent()); + interviews.addAll(interviewRepository.findByCompanyUserIdOrderByStartAtDesc( + userId, PageRequest.of(0, MAX_ROWS)).getContent()); + Map> histories = groupBy( + interviewStatusHistoryRepository.findByInterviewIdInOrderByInterviewIdAscCreatedAtAsc( + ids(interviews, Interview::getId)), InterviewStatusHistory::getInterviewId); + interviews.forEach(iv -> addInterview(iv, ivs, timelines, histories)); r.getSections().put("interviews", ivs); r.getSections().put("interview-timelines", timelines); } - private void addInterview(CollectResult r, Interview iv, - List> ivs, List> timelines) { + private void addInterview(Interview iv, List> ivs, + List> timelines, + Map> histories) { Map m = new LinkedHashMap<>(); m.put("interviewId", iv.getId()); m.put("applicationId", iv.getApplicationId()); @@ -250,7 +260,7 @@ public class PersonalDataCollector { m.put("notes", iv.getNotes()); m.put("status", iv.getStatus()); ivs.add(m); - interviewStatusHistoryRepository.findByInterviewIdOrderByCreatedAtAsc(iv.getId()) + histories.getOrDefault(iv.getId(), List.of()) .forEach(h -> timelines.add(interviewTimelineView(h))); } @@ -268,21 +278,27 @@ public class PersonalDataCollector { private void buildOffers(CollectResult r, Long userId) { List> offers = new ArrayList<>(); List> timelines = new ArrayList<>(); - offerRepository.findByCandidateIdOrderByCreatedAtDesc(userId, PageRequest.of(0, MAX_ROWS)) - .forEach(o -> addOffer(r, o, offers, timelines)); + List source = new ArrayList<>(); + source.addAll(offerRepository.findByCandidateIdOrderByCreatedAtDesc( + userId, PageRequest.of(0, MAX_ROWS)).getContent()); companyRepository.findByOwnerUserId(userId).ifPresent(c -> - offerRepository.findByCompanyIdOrderByCreatedAtDesc(c.getId(), PageRequest.of(0, MAX_ROWS)) - .forEach(o -> addOffer(r, o, offers, timelines))); + source.addAll(offerRepository.findByCompanyIdOrderByCreatedAtDesc( + c.getId(), PageRequest.of(0, MAX_ROWS)).getContent())); + Map> histories = groupBy( + offerStatusHistoryRepository.findByOfferIdInOrderByOfferIdAscCreatedAtAsc( + ids(source, Offer::getId)), OfferStatusHistory::getOfferId); + source.forEach(o -> addOffer(o, offers, timelines, histories)); r.getSections().put("offers", offers); r.getSections().put("offer-timelines", timelines); } - private void addOffer(CollectResult r, Offer o, - List> offers, List> timelines) { + private void addOffer(Offer o, List> offers, + List> timelines, + Map> histories) { if (!offersContains(offers, o.getId())) { offers.add(offerView(o)); } - offerStatusHistoryRepository.findByOfferIdOrderByCreatedAtAsc(o.getId()) + histories.getOrDefault(o.getId(), List.of()) .forEach(h -> timelines.add(offerTimelineView(h))); } @@ -325,11 +341,13 @@ public class PersonalDataCollector { } // ── feedback ──────────────────────────────────────────── - private void buildFeedback(CollectResult r, Long userId) { + private void buildFeedback(CollectResult r, List applications) { List> list = new ArrayList<>(); - applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId) - .forEach(a -> candidateFeedbackRepository.findByApplicationId(a.getId()) - .ifPresent(f -> list.add(feedbackView(f)))); + Map feedbackByApplication = candidateFeedbackRepository + .findByApplicationIdIn(ids(applications, Application::getId)).stream() + .collect(Collectors.toMap(CandidateFeedback::getApplicationId, Function.identity())); + applications.forEach(a -> Optional.ofNullable(feedbackByApplication.get(a.getId())) + .ifPresent(f -> list.add(feedbackView(f)))); r.getSections().put("feedback", list); } @@ -381,8 +399,15 @@ public class PersonalDataCollector { private void buildConversationsAndMessages(CollectResult r, Long userId) { List> convs = new ArrayList<>(); List> msgs = new ArrayList<>(); - for (ConversationMember member : conversationMemberRepository.findByUserId(userId)) { - conversationRepository.findById(member.getConversationId()).ifPresent(c -> { + List members = conversationMemberRepository.findByUserId(userId); + List conversationIds = ids(members, ConversationMember::getConversationId); + Map conversations = conversationRepository.findAllById(conversationIds).stream() + .collect(Collectors.toMap(Conversation::getId, Function.identity())); + Map> messages = groupBy( + messageRepository.findByConversationIdInOrderByConversationIdAscCreatedAtDesc(conversationIds), + Message::getConversationId); + for (ConversationMember member : members) { + Optional.ofNullable(conversations.get(member.getConversationId())).ifPresent(c -> { Map mc = new LinkedHashMap<>(); mc.put("conversationId", c.getId()); mc.put("title", c.getTitle()); @@ -391,8 +416,7 @@ public class PersonalDataCollector { mc.put("updatedAt", c.getUpdatedAt()); convs.add(mc); }); - messageRepository.findByConversationIdOrderByCreatedAtDesc( - member.getConversationId(), PageRequest.of(0, MAX_ROWS)) + messages.getOrDefault(member.getConversationId(), List.of()).stream().limit(MAX_ROWS) .forEach(msg -> msgs.add(messageView(msg))); } r.getSections().put("conversations", convs); @@ -428,9 +452,12 @@ public class PersonalDataCollector { r.getSections().put("candidate-scoring", scores); List> appeals = new ArrayList<>(); - candidateScoringAppealRepository.findByCandidateUserIdOrderByCreatedAtDesc( - userId, PageRequest.of(0, MAX_ROWS)) - .forEach(a -> { + List source = candidateScoringAppealRepository + .findByCandidateUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, MAX_ROWS)).getContent(); + Map> histories = groupBy( + candidateScoringAppealHistoryRepository.findByAppealIdInOrderByAppealIdAscCreatedAtAsc( + ids(source, CandidateScoringAppeal::getId)), CandidateScoringAppealHistory::getAppealId); + source.forEach(a -> { Map m = new LinkedHashMap<>(); m.put("appealId", a.getId()); m.put("dimension", a.getDimension()); @@ -441,7 +468,7 @@ public class PersonalDataCollector { m.put("createdAt", a.getCreatedAt()); m.put("updatedAt", a.getUpdatedAt()); List> hist = new ArrayList<>(); - candidateScoringAppealHistoryRepository.findByAppealIdOrderByCreatedAtAsc(a.getId()) + histories.getOrDefault(a.getId(), List.of()) .forEach(h -> { Map hm = new LinkedHashMap<>(); hm.put("fromStatus", h.getFromStatus()); @@ -479,4 +506,12 @@ public class PersonalDataCollector { String n = name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_"); return n.isEmpty() ? "resume" : n; } -} \ No newline at end of file + + private List ids(List values, Function idExtractor) { + return values.stream().map(idExtractor).toList(); + } + + private Map> groupBy(List values, Function idExtractor) { + return values.stream().collect(Collectors.groupingBy(idExtractor)); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java index d9d4a79..0ecb19b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java @@ -2,10 +2,10 @@ package com.mydreamjob.dataexport; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.PageResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.dataexport.dto.CreateDataExportRequest; import com.mydreamjob.dataexport.dto.DataExportTaskResponse; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -15,8 +15,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.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -37,16 +35,18 @@ import java.nio.charset.StandardCharsets; public class PersonalDataExportController { private final DataExportService dataExportService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "创建个人数据导出任务", description = "异步生成 ZIP;同 idempotencyKey 重放返回原任务;注销进行中禁止创建") @PostMapping("/profile/me/data-export-tasks") public ResponseEntity> create( @Valid @RequestBody(required = false) CreateDataExportRequest body, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); CreateDataExportRequest req = body == null ? new CreateDataExportRequest() : body; return ResponseEntity.ok(ApiResponse.ok( - dataExportService.create(userId, req), getRequestId(request))); + dataExportService.create(userId, req), requestIdAccessor.get(request))); } @Operation(summary = "查询个人数据导出任务列表", description = "仅本人;按创建时间倒序分页") @@ -56,9 +56,9 @@ public class PersonalDataExportController { @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String status, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - dataExportService.list(userId, page, size, status), getRequestId(request))); + dataExportService.list(userId, page, size, status), requestIdAccessor.get(request))); } @Operation(summary = "查询个人数据导出任务详情", description = "仅本人;不暴露存储路径与内部字段") @@ -66,9 +66,9 @@ public class PersonalDataExportController { public ResponseEntity> get( @PathVariable Long taskId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - dataExportService.get(userId, taskId), getRequestId(request))); + dataExportService.get(userId, taskId), requestIdAccessor.get(request))); } @Operation(summary = "下载个人数据导出 ZIP", description = "仅本人且 COMPLETED 未过期可下载;attachment") @@ -76,7 +76,7 @@ public class PersonalDataExportController { public ResponseEntity download( @PathVariable Long taskId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); DataExportService.DownloadFile df = dataExportService.download(userId, taskId); ContentDisposition cd = ContentDisposition.attachment() .filename(df.filename(), StandardCharsets.UTF_8) @@ -86,21 +86,4 @@ public class PersonalDataExportController { .header(HttpHeaders.CONTENT_DISPOSITION, cd.toString()) .body(df.content()); } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated() - || "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - try { - return Long.valueOf(auth.getName()); - } catch (NumberFormatException e) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - } - - 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/deletion/AccountDeletionCompletionWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java index 709ee60..3193e1c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java @@ -1,6 +1,7 @@ package com.mydreamjob.deletion; import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.AuthSessionService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.dataexport.PersonalDataExportCleanupService; @@ -11,6 +12,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; +import java.time.Instant; import java.time.LocalDateTime; /** @@ -32,6 +35,8 @@ public class AccountDeletionCompletionWorker { private final AuditService auditService; private final AccountDeletionPhoneHasher phoneHasher; private final PersonalDataExportCleanupService dataExportCleanupService; + private final AuthSessionService authSessionService; + private final Clock clock; /** * 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。 @@ -44,7 +49,9 @@ public class AccountDeletionCompletionWorker { if (rec == null || rec.getStatus() != DeletionStatus.PENDING) { return false; } - LocalDateTime now = LocalDateTime.now(); + // S02:注销完成入口只取一次时间,状态迁移、脱敏与全局会话吊销共用。 + Instant capturedNow = clock.instant(); + LocalDateTime now = LocalDateTime.ofInstant(capturedNow, AuthSessionService.BUSINESS_ZONE); if (rec.getEffectiveAt() == null || rec.getEffectiveAt().isAfter(now)) { return false; } @@ -55,6 +62,10 @@ public class AccountDeletionCompletionWorker { rec.setCompletedAt(now); accountDeletionRepository.saveAndFlush(rec); // @Version 门控:并发时后到者抛异常 desensitize(rec.getUserId()); + // S02:注销完成必须撤销该用户全部登录会话(与状态迁移同一事务)。 + // 只按 rec.getUserId() 撤销,不接受任何客户端传入的 userId。 + authSessionService.revokeAllUserSessions( + rec.getUserId(), AuthSessionService.REASON_ACCOUNT_DELETED, capturedNow); // 注销完成:清理该用户的个人导出文件并作废未完成任务 dataExportCleanupService.purgeForDeletedUser(rec.getUserId()); // 完成 + 脱敏审计各写一次 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java index 271801c..d5c707a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java @@ -14,8 +14,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -27,6 +25,11 @@ import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor public class AccountDeletionController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final AccountDeletionService accountDeletionService; @Operation( @@ -57,9 +60,9 @@ public class AccountDeletionController { }) @PostMapping public ResponseEntity> initiate(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - accountDeletionService.initiate(userId), getRequestId(request))); + accountDeletionService.initiate(userId), requestIdAccessor.get(request))); } @Operation( @@ -82,9 +85,9 @@ public class AccountDeletionController { }) @GetMapping public ResponseEntity> getStatus(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - accountDeletionService.getCurrentStatus(userId), getRequestId(request))); + accountDeletionService.getCurrentStatus(userId), requestIdAccessor.get(request))); } @Operation( @@ -112,20 +115,9 @@ public class AccountDeletionController { }) @PostMapping("/cancel") public ResponseEntity> cancel(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - accountDeletionService.cancel(userId), getRequestId(request))); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); + accountDeletionService.cancel(userId), requestIdAccessor.get(request))); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java index 522f8ae..5de8e31 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java @@ -3,11 +3,13 @@ package com.mydreamjob.deletion; import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.security.CurrentUserAccessor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.MediaType; +import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -24,7 +26,14 @@ import java.util.Set; *
  • PENDING:拦截 POST/PUT/PATCH/DELETE 写操作返回 409/9302;GET 与 OPTIONS 放行
  • * * 注销接口(POST/GET /profile/me/deletion、POST /profile/me/deletion/cancel)始终放行,交由 Controller 决策。 - * 未认证、未注销用户不受影响。 + * 未认证、未注销用户不受影响。

    + * + *

    身份解析复用唯一的 {@link CurrentUserAccessor}(R01-A):本拦截器注册在 + * {@code /**},先于所有 Controller 执行,因此绝不能自己解析主体。此前它用 + * {@code Character::isDigit} 判断后直接 {@code Long.valueOf(name)},导致 Long 溢出主体 + * (如 {@code "99999999999999999999"})抛出 {@code NumberFormatException},在到达任何 + * Controller 之前就逃逸为全站 HTTP 500。现在非法主体一律视为未认证并放行, + * 由 Controller 统一返回 401。

    */ @Component @RequiredArgsConstructor @@ -37,6 +46,7 @@ public class DeletionGuardInterceptor implements HandlerInterceptor { private static final Set WRITE_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE"); private final ObjectProvider deletionServiceProvider; + private final CurrentUserAccessor currentUserAccessor; private final ObjectMapper objectMapper; @Override @@ -45,11 +55,9 @@ public class DeletionGuardInterceptor implements HandlerInterceptor { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - // 匿名用户(principal 为 "anonymousUser",非数字 userId)等同于未认证,直接放行 - if (auth == null || !auth.isAuthenticated() - || "anonymousUser".equals(String.valueOf(auth.getPrincipal())) - || auth.getName() == null || !auth.getName().chars().allMatch(Character::isDigit)) { + // 未认证 / 匿名 / 非法或溢出主体 → 一律放行,交由 Controller 按未登录处理(401)。 + Long userId = resolveUserIdOrNull(SecurityContextHolder.getContext().getAuthentication()); + if (userId == null) { return true; } // 切片测试(@WebMvcTest)等场景可能无此服务 Bean,此时不拦截 @@ -57,7 +65,6 @@ public class DeletionGuardInterceptor implements HandlerInterceptor { if (deletionService == null) { return true; } - Long userId = Long.valueOf(auth.getName()); String path = resolvePath(request); if (DELETION_PATHS.contains(path)) { return true; @@ -72,6 +79,19 @@ public class DeletionGuardInterceptor implements HandlerInterceptor { return true; } + /** + * 解析用户 ID;任何非法主体返回 {@code null},绝不抛出。 + * + *

    该安全依赖由构造器强制注入;装配缺失时应用必须失败启动。

    + */ + private Long resolveUserIdOrNull(Authentication authentication) { + try { + return currentUserAccessor.requireUserId(authentication); + } catch (InsufficientAuthenticationException e) { + return null; + } + } + private boolean reject(HttpServletResponse response, ErrorCode errorCode, HttpServletRequest request) throws Exception { response.setStatus(409); @@ -92,4 +112,4 @@ public class DeletionGuardInterceptor implements HandlerInterceptor { } return uri; } -} \ 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/feedback/CandidateFeedbackRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackRepository.java index b0d0c95..00f485d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackRepository.java @@ -4,11 +4,14 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; +import java.util.List; @Repository public interface CandidateFeedbackRepository extends JpaRepository { Optional findByApplicationId(Long applicationId); + List findByApplicationIdIn(List applicationIds); + boolean existsByApplicationId(Long applicationId); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java index 19728c3..09c5429 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java @@ -10,6 +10,7 @@ import com.mydreamjob.feedback.dto.CandidateFeedbackRequest; import com.mydreamjob.feedback.dto.CandidateFeedbackResponse; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobService; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -28,6 +29,8 @@ public class CandidateFeedbackService { private final CandidateFeedbackRepository feedbackRepository; private final ApplicationRepository applicationRepository; private final JobService jobService; + /** R01-D4-1:候选人反馈创建后登记评分快照刷新(INTEGRITY 维度)。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; private final CompanyRepository companyRepository; private static final ObjectMapper mapper = new ObjectMapper(); @@ -74,7 +77,10 @@ public class CandidateFeedbackService { .rating(req.getRating()).content(req.getContent()) .tags(toJson(req.getTags())) .build(); - return toResponse(feedbackRepository.save(fb)); + CandidateFeedback saved = feedbackRepository.save(fb); + // R01-D4-1:候选人反馈计数/均值影响 INTEGRITY 维度 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(app.getStudentUserId(), java.time.LocalDateTime.now()); + return toResponse(saved); } // ── 查询 ────────────────────────────────────────────────── @@ -110,7 +116,10 @@ public class CandidateFeedbackService { if (req.getRating() != null) fb.setRating(req.getRating()); if (req.getContent() != null) fb.setContent(req.getContent()); if (req.getTags() != null) fb.setTags(toJson(req.getTags())); - return toResponse(feedbackRepository.save(fb)); + CandidateFeedback updated = feedbackRepository.save(fb); + // R01-D4-2 F2:修改 rating 会改变 AVG(rating) → 与写操作同事务登记刷新。 + scoringRefreshRequestService.requestRefresh(app.getStudentUserId(), java.time.LocalDateTime.now()); + return toResponse(updated); } // ── 删除 ────────────────────────────────────────────────── @@ -123,7 +132,11 @@ public class CandidateFeedbackService { CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND)); + // R01-D4-2 F2:删除前先固定候选人标识,删除后仍能正确登记。 + Long studentUserId = app.getStudentUserId(); feedbackRepository.delete(fb); + // COUNT(*) 与 AVG(rating) 同时变化 → 与删除操作同事务登记刷新。 + scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now()); } // ── 视图 ────────────────────────────────────────────────── diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewController.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewController.java index 8246226..01df0e2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewController.java +++ b/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>>> listByApplication( @PathVariable Long applicationId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); List interviews = interviewService.listByApplication(applicationId, userId); List> 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 result = interviewService.listMyInterviews(userId, status, from, to, page, size); Map 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>> 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 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>>> timeline( @PathVariable Long interviewId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); List> 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"); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistoryRepository.java index aa9de0c..79f8a02 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistoryRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistoryRepository.java @@ -9,4 +9,7 @@ import java.util.List; public interface InterviewStatusHistoryRepository extends JpaRepository { List findByInterviewIdOrderByCreatedAtAsc(Long interviewId); -} \ No newline at end of file + + List findByInterviewIdInOrderByInterviewIdAscCreatedAtAsc( + List interviewIds); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java index 0dfb616..68bf1a2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java @@ -2,19 +2,17 @@ package com.mydreamjob.interviewscore; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.interviewscore.dto.InterviewScoreHistoryItem; import com.mydreamjob.interviewscore.dto.InterviewScoreResponse; import com.mydreamjob.interviewscore.dto.InterviewScoreSaveRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -31,6 +29,8 @@ import java.util.List; public class InterviewScoreController { private final InterviewScoreService interviewScoreService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "保存评分草稿", description = "企业为已完成的面试保存评分草稿(支持重复保存)") @PutMapping("/interviews/{interviewId}/score") @@ -38,10 +38,10 @@ public class InterviewScoreController { @PathVariable Long interviewId, @Valid @RequestBody InterviewScoreSaveRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); InterviewScoreResponse data = interviewScoreService.saveDraft(userId, interviewId, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "查看评分", description = "企业 owner 可查看草稿与已提交;学生只能在提交后查看") @@ -49,9 +49,9 @@ public class InterviewScoreController { public ResponseEntity> get( @PathVariable Long interviewId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - interviewScoreService.getScore(userId, interviewId), getRequestId(request))); + interviewScoreService.getScore(userId, interviewId), requestIdAccessor.get(request))); } @Operation(summary = "提交评分", description = "将草稿提交为终态,提交后不可修改") @@ -59,10 +59,10 @@ public class InterviewScoreController { public ResponseEntity> submit( @PathVariable Long interviewId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( interviewScoreService.submitScore(userId, interviewId, AuditContext.from(request)), - getRequestId(request))); + requestIdAccessor.get(request))); } @Operation(summary = "评分历史", description = "查看评分快照历史;无历史返回空数组") @@ -70,20 +70,8 @@ public class InterviewScoreController { public ResponseEntity>> history( @PathVariable Long interviewId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - interviewScoreService.getHistory(userId, interviewId), getRequestId(request))); + interviewScoreService.getHistory(userId, interviewId), requestIdAccessor.get(request))); } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/FavoriteController.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/FavoriteController.java index 127a127..bdd95da 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/FavoriteController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/FavoriteController.java @@ -1,14 +1,14 @@ package com.mydreamjob.job; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.HashMap; @@ -20,15 +20,17 @@ import java.util.Map; public class FavoriteController { private final JobFavoriteService favoriteService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "收藏岗位", description = "仅 STUDENT;仅 PUBLISHED;幂等") @PostMapping("/jobs/{jobId}/favorite") public ResponseEntity>> favorite( @PathVariable Long jobId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map result = favoriteService.favorite(userId, jobId); - return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request))); } @Operation(summary = "取消收藏", description = "仅 STUDENT;幂等,未收藏也返回 200") @@ -36,9 +38,9 @@ public class FavoriteController { public ResponseEntity>> unfavorite( @PathVariable Long jobId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map result = favoriteService.unfavorite(userId, jobId); - return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request))); } @Operation(summary = "我的收藏列表", description = "仅 STUDENT;分页,按收藏时间倒序") @@ -47,7 +49,7 @@ public class FavoriteController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Page> result = favoriteService.listFavorites(userId, page, size); Map data = new HashMap<>(); data.put("items", result.getContent()); @@ -55,19 +57,6 @@ public class FavoriteController { data.put("page", result.getNumber()); data.put("size", result.getSize()); data.put("totalPages", result.getTotalPages()); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new com.mydreamjob.common.exception.BusinessException( - com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java index d9ad7b6..940a6d8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java @@ -1,12 +1,15 @@ package com.mydreamjob.job; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyService; import com.mydreamjob.job.dto.JobCreateRequest; import com.mydreamjob.job.dto.JobSearchItem; import com.mydreamjob.job.dto.JobStatusRequest; import com.mydreamjob.job.dto.JobUpdateRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -14,8 +17,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.HashMap; @@ -31,9 +32,12 @@ import java.util.Map; @RequestMapping("/jobs") @RequiredArgsConstructor public class JobController { + /** R01-A6:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; private final JobService jobService; private final CompanyService companyService; + private final CurrentUserAccessor currentUserAccessor; // ── 企业端:创建岗位 ──────────────────────────────────────── @@ -42,9 +46,9 @@ public class JobController { public ResponseEntity>> createJob( @Valid @RequestBody JobCreateRequest req, HttpServletRequest request) { - Long companyId = resolveCompanyId(request); + Long companyId = resolveCompanyId(); Map view = jobService.toView(jobService.createJob(companyId, req)); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } // ── 企业端:编辑岗位 ──────────────────────────────────────── @@ -55,9 +59,9 @@ public class JobController { @PathVariable Long jobId, @Valid @RequestBody JobUpdateRequest req, HttpServletRequest request) { - Long companyId = resolveCompanyId(request); + Long companyId = resolveCompanyId(); Map view = jobService.toView(jobService.updateJob(companyId, jobId, req)); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } // ── 企业端:状态流转 ──────────────────────────────────────── @@ -68,9 +72,9 @@ public class JobController { @PathVariable Long jobId, @Valid @RequestBody JobStatusRequest req, HttpServletRequest request) { - Long companyId = resolveCompanyId(request); + Long companyId = resolveCompanyId(); Map view = jobService.toView(jobService.transitionStatus(companyId, jobId, req)); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } // ── 企业端:查询岗位详情 ──────────────────────────────────── @@ -80,11 +84,11 @@ public class JobController { public ResponseEntity>> getJob( @PathVariable Long jobId, HttpServletRequest request) { - Long companyId = resolveCompanyId(request); + Long companyId = resolveCompanyId(); // 验证归属后返回 jobService.assertOwnerOrThrow(companyId, jobId); Map view = jobService.toView(jobService.getJob(jobId)); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } // ── 公开端:岗位列表 ──────────────────────────────────────── @@ -110,7 +114,7 @@ public class JobController { 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))); } // ── 公开端:岗位详情 ──────────────────────────────────────── @@ -121,7 +125,7 @@ public class JobController { @PathVariable Long jobId, HttpServletRequest request) { Map view = jobService.getPublicJob(jobId); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } // ── 公开端:高级搜索 ──────────────────────────────────────── @@ -148,7 +152,7 @@ public class JobController { 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))); } // ── 公开端:筛选选项 ──────────────────────────────────────── @@ -158,27 +162,19 @@ public class JobController { public ResponseEntity>> getFilters( HttpServletRequest request) { Map options = jobService.getFilterOptions(); - return ResponseEntity.ok(ApiResponse.ok(options, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(options, requestIdAccessor.get(request))); } // ── 辅助方法 ──────────────────────────────────────────────── - private Long resolveCompanyId(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); - } - Long userId = Long.valueOf(auth.getName()); + private Long resolveCompanyId() { + // 身份只来自 JWT/SecurityContext;非法主体统一为未登录语义(401),不抛 NumberFormatException + Long userId = currentUserAccessor.requireUserId(); Company company = companyService.findCompanyByOwnerUserId(userId); if (company == null) { - throw new com.mydreamjob.common.exception.BusinessException( - com.mydreamjob.common.exception.ErrorCode.COMPANY_NOT_FOUND); + throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); } return company.getId(); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java index 0dd3959..da171b4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java @@ -20,6 +20,7 @@ import java.util.Map; @RequiredArgsConstructor public class JobFavoriteService { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final JobFavoriteRepository favoriteRepository; private final JobService jobService; private final UserRepository userRepository; @@ -27,22 +28,11 @@ public class JobFavoriteService { private static final int MAX_PAGE_SIZE = 50; // ── 角色校验 ────────────────────────────────────────────── - - private void assertStudentRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isStudent = user.getRoles().stream() - .anyMatch(r -> r == User.Role.STUDENT); - if (!isStudent) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } - } - // ── 收藏 ────────────────────────────────────────────────── @Transactional public Map favorite(Long studentUserId, Long jobId) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); Job job = jobService.getJob(jobId); if (!"PUBLISHED".equals(job.getStatus())) { @@ -76,7 +66,7 @@ public class JobFavoriteService { @Transactional public Map unfavorite(Long studentUserId, Long jobId) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); // 幂等:不存在时也返回 200 favoriteRepository.deleteByJobIdAndStudentUserId(jobId, studentUserId); @@ -86,7 +76,7 @@ public class JobFavoriteService { // ── 查询收藏列表 ────────────────────────────────────────── public Page> listFavorites(Long studentUserId, int page, int size) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); // size<1 直接 400,避免 PageRequest.of 抛 IllegalArgumentException 导致 500 if (size < 1) throw new BusinessException(ErrorCode.BAD_REQUEST); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java index a2df424..17fecd3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.domain.Specification; import java.util.ArrayList; import java.util.List; +import java.util.Locale; /** * 岗位搜索动态查询 Specification。 @@ -51,21 +52,24 @@ public class JobSearchSpecification { if (keyword != null && !keyword.isBlank()) { String pattern = "%" + keyword.trim() + "%"; Predicate keywordPred = cb.or( - cb.like(cb.lower(root.get(TITLE)), pattern.toLowerCase()), - cb.like(cb.lower(root.get(DESCRIPTION)), pattern.toLowerCase()), - cb.like(cb.lower(root.get(REQUIREMENTS)), pattern.toLowerCase()) + cb.like(cb.lower(root.get(TITLE)), pattern.toLowerCase(Locale.ROOT)), + cb.like(cb.lower(root.get(DESCRIPTION)), pattern.toLowerCase(Locale.ROOT)), + cb.like(cb.lower(root.get(REQUIREMENTS)), pattern.toLowerCase(Locale.ROOT)) ); predicates.add(keywordPred); } - // 城市精确匹配 + // 城市:trim 后忽略大小写精确匹配。 + // 显式 lower 可避免 MySQL 的大小写不敏感排序规则与 H2 默认比较规则产生差异。 if (city != null && !city.isBlank()) { - predicates.add(cb.equal(root.get(CITY), city.trim())); + predicates.add(cb.equal(cb.lower(root.get(CITY)), city.trim().toLowerCase(Locale.ROOT))); } - // 用工类型精确匹配 + // 用工类型:trim 后忽略大小写精确匹配,与线上 MySQL 既有比较结果保持一致。 if (employmentType != null && !employmentType.isBlank()) { - predicates.add(cb.equal(root.get(EMPLOYMENT_TYPE), employmentType.trim())); + predicates.add(cb.equal( + cb.lower(root.get(EMPLOYMENT_TYPE)), + employmentType.trim().toLowerCase(Locale.ROOT))); } // 薪资范围:岗位 salaryMax >= 用户 salaryMin @@ -86,4 +90,4 @@ public class JobSearchSpecification { return cb.and(predicates.toArray(new Predicate[0])); }; } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java index 8effaaa..b36913e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java @@ -119,6 +119,11 @@ public class JobService { /** * 公开岗位列表:仅返回 PUBLISHED,支持筛选和分页 + * + *

    DEF-01:关键词在此处规范化,使其与 {@code GET /jobs/search} + * ({@code JobSearchSpecification} 的 {@code keyword.trim()} + {@code isBlank()} 判空) + * 的输入语义一致 —— 只去两端空白,空串与纯空白等同「未传关键词」, + * 不折叠中间空格、不改大小写,也不改变任何过滤/排序/分页语义。

    */ public Page> listPublishedJobs(String keyword, String city, String employmentType, @@ -127,8 +132,9 @@ public class JobService { if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) { throw new BusinessException(ErrorCode.BAD_REQUEST); } + String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.trim(); Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); - Page result = jobRepository.findPublishedJobs(keyword, city, employmentType, + Page result = jobRepository.findPublishedJobs(normalizedKeyword, city, employmentType, salaryMin, salaryMax, pageable); return result.map(this::toPublicView); } 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/match/MatchService.java b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java index eadd129..93ee9a9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java @@ -29,6 +29,7 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class MatchService { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final JobService jobService; private final StudentPreferenceRepository preferenceRepository; private final StudentProfileRepository profileRepository; @@ -38,15 +39,6 @@ public class MatchService { private static final String RULES_VERSION = "RULES_V0_1"; /** 岗位匹配诊断仅 STUDENT 可用 */ - private void assertStudentRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isStudent = user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT); - if (!isStudent) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } - } - // ── 匹配规则 ────────────────────────────────────────────── public Map diagnose(Long studentUserId, Long jobId) { @@ -54,7 +46,7 @@ public class MatchService { } public Map diagnose(Long studentUserId, Long jobId, com.mydreamjob.audit.AuditContext ctx) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); // 岗位必须存在 Job job = jobService.getJob(jobId); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java index 37a1f54..814d29d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java @@ -11,12 +11,46 @@ import org.springframework.stereotype.Repository; import jakarta.persistence.LockModeType; import java.time.LocalDateTime; +import java.util.List; @Repository public interface MessageRepository extends JpaRepository { Page findByConversationIdOrderByCreatedAtDesc(Long conversationId, Pageable pageable); + List findByConversationIdInOrderByConversationIdAscCreatedAtDesc(List conversationIds); + + interface ConversationUnreadCount { + Long getConversationId(); + Long getUnreadCount(); + } + + /** + * 批量统计各会话最近 500 条消息中的未读数,保持历史 countUnread 的窗口与撤回语义。 + */ + @Query(value = """ + SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount + FROM ( + SELECT m.id, m.conversation_id, m.status, + ROW_NUMBER() OVER ( + PARTITION BY m.conversation_id + ORDER BY m.created_at DESC + ) AS row_no + FROM messages m + WHERE m.conversation_id IN (:conversationIds) + ) ranked + LEFT JOIN message_reads mr + ON mr.message_id = ranked.id + AND mr.reader_user_id = :readerUserId + WHERE ranked.row_no <= 500 + AND (ranked.status IS NULL OR ranked.status <> 'RECALLED') + AND mr.id IS NULL + GROUP BY ranked.conversation_id + """, nativeQuery = true) + List countUnreadByConversationIds( + @Param("conversationIds") List conversationIds, + @Param("readerUserId") Long readerUserId); + /** * 条件更新未命中后的当前读,用于区分并发请求已完成撤回与真实冲突。 * PESSIMISTIC_WRITE 强制读取提交后的最新行,避免 MySQL REPEATABLE READ 使用旧快照。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java index 21e3a12..9392101 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java +++ b/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>>> 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>> 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>> 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>> 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>> 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"); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java index a4164d4..b443d95 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java @@ -45,6 +45,7 @@ public class MessagingService { private final BlockRelationRepository blockRelationRepository; private final SecurityAuditService securityAuditService; private final AuditService auditService; + private final com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; private final Clock clock; private final MessageViewMapper messageViewMapper; private final ObjectMapper objectMapper; @@ -71,11 +72,14 @@ public class MessagingService { if (memberships.isEmpty()) return List.of(); List convIds = memberships.stream().map(ConversationMember::getConversationId).toList(); List convs = conversationRepository.findAllById(convIds); + Map unreadByConversation = new HashMap<>(); + messageRepository.countUnreadByConversationIds(convIds, userId).forEach(row -> + unreadByConversation.put(row.getConversationId(), row.getUnreadCount())); convs.sort((a, b) -> b.getUpdatedAt().compareTo(a.getUpdatedAt())); List> result = new ArrayList<>(); for (Conversation c : convs) { Map v = toView(c, userId); - v.put("unreadCount", countUnread(c.getId(), userId)); + v.put("unreadCount", unreadByConversation.getOrDefault(c.getId(), 0L)); result.add(v); } return result; @@ -188,6 +192,14 @@ public class MessagingService { conversationRepository.findById(req.getConversationId()).ifPresent(c -> { c.setUpdatedAt(msg.getCreatedAt()); conversationRepository.save(c); }); + // R01-D4-1:成功发送后,若发送者为学生则登记评分刷新(ACTIVITY 维度按发送消息计数)。 + // 消息撤回不登记(公式只统计发送消息总数)。 + userRepository.findById(userId).ifPresent(u -> { + if (u.getRoles() != null && u.getRoles().stream() + .anyMatch(r -> r == com.mydreamjob.auth.User.Role.STUDENT)) { + scoringRefreshRequestService.requestRefresh(userId, LocalDateTime.now(clock)); + } + }); return toView(msg, userId); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java index 2951e31..2c0a9c3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java @@ -7,8 +7,6 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.HashMap; @@ -21,6 +19,11 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class NotificationController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final NotificationService notificationService; @Operation(summary = "通知列表", description = "当前用户的通知,分页,按时间倒序") @@ -29,7 +32,7 @@ public class NotificationController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Page result = notificationService.list(userId, page, size); Map data = new HashMap<>(); data.put("items", result.getContent().stream().map(notificationService::toView).collect(Collectors.toList())); @@ -37,7 +40,7 @@ public class NotificationController { data.put("page", result.getNumber()); data.put("size", result.getSize()); data.put("totalPages", result.getTotalPages()); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "标记已读", description = "标记单条通知已读(幂等)") @@ -45,36 +48,25 @@ public class NotificationController { public ResponseEntity>> markRead( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); notificationService.markRead(userId, id); - return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "marked as read"), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "marked as read"), requestIdAccessor.get(request))); } @Operation(summary = "全部已读", description = "标记当前用户全部通知已读(幂等)") @PostMapping("/read-all") public ResponseEntity>> markAllRead(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); int marked = notificationService.markAllRead(userId); - return ResponseEntity.ok(ApiResponse.ok(Map.of("marked", marked), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(Map.of("marked", marked), requestIdAccessor.get(request))); } @Operation(summary = "未读数", description = "当前用户未读通知数") @GetMapping("/unread-count") public ResponseEntity>> unreadCount(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); long count = notificationService.unreadCount(userId); - return ResponseEntity.ok(ApiResponse.ok(Map.of("unreadCount", count), getRequestId(request))); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) - throw new com.mydreamjob.common.exception.BusinessException( - com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED); - return Long.valueOf(auth.getName()); + return ResponseEntity.ok(ApiResponse.ok(Map.of("unreadCount", count), requestIdAccessor.get(request))); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java index 3a835c5..d1b59fd 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java +++ b/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>> 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 result = offerService.listOffers(userId, status, applicationId, page, size); Map 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>> 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>>> timeline( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); List> 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>> remind( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); OfferReminder reminder = offerService.remind(userId, id, AuditContext.from(request)); Map 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>>> listCounterOffers( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); List> 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>>> compare( @RequestParam("ids") String idsParam, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); List ids = parseIds(idsParam); List> 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>> statistics( @RequestParam(required = false, defaultValue = "6") Integer months, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map 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"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java index 4d44423..a006531 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java @@ -35,6 +35,13 @@ public class OfferExpiryWorker { private final OfferCounterOfferRepository counterOfferRepository; private final CompanyRepository companyRepository; private final NotificationService notificationService; + /** + * R01-D4-2 F1:Offer 自动过期是评分输入的真实写侧事件 + * ({@code CandidateScoringCalculator#queryOffers} 以 {@code status='EXPIRED'} 计数参与 + * INTEGRITY 维度),必须与状态变更登记在同一事务。 + */ + private final com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService + scoringRefreshRequestService; /** 到期自动失效:SENT/NEGOTIATING → EXPIRED */ @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -59,6 +66,8 @@ public class OfferExpiryWorker { .build()); // 同步将 PENDING 还价标记为 EXPIRED counterOfferRepository.expirePendingByOfferId(saved.getId()); + // R01-D4-2 F1:状态变更与刷新登记同一事务——任一步失败则二者一起回滚。 + scoringRefreshRequestService.requestRefresh(saved.getCandidateId(), LocalDateTime.now()); notifyExpired(saved); log.info("Offer {} expired ({})", saved.getId(), status); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java index c2bdc14..e0f52d8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java @@ -13,6 +13,7 @@ import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobService; import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.offer.dto.CounterOfferCreateRequest; import com.mydreamjob.offer.dto.OfferCreateRequest; import com.mydreamjob.offer.dto.OfferStatusRequest; @@ -64,6 +65,8 @@ public class OfferService { private final UserRepository userRepository; private final NotificationService notificationService; private final AuditService auditService; + /** R01-D4-1:Offer 状态变更(ACCEPTED/EXPIRED 履约聚合)后登记评分快照刷新。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; @PersistenceContext private EntityManager entityManager; @@ -297,6 +300,8 @@ public class OfferService { notifyStatusChange(saved, fromStatus, to); auditService.log(operatorUserId, "UPDATE_OFFER_STATUS", "OFFER", saved.getId(), "Offer status changed: " + fromStatus + " → " + to.name(), ctx); + // R01-D4-1:Offer 状态变更影响 ACCEPTED/EXPIRED 履约聚合(INTEGRITY)→ 登记刷新。 + scoringRefreshRequestService.requestRefresh(offer.getCandidateId(), java.time.LocalDateTime.now()); return saved; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java index 97a1772..eb090c8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java @@ -13,6 +13,8 @@ public interface OfferStatusHistoryRepository extends JpaRepository findByOfferIdOrderByCreatedAtAsc(Long offerId); + List findByOfferIdInOrderByOfferIdAscCreatedAtAsc(List offerIds); + /** 查询某企业指定时间范围内的状态历史(用于统计平均响应时间) */ @Query("SELECT h FROM OfferStatusHistory h, Offer o " + "WHERE h.offerId = o.id AND o.companyId = :companyId AND h.createdAt >= :since " + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java index e53f6ed..24d5b57 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java @@ -1,15 +1,15 @@ package com.mydreamjob.preference; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.preference.dto.StudentPreferenceRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.Map; @@ -21,13 +21,15 @@ import java.util.Map; public class PreferenceController { private final PreferenceService preferenceService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "查询求职偏好", description = "仅 STUDENT;无记录时返回空偏好对象") @GetMapping("/preferences") public ResponseEntity>> getPreferences(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = preferenceService.getPreferences(userId); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } @Operation(summary = "保存求职偏好", description = "仅 STUDENT;upsert,完全替换传入字段") @@ -35,21 +37,8 @@ public class PreferenceController { public ResponseEntity>> savePreferences( @Valid @RequestBody StudentPreferenceRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = preferenceService.savePreferences(userId, req); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new com.mydreamjob.common.exception.BusinessException( - com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java index 06aca67..9ff2007 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java @@ -4,15 +4,18 @@ import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.preference.dto.StudentPreferenceRequest; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.security.UserRoleGuard; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -22,20 +25,17 @@ import java.util.Map; @RequiredArgsConstructor public class PreferenceService { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final StudentPreferenceRepository preferenceRepository; private final UserRepository userRepository; + /** R01-D4-1:求职偏好更新后登记评分快照刷新(COMPLIANCE 维度)。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; private static final ObjectMapper mapper = new ObjectMapper(); // ── 角色校验 ────────────────────────────────────────────── public void assertStudentRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isStudent = user.getRoles().stream() - .anyMatch(r -> r == User.Role.STUDENT); - if (!isStudent) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } + userRoleGuard.requireStudent(userId); } // ── 查询 ────────────────────────────────────────────────── @@ -68,6 +68,8 @@ public class PreferenceService { if (req.getRemoteAllowed() != null) pref.setRemoteAllowed(req.getRemoteAllowed()); StudentPreference saved = preferenceRepository.save(pref); + // R01-D4-1:偏好更新影响 COMPLIANCE 维度 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(studentUserId, LocalDateTime.now()); return toView(saved); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java index 8a16618..be04d75 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java @@ -1,10 +1,10 @@ package com.mydreamjob.privacy; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.privacy.dto.PrivacySettingsResponse; import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -16,8 +16,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.GetMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -31,6 +29,8 @@ import org.springframework.web.bind.annotation.RestController; public class PrivacySettingsController { private final PrivacySettingsService privacySettingsService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation( summary = "查询当前学生隐私设置", @@ -77,9 +77,9 @@ public class PrivacySettingsController { }) @GetMapping public ResponseEntity> getMyPrivacySettings(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); PrivacySettingsResponse response = privacySettingsService.getCurrentUserSettings(userId); - return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request))); } @Operation( @@ -139,20 +139,8 @@ public class PrivacySettingsController { public ResponseEntity> updateMyPrivacySettings( @Valid @RequestBody PrivacySettingsUpdateRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); PrivacySettingsResponse response = privacySettingsService.updateCurrentUserSettings(userId, req); - 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 BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request))); } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java index d99c992..674f7d2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java @@ -1,16 +1,14 @@ package com.mydreamjob.privacy; -import com.mydreamjob.application.Application; -import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.application.CompanyResumeAccessPolicy; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; -import com.mydreamjob.company.Company; -import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.privacy.dto.PrivacySettingsResponse; import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; +import com.mydreamjob.security.UserRoleGuard; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -18,8 +16,6 @@ import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Set; - /** * 学生隐私设置服务。 * @@ -31,24 +27,20 @@ import java.util.Set; @RequiredArgsConstructor public class PrivacySettingsService { - /** 视为"非有效投递"的状态:撤销或已拒绝的投递不允许企业查看简历 */ - private static final Set INEFFECTIVE_STATUSES = Set.of("WITHDRAWN", "REJECTED"); - + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final StudentPrivacySettingsRepository privacyRepository; private final UserRepository userRepository; - private final ApplicationRepository applicationRepository; - private final CompanyRepository companyRepository; private final AuditService auditService; + /** + * S03-B:企业简历可见性统一由失败关闭的授权策略判定。 + * 策略强制校验「投递所属岗位属于该企业」,修复了此前只校验学生与状态、 + * 不校验企业归属的缺陷。 + */ + private final CompanyResumeAccessPolicy companyResumeAccessPolicy; /** 校验当前用户必须为 STUDENT,否则抛 FORBIDDEN。 */ public void assertStudentRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isStudent = user.getRoles() != null - && user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT); - if (!isStudent) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } + userRoleGuard.requireStudent(userId); } /** @@ -105,33 +97,20 @@ public class PrivacySettingsService { } /** - * 企业是否可查看某学生的简历。 + * 企业是否可查看某学生的简历(S03-B:委托给失败关闭的授权策略)。 + * + *

    判定需要同时满足:投递存在、投递学生与传入 {@code studentUserId} 一致、 + * 投递所属岗位属于 {@code companyId},再按隐私设置决定:

    * *
      - *
    • PRIVATE:企业不可见
    • - *
    • APPLIED_COMPANIES:仅当该学生在该企业存在有效投递时可见(WITHDRAWN/REJECTED 不算)
    • - *
    • VERIFIED_COMPANIES:仅当企业 verified=true 时可见
    • + *
    • 无记录 / null / PRIVATE:不可见
    • + *
    • APPLIED_COMPANIES:岗位属于该企业且投递状态为 + * SUBMITTED/VIEWED/INTERVIEW/HIRED
    • + *
    • VERIFIED_COMPANIES:该企业 {@code verified=true} 且拥有该投递岗位
    • *
    */ public boolean canCompanyViewResume(Long studentUserId, Long companyId, Long applicationId) { - StudentPrivacySettings settings = privacyRepository.findByStudentUserId(studentUserId).orElse(null); - ResumeVisibility visibility = (settings == null || settings.getResumeVisibility() == null) - ? ResumeVisibility.PRIVATE : settings.getResumeVisibility(); - switch (visibility) { - case VERIFIED_COMPANIES: - Company company = companyRepository.findById(companyId).orElse(null); - return company != null && Boolean.TRUE.equals(company.getVerified()); - case APPLIED_COMPANIES: - // 投递表不含 companyId,以 applicationId 定位具体投递校验有效状态(WITHDRAWN/REJECTED 不算有效) - Application app = applicationRepository.findById(applicationId).orElse(null); - if (app == null || !studentUserId.equals(app.getStudentUserId())) { - return false; - } - return !INEFFECTIVE_STATUSES.contains(app.getStatus()); - case PRIVATE: - default: - return false; - } + return companyResumeAccessPolicy.isVisible(studentUserId, companyId, applicationId); } private void validate(PrivacySettingsUpdateRequest req) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java index 4a5a8c3..63a088f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java @@ -2,7 +2,10 @@ package com.mydreamjob.profile; import com.mydreamjob.profile.dto.StudentProfileResponse; import com.mydreamjob.profile.dto.StudentProfileUpdateRequest; +import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -17,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 org.springframework.web.multipart.MultipartFile; @@ -32,6 +33,8 @@ import java.util.Map; public class ProfileController { private final ProfileService profileService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; // ── 资料 CRUD ──────────────────────────────────────────── @@ -80,10 +83,10 @@ public class ProfileController { }) @GetMapping("/me") public ResponseEntity> getMyProfile(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); profileService.assertStudentRole(userId); StudentProfileResponse response = profileService.toResponse(profileService.findProfileByUserId(userId)); - return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request))); } @Operation(summary = "更新学生资料", description = "仅更新传入的非 null 字段;不存在则自动创建") @@ -91,9 +94,9 @@ public class ProfileController { public ResponseEntity>> updateMyProfile( @Valid @RequestBody StudentProfileUpdateRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = profileService.toView(profileService.updateProfile(userId, req)); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } // ── 简历管理 ────────────────────────────────────────────── @@ -103,19 +106,19 @@ public class ProfileController { public ResponseEntity>> uploadResume( @RequestParam("file") MultipartFile file, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = profileService.uploadResume(userId, file); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } @Operation(summary = "下载简历", description = "下载当前学生上传的简历文件,仅 STUDENT 角色") @GetMapping("/me/resume") public ResponseEntity downloadResume(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ProfileService.ResumeFile rf = profileService.getResume(userId); MediaType mediaType = resolveContentType(rf.getContentType()); ContentDisposition cd = ContentDisposition.inline() - .filename(rf.getFilename(), StandardCharsets.UTF_8) + .filename(ResumeFilenameSanitizer.forDownload(rf.getFilename(), "resume"), StandardCharsets.UTF_8) .build(); return ResponseEntity.ok() .contentType(mediaType) @@ -126,27 +129,14 @@ public class ProfileController { @Operation(summary = "删除简历", description = "删除当前学生的简历文件并清空 resumeUrl,仅 STUDENT 角色") @DeleteMapping("/me/resume") public ResponseEntity>> deleteResume(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); profileService.deleteResume(userId); return ResponseEntity.ok(ApiResponse.ok( - Map.of("message", "resume deleted"), getRequestId(request))); + Map.of("message", "resume deleted"), 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"); - } - private MediaType resolveContentType(String contentType) { if (contentType == null) return MediaType.APPLICATION_OCTET_STREAM; try { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java index f1345bd..c1c2498 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java @@ -6,55 +6,46 @@ import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.profile.dto.StudentProfileResponse; import com.mydreamjob.profile.dto.StudentProfileUpdateRequest; +import com.mydreamjob.profile.storage.ResumeFileValidator; +import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; import com.mydreamjob.profile.storage.ResumeStorageService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import lombok.Getter; +import com.mydreamjob.security.UserRoleGuard; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; @Slf4j @Service @RequiredArgsConstructor public class ProfileService { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final StudentProfileRepository profileRepository; private final ResumeStorageService resumeStorageService; private final UserRepository userRepository; + private final ResumeFileValidator resumeFileValidator; + /** R01-D4-1:学生资料/简历更新后登记评分快照刷新。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final Set ALLOWED_CONTENT_TYPES = Set.of( - "application/pdf", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ); - private static final Set ALLOWED_EXTENSIONS = Set.of(".pdf", ".doc", ".docx"); - private static final Map EXT_TO_CONTENT_TYPE = Map.of( - ".pdf", "application/pdf", - ".doc", "application/msword", - ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ); - private static final long MAX_RESUME_SIZE = 10 * 1024 * 1024L; - // ── 角色校验 ────────────────────────────────────────────── public void assertStudentRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isStudent = user.getRoles().stream() - .anyMatch(r -> r == User.Role.STUDENT); - if (!isStudent) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } + userRoleGuard.requireStudent(userId); } // ── 学生资料 CRUD ──────────────────────────────────────── @@ -75,7 +66,10 @@ public class ProfileService { if (req.getGraduationYear() != null) profile.setGraduationYear(req.getGraduationYear().shortValue()); if (req.getResumeUrl() != null) profile.setResumeUrl(req.getResumeUrl()); if (req.getSkills() != null) profile.setSkillsJson(toValidJson(req.getSkills())); - return profileRepository.save(profile); + StudentProfile saved = profileRepository.save(profile); + // R01-D4-1:资料(含学历/学校/技能/简历链接)变化影响 8 维评分 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(userId, LocalDateTime.now()); + return saved; } /** @@ -94,32 +88,7 @@ public class ProfileService { // ── 简历校验 ────────────────────────────────────────────── public void validateResumeFile(MultipartFile file) { - if (file == null || file.isEmpty()) { - throw new BusinessException(ErrorCode.BAD_REQUEST); - } - if (file.getSize() > MAX_RESUME_SIZE) { - throw new BusinessException(ErrorCode.BAD_REQUEST); - } - - String originalName = file.getOriginalFilename(); - if (originalName == null || !originalName.contains(".")) { - throw new BusinessException(ErrorCode.BAD_REQUEST); - } - String ext = originalName.substring(originalName.lastIndexOf('.')).toLowerCase(); - if (!ALLOWED_EXTENSIONS.contains(ext)) { - throw new BusinessException(ErrorCode.BAD_REQUEST); - } - - String contentType = file.getContentType(); - if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) { - throw new BusinessException(ErrorCode.BAD_REQUEST); - } - - // 验证扩展名与 Content-Type 一致 - String expectedContentType = EXT_TO_CONTENT_TYPE.get(ext); - if (!contentType.equals(expectedContentType)) { - throw new BusinessException(ErrorCode.BAD_REQUEST); - } + resumeFileValidator.validate(file); } // ── 简历上传 ────────────────────────────────────────────── @@ -132,17 +101,27 @@ public class ProfileService { StudentProfile profile = profileRepository.findByUserId(userId) .orElse(StudentProfile.builder().userId(userId).build()); - // 删除旧文件 - if (profile.getResumeUrl() != null) { - resumeStorageService.delete(profile.getResumeUrl()); - } - + String oldPath = profile.getResumeUrl(); String newPath = resumeStorageService.store(userId, file); + if (newPath == null || newPath.isBlank()) { + throw new IllegalStateException("resume storage returned no path"); + } + boolean synchronizationRegistered = registerReplacementLifecycle(oldPath, newPath); profile.setResumeUrl(newPath); - profile.setResumeOriginalName(file.getOriginalFilename()); + profile.setResumeOriginalName(ResumeFilenameSanitizer.forDownload(file.getOriginalFilename(), "resume")); profile.setResumeContentType(file.getContentType()); profile.setResumeSize(file.getSize()); - profileRepository.save(profile); + try { + profileRepository.save(profile); + } catch (RuntimeException e) { + if (!synchronizationRegistered && !newPath.equals(oldPath)) { + resumeStorageService.delete(newPath); + } + throw e; + } + if (!synchronizationRegistered && oldPath != null && !oldPath.equals(newPath)) { + resumeStorageService.delete(oldPath); + } return toResumeView(profile); } @@ -170,12 +149,13 @@ public class ProfileService { StudentProfile profile = profileRepository.findByUserId(userId) .orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND)); if (profile.getResumeUrl() != null) { - resumeStorageService.delete(profile.getResumeUrl()); + String storagePath = profile.getResumeUrl(); profile.setResumeUrl(null); profile.setResumeOriginalName(null); profile.setResumeContentType(null); profile.setResumeSize(null); profileRepository.save(profile); + deleteFileAfterCommit(storagePath); } } @@ -242,4 +222,39 @@ public class ProfileService { this.content = content; } } + + private boolean registerReplacementLifecycle(String oldPath, String newPath) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + return false; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + if (oldPath != null && !oldPath.equals(newPath)) { + resumeStorageService.delete(oldPath); + } + } + + @Override + public void afterCompletion(int status) { + if (status != TransactionSynchronization.STATUS_COMMITTED && !newPath.equals(oldPath)) { + resumeStorageService.delete(newPath); + } + } + }); + return true; + } + + private void deleteFileAfterCommit(String storagePath) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + resumeStorageService.delete(storagePath); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + resumeStorageService.delete(storagePath); + } + }); + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/LocalResumeStorageService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/LocalResumeStorageService.java index 17d8b8f..2d84fa2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/LocalResumeStorageService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/LocalResumeStorageService.java @@ -49,15 +49,27 @@ public class LocalResumeStorageService implements ResumeStorageService { String ext = getExtension(originalName); String storedName = userId + "_" + UUID.randomUUID().toString().substring(0, 8) + ext; Path target = basePath.resolve(storedName).normalize(); + Path temporary = basePath.resolve(storedName + ".uploading").normalize(); // 禁止路径穿越 - if (!target.startsWith(basePath)) { + if (!target.startsWith(basePath) || !temporary.startsWith(basePath)) { throw new BusinessException(ErrorCode.BAD_REQUEST); } try { - Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING); + // Never expose a partially copied file under its final storage key. + Files.copy(file.getInputStream(), temporary, StandardCopyOption.REPLACE_EXISTING); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } } catch (IOException e) { + try { + Files.deleteIfExists(temporary); + } catch (IOException cleanupFailure) { + e.addSuppressed(cleanupFailure); + } throw new RuntimeException("Failed to store resume", e); } log.info("Resume stored: userId={}, path={}", userId, storedName); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFileValidator.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFileValidator.java new file mode 100644 index 0000000..b697e4b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFileValidator.java @@ -0,0 +1,216 @@ +package com.mydreamjob.profile.storage; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipInputStream; + +/** + * 简历上传白名单校验器。 + * + *

    扩展名和客户端 Content-Type 只作为第一层校验;随后检查文件实际签名。 + * DOCX 还会验证必要的 Office Open XML 条目,并限制条目数、解压总量和压缩比, + * 避免将任意 ZIP、路径穿越条目或压缩炸弹当作简历保存。

    + * + *

    本校验不等同于恶意软件扫描。生产环境如需病毒查杀,应在独立安全组件中完成。

    + */ +@Component +public class ResumeFileValidator { + + static final long MAX_FILE_SIZE = 10L * 1024 * 1024; + static final int MAX_DOCX_ENTRIES = 1_024; + static final long MAX_DOCX_UNCOMPRESSED_BYTES = 40L * 1024 * 1024; + static final long MAX_DOCX_COMPRESSION_RATIO = 100L; + private static final int BUFFER_SIZE = 8 * 1024; + + private static final Set ALLOWED_EXTENSIONS = Set.of(".pdf", ".doc", ".docx"); + private static final Map EXT_TO_CONTENT_TYPE = Map.of( + ".pdf", "application/pdf", + ".doc", "application/msword", + ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + + private static final byte[] PDF_SIGNATURE = {'%', 'P', 'D', 'F', '-'}; + private static final byte[] OLE_SIGNATURE = { + (byte) 0xD0, (byte) 0xCF, 0x11, (byte) 0xE0, + (byte) 0xA1, (byte) 0xB1, 0x1A, (byte) 0xE1 + }; + private static final byte[] WORD_DOCUMENT_UTF16_LE = { + 'W', 0, 'o', 0, 'r', 0, 'd', 0, 'D', 0, 'o', 0, 'c', 0, + 'u', 0, 'm', 0, 'e', 0, 'n', 0, 't', 0 + }; + + public void validate(MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() <= 0 || file.getSize() > MAX_FILE_SIZE) { + reject(); + } + + String originalName = file.getOriginalFilename(); + ResumeFilenameSanitizer.validateUploadName(originalName); + String extension = extensionOf(originalName); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + reject(); + } + + String contentType = file.getContentType(); + if (!EXT_TO_CONTENT_TYPE.get(extension).equals(contentType)) { + reject(); + } + + byte[] content; + try { + content = file.getBytes(); + } catch (IOException e) { + reject(); + return; + } + if (content.length == 0 || content.length > MAX_FILE_SIZE) { + reject(); + } + + switch (extension) { + case ".pdf" -> validatePdf(content); + case ".doc" -> validateLegacyDoc(content); + case ".docx" -> validateDocx(content); + default -> reject(); + } + } + + private void validatePdf(byte[] content) { + int offset = startsWith(content, new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}) ? 3 : 0; + while (offset < content.length && offset < 16 && isAsciiWhitespace(content[offset])) { + offset++; + } + if (!startsWithAt(content, PDF_SIGNATURE, offset)) { + reject(); + } + int searchStart = Math.max(0, content.length - 2_048); + if (indexOf(content, new byte[]{'%', '%', 'E', 'O', 'F'}, searchStart) < 0) { + reject(); + } + } + + private void validateLegacyDoc(byte[] content) { + if (!startsWith(content, OLE_SIGNATURE) || indexOf(content, WORD_DOCUMENT_UTF16_LE, 0) < 0) { + reject(); + } + } + + private void validateDocx(byte[] content) { + if (!startsWith(content, new byte[]{'P', 'K'})) { + reject(); + } + + boolean contentTypesFound = false; + boolean documentFound = false; + int entryCount = 0; + long uncompressedBytes = 0; + byte[] buffer = new byte[BUFFER_SIZE]; + + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(content))) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + entryCount++; + if (entryCount > MAX_DOCX_ENTRIES || unsafeEntryName(entry.getName())) { + reject(); + } + if ("[Content_Types].xml".equals(entry.getName())) { + contentTypesFound = true; + } else if ("word/document.xml".equals(entry.getName())) { + documentFound = true; + } + + int read; + while ((read = zip.read(buffer)) != -1) { + uncompressedBytes += read; + if (uncompressedBytes > MAX_DOCX_UNCOMPRESSED_BYTES + || uncompressedBytes > safeMultiply(content.length, MAX_DOCX_COMPRESSION_RATIO)) { + reject(); + } + } + zip.closeEntry(); + } + } catch (ZipException e) { + reject(); + } catch (IOException e) { + reject(); + } + + if (!contentTypesFound || !documentFound) { + reject(); + } + } + + private boolean unsafeEntryName(String name) { + if (name == null || name.isBlank() || name.startsWith("/") || name.startsWith("\\") + || name.contains("\\") || name.indexOf('\0') >= 0 || name.contains(":")) { + return true; + } + for (String segment : name.split("/")) { + if ("..".equals(segment)) { + return true; + } + } + return false; + } + + private long safeMultiply(long value, long multiplier) { + if (value > Long.MAX_VALUE / multiplier) { + return Long.MAX_VALUE; + } + return value * multiplier; + } + + private String extensionOf(String filename) { + int dot = filename.lastIndexOf('.'); + if (dot <= 0 || dot == filename.length() - 1) { + reject(); + } + return filename.substring(dot).toLowerCase(java.util.Locale.ROOT); + } + + private boolean startsWith(byte[] content, byte[] signature) { + return startsWithAt(content, signature, 0); + } + + private boolean startsWithAt(byte[] content, byte[] signature, int offset) { + if (offset < 0 || content.length - offset < signature.length) { + return false; + } + for (int i = 0; i < signature.length; i++) { + if (content[offset + i] != signature[i]) { + return false; + } + } + return true; + } + + private int indexOf(byte[] content, byte[] target, int start) { + outer: + for (int i = Math.max(0, start); i <= content.length - target.length; i++) { + for (int j = 0; j < target.length; j++) { + if (content[i + j] != target[j]) { + continue outer; + } + } + return i; + } + return -1; + } + + private boolean isAsciiWhitespace(byte value) { + return value == ' ' || value == '\t' || value == '\r' || value == '\n' || value == '\f'; + } + + private void reject() { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFilenameSanitizer.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFilenameSanitizer.java new file mode 100644 index 0000000..276b22c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFilenameSanitizer.java @@ -0,0 +1,54 @@ +package com.mydreamjob.profile.storage; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; + +/** 文件名白名单处理:阻止路径字符、控制字符和响应头换行进入存储元数据或下载响应。 */ +public final class ResumeFilenameSanitizer { + + private static final int MAX_FILENAME_LENGTH = 255; + + private ResumeFilenameSanitizer() { + } + + public static void validateUploadName(String filename) { + if (filename == null || filename.isBlank() || filename.length() > MAX_FILENAME_LENGTH) { + reject(); + } + for (int i = 0; i < filename.length(); i++) { + char c = filename.charAt(i); + if (Character.isISOControl(c) || c == '/' || c == '\\' || c == ':') { + reject(); + } + } + if (".".equals(filename) || "..".equals(filename)) { + reject(); + } + } + + /** + * 兼容历史数据的下载文件名净化。新上传文件已在入口处拒绝危险字符, + * 此方法为旧记录提供防御性兜底,不返回路径片段或换行。 + */ + public static String forDownload(String filename, String fallback) { + String safeFallback = (fallback == null || fallback.isBlank()) ? "resume" : fallback; + if (filename == null || filename.isBlank()) { + return safeFallback; + } + StringBuilder safe = new StringBuilder(Math.min(filename.length(), MAX_FILENAME_LENGTH)); + for (int i = 0; i < filename.length() && safe.length() < MAX_FILENAME_LENGTH; i++) { + char c = filename.charAt(i); + if (Character.isISOControl(c) || c == '/' || c == '\\' || c == ':') { + safe.append('_'); + } else { + safe.append(c); + } + } + String result = safe.toString().trim(); + return result.isEmpty() || ".".equals(result) || "..".equals(result) ? safeFallback : result; + } + + private static void reject() { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculator.java new file mode 100644 index 0000000..7368f4a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculator.java @@ -0,0 +1,68 @@ +package com.mydreamjob.recommendation; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Pure calculator for the existing 30/20/20/15/15 job-preference score. */ +public final class JobPreferenceScoreCalculator { + + private static final int CITY_WEIGHT = 30; + private static final int EMPLOYMENT_TYPE_WEIGHT = 20; + private static final int SALARY_WEIGHT = 20; + private static final int INDUSTRY_WEIGHT = 15; + private static final int JOB_FAMILY_WEIGHT = 15; + + private JobPreferenceScoreCalculator() { + } + + public static BigDecimal calculate(Criteria criteria, Target target) { + int matched = 0; + int available = 0; + + if (!criteria.cities().isEmpty()) { + available += CITY_WEIGHT; + if (criteria.cities().contains(target.city())) matched += CITY_WEIGHT; + } + if (!criteria.employmentTypes().isEmpty()) { + available += EMPLOYMENT_TYPE_WEIGHT; + if (criteria.employmentTypes().contains(target.employmentType())) matched += EMPLOYMENT_TYPE_WEIGHT; + } + if (criteria.expectedSalaryMin() != null) { + available += SALARY_WEIGHT; + if (target.salaryMax() != null && target.salaryMax() >= criteria.expectedSalaryMin()) { + matched += SALARY_WEIGHT; + } + } + if (!criteria.industries().isEmpty()) { + available += INDUSTRY_WEIGHT; + if (criteria.industries().contains(target.industryCode())) matched += INDUSTRY_WEIGHT; + } + if (!criteria.jobFamilies().isEmpty()) { + available += JOB_FAMILY_WEIGHT; + if (criteria.jobFamilies().contains(target.jobFamilyCode())) matched += JOB_FAMILY_WEIGHT; + } + return available == 0 ? null : BigDecimal.valueOf(matched * 100L) + .divide(BigDecimal.valueOf(available), 2, RoundingMode.HALF_UP); + } + + public record Criteria(List cities, List employmentTypes, Integer expectedSalaryMin, + List industries, List jobFamilies) { + public Criteria { + cities = copyPreservingNulls(cities); + employmentTypes = copyPreservingNulls(employmentTypes); + industries = copyPreservingNulls(industries); + jobFamilies = copyPreservingNulls(jobFamilies); + } + + private static List copyPreservingNulls(List values) { + return values == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(values)); + } + } + + public record Target(String city, String employmentType, Integer salaryMax, + String industryCode, String jobFamilyCode) { + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java index d182d1c..05ed3cf 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java @@ -10,7 +10,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.security.core.*; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -18,49 +17,52 @@ import java.util.List; @RestController @RequiredArgsConstructor public class JobRecommendationController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final JobRecommendationService service; @GetMapping("/job-recommendation-taxonomies") public ApiResponse> taxonomies(HttpServletRequest request) { - return ApiResponse.ok(service.taxonomies(), requestId(request)); + return ApiResponse.ok(service.taxonomies(), requestIdAccessor.get(request)); } @GetMapping("/profile/me/job-recommendations") public ApiResponse> recommendations(@RequestParam(defaultValue="0") int page, @RequestParam(defaultValue="20") int size, HttpServletRequest request) { - Page result = service.recommend(userId(), page, size); - return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestId(request)); + Page result = service.recommend(currentUserAccessor.requireUserId(), page, size); + return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestIdAccessor.get(request)); } @PostMapping("/profile/me/job-recommendations/{jobId}/feedback") public ApiResponse feedback(@PathVariable Long jobId,@Valid @RequestBody RecommendationFeedbackRequest body,HttpServletRequest request) { - return ApiResponse.ok(service.feedback(userId(),jobId,body,AuditContext.from(request)),requestId(request)); + return ApiResponse.ok(service.feedback(currentUserAccessor.requireUserId(),jobId,body,AuditContext.from(request)),requestIdAccessor.get(request)); } @DeleteMapping("/profile/me/job-recommendations/{jobId}/feedback") public ApiResponse revokeFeedback(@PathVariable Long jobId,@RequestParam Long expectedVersion,HttpServletRequest request) { - service.revokeNotInterested(userId(),jobId,expectedVersion,AuditContext.from(request)); return ApiResponse.ok(null,requestId(request)); + service.revokeNotInterested(currentUserAccessor.requireUserId(),jobId,expectedVersion,AuditContext.from(request)); return ApiResponse.ok(null,requestIdAccessor.get(request)); } @GetMapping("/companies/me/jobs/{jobId}/recommendation-profile") public ApiResponse profile(@PathVariable Long jobId,HttpServletRequest request) { - return ApiResponse.ok(service.companyProfile(userId(),jobId),requestId(request)); + return ApiResponse.ok(service.companyProfile(currentUserAccessor.requireUserId(),jobId),requestIdAccessor.get(request)); } @PutMapping("/companies/me/jobs/{jobId}/recommendation-profile") public ApiResponse updateProfile(@PathVariable Long jobId,@Valid @RequestBody RecommendationProfileRequest body,HttpServletRequest request) { - return ApiResponse.ok(service.upsertCompanyProfile(userId(),jobId,body,AuditContext.from(request)),requestId(request)); + return ApiResponse.ok(service.upsertCompanyProfile(currentUserAccessor.requireUserId(),jobId,body,AuditContext.from(request)),requestIdAccessor.get(request)); } @GetMapping("/admin/job-recommendation-profiles") public ApiResponse> adminProfiles(@RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="20") int size,@RequestParam(required=false) String status,HttpServletRequest request) { - Page result=service.adminProfiles(userId(),page,size,status); return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestId(request)); + Page result=service.adminProfiles(currentUserAccessor.requireUserId(),page,size,status); return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestIdAccessor.get(request)); } @PostMapping("/admin/job-recommendation-profiles/{jobId}/decision") public ApiResponse decide(@PathVariable Long jobId,@Valid @RequestBody RecommendationDecisionRequest body,HttpServletRequest request) { - return ApiResponse.ok(service.decide(userId(),jobId,body,AuditContext.from(request)),requestId(request)); + return ApiResponse.ok(service.decide(currentUserAccessor.requireUserId(),jobId,body,AuditContext.from(request)),requestIdAccessor.get(request)); } - private Long userId(){ Authentication a=SecurityContextHolder.getContext().getAuthentication(); if(a==null||!a.isAuthenticated())throw new BusinessException(ErrorCode.UNAUTHORIZED); return Long.valueOf(a.getName()); } - private String requestId(HttpServletRequest r){return r.getHeader("X-Request-Id");} } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java index e997919..13c4e98 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java @@ -40,6 +40,7 @@ public class JobRecommendationService { private static final Set FEEDBACK = Set.of("NOT_INTERESTED","REASON_HELPFUL","REASON_NOT_HELPFUL"); private static final Set DECISIONS = Set.of("APPROVE", "REJECT"); private static final Set REJECT_REASONS = Set.of("TAXONOMY_MISMATCH", "WEIGHT_INVALID", "CONTENT_INCOMPLETE", "OTHER"); + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final JobRecommendationTaxonomyRepository taxonomyRepository; private final JobRecommendationProfileRepository profileRepository; private final StudentJobRecommendationFeedbackRepository feedbackRepository; @@ -90,7 +91,7 @@ public class JobRecommendationService { @Transactional(readOnly = true) public Page adminProfiles(Long adminId, int page, int size, String status) { - requireAdmin(adminId); validatePage(page, size); + userRoleGuard.requireAdmin(adminId); validatePage(page, size); Page result = status == null || status.isBlank() ? profileRepository.findAll(PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"))) : profileRepository.findByReviewStatusOrderByUpdatedAtDesc(status, PageRequest.of(page, size)); @@ -99,7 +100,7 @@ public class JobRecommendationService { @Transactional public RecommendationProfileResponse decide(Long adminId, Long jobId, RecommendationDecisionRequest req, AuditContext ctx) { - requireAdmin(adminId); + userRoleGuard.requireAdmin(adminId); if (!DECISIONS.contains(req.getDecision()) || ("REJECT".equals(req.getDecision()) && !REJECT_REASONS.contains(req.getReasonCode()))) throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID); JobRecommendationProfile p = profileRepository.findByJobId(jobId) @@ -116,7 +117,7 @@ public class JobRecommendationService { @Transactional public RecommendationFeedbackResponse feedback(Long studentId, Long jobId, RecommendationFeedbackRequest req, AuditContext ctx) { - requireStudent(studentId); jobService.getPublicJob(jobId); + userRoleGuard.requireStudent(studentId); jobService.getPublicJob(jobId); if (!FEEDBACK.contains(req.getFeedbackType()) || req.getIdempotencyKey().length() > 64) throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID); String key = studentId + ":" + jobId + ":" + req.getFeedbackType() + ":" + Optional.ofNullable(req.getReasonType()).orElse(""); @@ -137,7 +138,7 @@ public class JobRecommendationService { @Transactional public void revokeNotInterested(Long studentId, Long jobId, Long expectedVersion, AuditContext ctx) { - requireStudent(studentId); + userRoleGuard.requireStudent(studentId); StudentJobRecommendationFeedback f = feedbackRepository.findByStudentUserIdAndJobIdAndFeedbackTypeAndStatus(studentId, jobId, "NOT_INTERESTED", StudentJobRecommendationFeedback.ACTIVE) .orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_NOT_FOUND)); if (!Objects.equals(f.getVersion(), expectedVersion)) throw new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE); @@ -147,7 +148,7 @@ public class JobRecommendationService { @Transactional(readOnly = true) public Page recommend(Long studentId, int page, int size) { - requireStudent(studentId); validatePage(page, size); LocalDateTime now = LocalDateTime.now(); + userRoleGuard.requireStudent(studentId); validatePage(page, size); LocalDateTime now = LocalDateTime.now(); Set applied = new HashSet<>(applicationRepository.findDistinctJobIdsByStudentUserId(studentId)); Set hidden = feedbackRepository.findByStudentUserIdAndStatusAndFeedbackType(studentId, StudentJobRecommendationFeedback.ACTIVE, "NOT_INTERESTED") .stream().map(StudentJobRecommendationFeedback::getJobId).collect(Collectors.toSet()); @@ -181,7 +182,19 @@ public class JobRecommendationService { } private Page slice(List all,int page,int size) { int from=Math.min(page*size,all.size()), to=Math.min(from+size,all.size()); return new PageImpl<>(all.subList(from,to), PageRequest.of(page,size),all.size()); } private boolean hardPreferenceMatch(StudentPreference p,Job j) { if(p==null)return true; List cities=list(p.getPreferredCities()); if(!cities.isEmpty()&&!cities.contains(j.getCity()))return false; return p.getExpectedSalaryMin()==null || j.getSalaryMax()==null || j.getSalaryMax()>=p.getExpectedSalaryMin(); } - private BigDecimal preferenceScore(StudentPreference p,JobRecommendationProfile profile,Job j) { if(p==null)return null; int got=0,total=0; List cities=list(p.getPreferredCities());if(!cities.isEmpty()){total+=30;if(cities.contains(j.getCity()))got+=30;} List types=list(p.getEmploymentTypes());if(!types.isEmpty()){total+=20;if(types.contains(j.getEmploymentType()))got+=20;} if(p.getExpectedSalaryMin()!=null){total+=20;if(j.getSalaryMax()!=null&&j.getSalaryMax()>=p.getExpectedSalaryMin())got+=20;} List industries=list(p.getPreferredIndustries());if(!industries.isEmpty()){total+=15;if(industries.contains(profile.getIndustryCode()))got+=15;} List families=list(p.getPreferredJobTypes());if(!families.isEmpty()){total+=15;if(families.contains(profile.getJobFamilyCode()))got+=15;} return total==0?null:BigDecimal.valueOf(got*100L).divide(BigDecimal.valueOf(total),2,RoundingMode.HALF_UP); } + private BigDecimal preferenceScore(StudentPreference preference, JobRecommendationProfile profile, Job job) { + if (preference == null) return null; + JobPreferenceScoreCalculator.Criteria criteria = new JobPreferenceScoreCalculator.Criteria( + list(preference.getPreferredCities()), + list(preference.getEmploymentTypes()), + preference.getExpectedSalaryMin(), + list(preference.getPreferredIndustries()), + list(preference.getPreferredJobTypes())); + JobPreferenceScoreCalculator.Target target = new JobPreferenceScoreCalculator.Target( + job.getCity(), job.getEmploymentType(), job.getSalaryMax(), + profile.getIndustryCode(), profile.getJobFamilyCode()); + return JobPreferenceScoreCalculator.calculate(criteria, target); + } private BigDecimal weighted(Map scores,Map expected,Set allowed){if(scores.isEmpty()||expected.isEmpty())return null; BigDecimal n=BigDecimal.ZERO,d=BigDecimal.ZERO;for(var e:expected.entrySet())if(allowed.contains(e.getKey())&&scores.containsKey(e.getKey())&&e.getValue().signum()>0){n=n.add(scores.get(e.getKey()).multiply(e.getValue()));d=d.add(e.getValue());}return d.signum()==0?null:n.divide(d,2,RoundingMode.HALF_UP);} private BigDecimal abilityScore(Map scores,Map expected){if(scores.isEmpty()||expected.isEmpty())return null;BigDecimal total=BigDecimal.ZERO;int n=0;for(var e:expected.entrySet())if(ABILITY.contains(e.getKey())&&scores.containsKey(e.getKey())&&e.getValue().compareTo(BigDecimal.ZERO)>0){total=total.add(scores.get(e.getKey()).min(e.getValue()).multiply(BigDecimal.valueOf(100)).divide(e.getValue(),2,RoundingMode.HALF_UP));n++;}return n==0?null:total.divide(BigDecimal.valueOf(n),2,RoundingMode.HALF_UP);} private Map latestRiasec(Long student){return latest("SELECT r.dimension_scores_text FROM assessment_results r JOIN assessment_attempts a ON a.id=r.attempt_id JOIN assessment_definitions d ON d.id=a.definition_id WHERE a.student_user_id=? AND a.status='COMPLETED' AND d.type_code='HOLLAND_RIASEC' ORDER BY a.completed_at DESC,a.id DESC LIMIT 1",student,null,RIASEC);} @@ -194,8 +207,6 @@ public class JobRecommendationService { private void validateTaxonomy(String industry,String family){JobRecommendationTaxonomy i=taxonomyRepository.findById(industry).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));JobRecommendationTaxonomy f=taxonomyRepository.findById(family).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));if(!Boolean.TRUE.equals(i.getEnabled())||!Boolean.TRUE.equals(f.getEnabled())||!"INDUSTRY".equals(i.getTaxonomyType())||!"JOB_FAMILY".equals(f.getTaxonomyType())||!industry.equals(f.getParentCode()))throw new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID);} private void validateMaps(Map r,Mapa){if((r==null||r.isEmpty())&&(a==null||a.isEmpty()))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(r!=null&&(!RIASEC.containsAll(r.keySet())||r.values().stream().anyMatch(v->v==null||v<0||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(a!=null&&(!ABILITY.containsAll(a.keySet())||a.values().stream().anyMatch(v->v==null||v<1||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);} private void assertCompanyOwns(Long user,Long job){Company c=companyService.findCompanyByOwnerUserId(user);if(c==null)throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);jobService.assertOwnerOrThrow(c.getId(),job);} - private void requireStudent(Long id){User u=userRepository.findById(id).orElseThrow(()->new BusinessException(ErrorCode.USER_NOT_FOUND));if(!u.getRoles().contains(User.Role.STUDENT))throw new BusinessException(ErrorCode.FORBIDDEN);} - private void requireAdmin(Long id){User u=userRepository.findById(id).orElseThrow(()->new BusinessException(ErrorCode.USER_NOT_FOUND));if(!u.getRoles().contains(User.Role.ADMIN))throw new BusinessException(ErrorCode.FORBIDDEN);} private void validatePage(int p,int s){if(p<0||s<1||s>50)throw new BusinessException(ErrorCode.RECOMMENDATION_INVALID_QUERY);} private RecommendationProfileResponse profileView(JobRecommendationProfile p){return RecommendationProfileResponse.builder().jobId(p.getJobId()).industryCode(p.getIndustryCode()).jobFamilyCode(p.getJobFamilyCode()).riasecWeights(mapInteger(p.getRiasecWeightsJson())).abilityExpectations(mapInteger(p.getAbilityExpectationsJson())).enabled(p.getEnabled()).reviewStatus(p.getReviewStatus()).reviewReason(p.getReviewReason()).version(p.getVersion()).updatedAt(p.getUpdatedAt()).build();} private Map mapInteger(String raw){try{return raw==null?Map.of():JSON.readValue(raw,new TypeReference>(){});}catch(Exception e){return Map.of();}} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java index b0f19f8..f6b93b5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java @@ -1,8 +1,9 @@ package com.mydreamjob.resume; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -11,8 +12,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 org.springframework.web.multipart.MultipartFile; @@ -34,6 +33,8 @@ public class ResumeVersionController { private final ResumeVersionService resumeVersionService; private final ResumeExportService resumeExportService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "创建简历版本", description = "仅本人(STUDENT);PDF/DOC/DOCX ≤10MB;可附版本名") @PostMapping(value = "/profile/me/resume-versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -41,18 +42,18 @@ public class ResumeVersionController { @RequestParam("file") MultipartFile file, @RequestParam(value = "name", required = false) String name, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeVersion version = resumeVersionService.createVersion(userId, name, file); - return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), requestIdAccessor.get(request))); } @Operation(summary = "简历版本列表", description = "本人 ACTIVE 版本,version_no 倒序") @GetMapping("/profile/me/resume-versions") public ResponseEntity>>> list(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); List> data = resumeVersionService.listVersions(userId) .stream().map(resumeVersionService::toView).collect(Collectors.toList()); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "简历版本详情", description = "仅本人可查看;他人 403,不存在 404") @@ -60,9 +61,9 @@ public class ResumeVersionController { public ResponseEntity>> get( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeVersion version = resumeVersionService.getVersion(userId, id); - return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), requestIdAccessor.get(request))); } @Operation(summary = "修改简历版本", description = "本人;可选重命名 + 可选替换文件(替换时删除旧文件)") @@ -72,9 +73,9 @@ public class ResumeVersionController { @RequestParam(value = "file", required = false) MultipartFile file, @RequestParam(value = "name", required = false) String name, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeVersion version = resumeVersionService.updateVersion(userId, id, name, file); - return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), requestIdAccessor.get(request))); } @Operation(summary = "删除简历版本", description = "默认版本需先设其他默认;导出进行中不可删;删除文件并移除记录") @@ -82,10 +83,10 @@ public class ResumeVersionController { public ResponseEntity>> delete( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); resumeVersionService.deleteVersion(userId, id); return ResponseEntity.ok(ApiResponse.ok( - Map.of("message", "resume version deleted"), getRequestId(request))); + Map.of("message", "resume version deleted"), requestIdAccessor.get(request))); } @Operation(summary = "设为默认简历", description = "同用户最多一个默认;已默认幂等;并发冲突 409") @@ -93,9 +94,9 @@ public class ResumeVersionController { public ResponseEntity>> setDefault( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeVersion version = resumeVersionService.setDefault(userId, id); - return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), requestIdAccessor.get(request))); } @Operation(summary = "创建简历导出任务", description = "异步复制该版本文件到导出目录;返回任务状态") @@ -103,9 +104,9 @@ public class ResumeVersionController { public ResponseEntity>> createExportTask( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeExportTask task = resumeExportService.createExportTask(userId, id); - return ResponseEntity.ok(ApiResponse.ok(resumeExportService.toView(task), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(resumeExportService.toView(task), requestIdAccessor.get(request))); } @Operation(summary = "导出任务状态", description = "仅本人可查询;SUCCESS 含输出文件,FAILED 含失败原因") @@ -113,9 +114,9 @@ public class ResumeVersionController { public ResponseEntity>> getExportTask( @PathVariable Long taskId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeExportTask task = resumeExportService.getExportTask(userId, taskId); - return ResponseEntity.ok(ApiResponse.ok(resumeExportService.toView(task), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(resumeExportService.toView(task), requestIdAccessor.get(request))); } @Operation(summary = "下载导出产物", description = "仅本人且任务 SUCCESS 可下载;attachment;不暴露内部路径") @@ -123,11 +124,11 @@ public class ResumeVersionController { public ResponseEntity downloadExportTask( @PathVariable Long taskId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeExportService.DownloadFile df = resumeExportService.download(userId, taskId); MediaType mediaType = resolveContentType(df.getContentType()); ContentDisposition cd = ContentDisposition.attachment() - .filename(df.getFilename(), StandardCharsets.UTF_8) + .filename(ResumeFilenameSanitizer.forDownload(df.getFilename(), "resume"), StandardCharsets.UTF_8) .build(); return ResponseEntity.ok() .contentType(mediaType) @@ -135,17 +136,6 @@ public class ResumeVersionController { .body(df.getContent()); } - 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"); - } - private MediaType resolveContentType(String contentType) { if (contentType == null) return MediaType.APPLICATION_OCTET_STREAM; try { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java index cfd599d..2ad1fbd 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java @@ -2,7 +2,9 @@ package com.mydreamjob.resume; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.profile.ProfileService; +import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; import com.mydreamjob.profile.storage.ResumeStorageService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,6 +39,8 @@ public class ResumeVersionService { private final ResumeStorageService resumeStorageService; private final ResumeVersionWriter versionWriter; private final ProfileService profileService; + /** R01-D4-1:简历版本创建/替换/删除后登记评分快照刷新(GROWTH 维度)。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; // ── 创建 ────────────────────────────────────────────────── @@ -60,8 +64,12 @@ public class ResumeVersionService { try { for (int attempt = 0; attempt < MAX_VERSION_NO_RETRY; attempt++) { try { - return versionWriter.saveNewVersion(userId, normalizeName(name), storagePath, - file.getOriginalFilename(), file.getContentType(), file.getSize()); + ResumeVersion saved = versionWriter.saveNewVersion(userId, normalizeName(name), storagePath, + ResumeFilenameSanitizer.forDownload(file.getOriginalFilename(), "resume"), + file.getContentType(), file.getSize()); + // R01-D4-2 F3:刷新登记已移入 ResumeVersionWriter 的同一 REQUIRES_NEW 事务, + // 保证"版本行与刷新任务同一提交边界";此处不再重复登记(避免二次登记与外层事务耦合)。 + return saved; } catch (DataIntegrityViolationException e) { // uk_resume_versions_user_no 并发冲突 → 短退避后重试 log.info("Resume version_no conflict for user {}, retry {}/{}", userId, attempt + 1, MAX_VERSION_NO_RETRY); @@ -129,7 +137,7 @@ public class ResumeVersionService { // 1) 先保存新文件(绝不在保存前删除旧文件) newStoragePath = resumeStorageService.store(userId, file); version.setFileStoragePath(newStoragePath); - version.setFileName(file.getOriginalFilename()); + version.setFileName(ResumeFilenameSanitizer.forDownload(file.getOriginalFilename(), "resume")); version.setContentType(file.getContentType()); version.setFileSize(file.getSize()); // 2) 注册事务回调:提交后删旧文件;回滚时清理新文件 @@ -151,6 +159,8 @@ public class ResumeVersionService { // 非事务上下文(单元测试):无 afterCommit 回调,立即执行旧文件清理 deleteOldFileIfUnreferenced(oldStoragePath); } + // R01-D4-1:简历替换/重命名影响 GROWTH 维度 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(userId, java.time.LocalDateTime.now()); return version; } @@ -175,6 +185,8 @@ public class ResumeVersionService { } // 1) 先完成数据库删除 resumeVersionRepository.delete(version); + // R01-D4-1:简历版本数影响 GROWTH 维度 → 删除后登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(userId, java.time.LocalDateTime.now()); // 2) 事务提交后再删除存储文件(提交失败则文件保留) deleteFileAfterCommit(version.getFileStoragePath()); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionWriter.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionWriter.java index a63fdd6..751c391 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionWriter.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionWriter.java @@ -1,10 +1,13 @@ package com.mydreamjob.resume; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + /** * 简历版本写入工作单元:每个"计算版本号 + 插入"运行在独立事务(REQUIRES_NEW)。 * @@ -16,6 +19,14 @@ import org.springframework.transaction.annotation.Transactional; public class ResumeVersionWriter { private final ResumeVersionRepository resumeVersionRepository; + /** + * R01-D4-2 F3:刷新登记必须与版本行在同一提交边界。 + * + *

    版本行由本组件的 {@code REQUIRES_NEW} 事务独立提交(这是既有的并发冲突重试防护, + * 不能移除)。若登记留在外层事务,外层在提交阶段失败就会出现"版本行已落库、刷新信号 + * 丢失"。把登记放进本事务后,二者要么一起提交、要么一起回滚。

    + */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; /** 计算用户下一个版本号(max+1)并插入;并发冲突抛 DataIntegrityViolationException */ @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -34,6 +45,9 @@ public class ResumeVersionWriter { .defaultKey(null) .status(ResumeVersion.STATUS_ACTIVE) .build(); - return resumeVersionRepository.save(version); + ResumeVersion saved = resumeVersionRepository.save(version); + // R01-D4-2 F3:与版本行同事务登记(RESUME 版本数/最近更新影响 GROWTH 维度)。 + scoringRefreshRequestService.requestRefresh(userId, LocalDateTime.now()); + return saved; } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java index 2981663..b0cdeb9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java @@ -1,9 +1,9 @@ package com.mydreamjob.review; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.review.dto.ReviewDecisionRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -12,8 +12,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @@ -30,6 +28,8 @@ import java.util.Map; public class AdminReviewController { private final JobReviewService reviewService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "待审评价列表", description = "仅 ADMIN;分页 + jobId/riskLevel/createdFrom/createdTo 筛选;不返回明文 IP/UA") @GetMapping("/pending") @@ -43,10 +43,10 @@ public class AdminReviewController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Page> result = reviewService.listPendingReviews( userId, jobId, riskLevel, createdFrom, createdTo, page, size); - return ResponseEntity.ok(ApiResponse.ok(toPage(result), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(toPage(result), requestIdAccessor.get(request))); } @Operation(summary = "评价审核决策", description = "仅 ADMIN;仅 PENDING 可审,重复提交 409;决策写入历史与 audit_logs") @@ -55,11 +55,11 @@ public class AdminReviewController { @PathVariable Long reviewId, @Valid @RequestBody ReviewDecisionRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = reviewService.decideReview( userId, reviewId, req.getDecision(), req.getReason(), com.mydreamjob.audit.AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } @Operation(summary = "审核历史", description = "仅 ADMIN;分页返回审核结果、审核人、时间与原因") @@ -68,9 +68,9 @@ public class AdminReviewController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Page> result = reviewService.listModerationHistory(userId, page, size); - return ResponseEntity.ok(ApiResponse.ok(toPage(result), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(toPage(result), requestIdAccessor.get(request))); } private Map toPage(Page> result) { @@ -83,14 +83,4 @@ public class AdminReviewController { return data; } - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) - throw new BusinessException(ErrorCode.UNAUTHORIZED); - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java index 6d6dd70..7ef8a63 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java @@ -2,11 +2,11 @@ package com.mydreamjob.review; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.review.dto.AdminReviewReplyPageResponse; import com.mydreamjob.review.dto.AdminReviewReplyResponse; import com.mydreamjob.review.dto.ReviewReplyDecisionRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -14,8 +14,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -31,6 +29,8 @@ import java.time.LocalDateTime; public class AdminReviewReplyController { private final AdminReviewReplyService adminService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "待审官方回复", description = "仅 ADMIN;分页查看风险回复") @GetMapping("/admin/review-replies/pending") @@ -45,9 +45,9 @@ public class AdminReviewReplyController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok(adminService.listPending(userId, companyId, jobId, riskLevel, - createdFrom, createdTo, page, size), requestId(request))); + createdFrom, createdTo, page, size), requestIdAccessor.get(request))); } @Operation(summary = "官方回复审核决策", description = "仅 ADMIN;PENDING 回复可 APPROVE 或 REJECT") @@ -56,23 +56,8 @@ public class AdminReviewReplyController { @PathVariable Long replyId, @Valid @RequestBody ReviewReplyDecisionRequest requestBody, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok(adminService.decide(userId, replyId, requestBody, - AuditContext.from(request)), requestId(request))); - } - - private Long resolveUserId() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) - throw new BusinessException(ErrorCode.UNAUTHORIZED); - try { - return Long.valueOf(auth.getName()); - } catch (NumberFormatException ex) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + AuditContext.from(request)), requestIdAccessor.get(request))); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java index e9299c1..70b47d4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java @@ -2,13 +2,13 @@ package com.mydreamjob.review; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.review.dto.CompanyReviewDetailResponse; import com.mydreamjob.review.dto.CompanyReviewReplyResponse; import com.mydreamjob.review.dto.CreateCompanyReviewReplyRequest; import com.mydreamjob.review.dto.ReviewReplyPageResponse; import com.mydreamjob.review.dto.UpdateCompanyReviewReplyRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -16,8 +16,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -35,6 +33,8 @@ public class CompanyReviewManagementController { private final CompanyReviewManagementService managementService; private final CompanyReviewReplyService replyService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "企业评价列表", description = "仅岗位所属 COMPANY;分页查看本企业岗位评价与回复状态") @GetMapping("/companies/me/job-reviews") @@ -50,17 +50,17 @@ public class CompanyReviewManagementController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok(managementService.listMine(userId, jobId, rating, - reviewModerationStatus, replyStatus, createdFrom, createdTo, page, size), requestId(request))); + reviewModerationStatus, replyStatus, createdFrom, createdTo, page, size), requestIdAccessor.get(request))); } @Operation(summary = "企业评价详情", description = "仅岗位所属 COMPANY;不返回评价作者身份") @GetMapping("/companies/me/job-reviews/{reviewId}") public ResponseEntity> detail( @PathVariable Long reviewId, HttpServletRequest request) { - Long userId = resolveUserId(); - return ResponseEntity.ok(ApiResponse.ok(managementService.getMine(userId, reviewId), requestId(request))); + Long userId = currentUserAccessor.requireUserId(); + return ResponseEntity.ok(ApiResponse.ok(managementService.getMine(userId, reviewId), requestIdAccessor.get(request))); } @Operation(summary = "创建官方回复", description = "仅岗位所属 COMPANY;安全文本自动公开,风险文本进入管理员审核") @@ -69,9 +69,9 @@ public class CompanyReviewManagementController { @PathVariable Long reviewId, @Valid @RequestBody CreateCompanyReviewReplyRequest requestBody, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok(replyService.create(userId, reviewId, requestBody, - AuditContext.from(request)), requestId(request))); + AuditContext.from(request)), requestIdAccessor.get(request))); } @Operation(summary = "修订被驳回回复", description = "仅 REJECTED 回复可修订;使用 expectedVersion 防并发覆盖") @@ -80,23 +80,8 @@ public class CompanyReviewManagementController { @PathVariable Long reviewId, @Valid @RequestBody UpdateCompanyReviewReplyRequest requestBody, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok(replyService.revise(userId, reviewId, requestBody, - AuditContext.from(request)), requestId(request))); - } - - private Long resolveUserId() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) - throw new BusinessException(ErrorCode.UNAUTHORIZED); - try { - return Long.valueOf(auth.getName()); - } catch (NumberFormatException ex) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + AuditContext.from(request)), requestIdAccessor.get(request))); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java index 63bf36b..c9de4e4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java @@ -3,8 +3,10 @@ package com.mydreamjob.review; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.review.dto.JobReviewCreateRequest; import com.mydreamjob.review.dto.JobReviewUpdateRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -12,8 +14,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.ArrayList; @@ -27,6 +27,8 @@ import java.util.Map; public class JobReviewController { private final JobReviewService reviewService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "创建岗位评价", description = "仅 STUDENT;同一学生对同一岗位只能评价一次") @PostMapping("/jobs/{jobId}/reviews") @@ -34,10 +36,10 @@ public class JobReviewController { @PathVariable Long jobId, @Valid @RequestBody JobReviewCreateRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = reviewService.createReview(userId, jobId, req, com.mydreamjob.audit.AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } @Operation(summary = "查询岗位评价列表", description = "公开访问;仅返回 PUBLISHED 评价,分页;每条含八维评分") @@ -54,7 +56,7 @@ public class JobReviewController { data.put("page", result.getNumber()); data.put("size", result.getSize()); data.put("totalPages", result.getTotalPages()); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "岗位评价详情", description = "公开访问;返回单条 PUBLISHED 评价与八维评分") @@ -64,7 +66,7 @@ public class JobReviewController { @PathVariable Long reviewId, HttpServletRequest request) { Map view = reviewService.getReview(jobId, reviewId); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } @Operation(summary = "评分汇总", description = "公开访问;统计 PUBLISHED 评价的平均分、分布和热门标签") @@ -73,7 +75,7 @@ public class JobReviewController { @PathVariable Long jobId, HttpServletRequest request) { Map data = reviewService.getReviewSummary(jobId); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "岗位对比", description = "公开访问;ids 为 2-3 个公开岗位;返回公开岗位字段与公开评分") @@ -83,7 +85,7 @@ public class JobReviewController { HttpServletRequest request) { List jobIds = parseIds(ids); List> data = reviewService.compareJobs(jobIds); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } private List parseIds(String ids) { @@ -108,10 +110,10 @@ public class JobReviewController { @PathVariable Long reviewId, @Valid @RequestBody JobReviewUpdateRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = reviewService.updateReview(userId, jobId, reviewId, req, com.mydreamjob.audit.AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } @Operation(summary = "删除评价", description = "仅评价本人可以删除") @@ -120,21 +122,8 @@ public class JobReviewController { @PathVariable Long jobId, @PathVariable Long reviewId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); reviewService.deleteReview(userId, jobId, reviewId, com.mydreamjob.audit.AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "review deleted"), getRequestId(request))); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new com.mydreamjob.common.exception.BusinessException( - com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "review deleted"), requestIdAccessor.get(request))); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java index ca3ab67..30bd08d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java @@ -32,6 +32,7 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class JobReviewService { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final JobReviewRepository reviewRepository; private final JobReviewDimensionScoreRepository dimensionScoreRepository; private final ReviewModerationHistoryRepository moderationHistoryRepository; @@ -53,14 +54,6 @@ public class JobReviewService { CompanyReviewReply.AUTO_APPROVED, CompanyReviewReply.APPROVED); // ── 角色校验 ────────────────────────────────────────────── - - private void assertStudentRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isStudent = user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT); - if (!isStudent) throw new BusinessException(ErrorCode.FORBIDDEN); - } - private void assertJobExists(Long jobId) { jobService.getJob(jobId); // throws JOB_NOT_FOUND } @@ -91,7 +84,7 @@ public class JobReviewService { @Transactional public Map createReview(Long studentUserId, Long jobId, JobReviewCreateRequest req, AuditContext ctx) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); assertJobExists(jobId); if (reviewRepository.existsByJobIdAndStudentUserId(jobId, studentUserId)) @@ -187,7 +180,7 @@ public class JobReviewService { @Transactional public Map updateReview(Long userId, Long jobId, Long reviewId, JobReviewUpdateRequest req, AuditContext ctx) { - assertStudentRole(userId); + userRoleGuard.requireStudent(userId); JobReview review = findOwnReview(userId, jobId, reviewId); if (req.getRating() != null) { @@ -224,7 +217,7 @@ public class JobReviewService { @Transactional public void deleteReview(Long userId, Long jobId, Long reviewId, AuditContext ctx) { - assertStudentRole(userId); + userRoleGuard.requireStudent(userId); findOwnReview(userId, jobId, reviewId); reviewRepository.deleteById(reviewId); // DB 外键 ON DELETE CASCADE 删除维度分 auditService.log(userId, "DELETE_REVIEW", "JOB_REVIEW", reviewId, @@ -331,7 +324,7 @@ public class JobReviewService { public Page> listPendingReviews(Long adminUserId, Long jobId, String riskLevel, LocalDateTime createdFrom, LocalDateTime createdTo, int page, int size) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); assertValidPage(page, size); return reviewRepository.findPendingModeration(jobId, riskLevel, createdFrom, createdTo, PageRequest.of(page, size)) @@ -342,7 +335,7 @@ public class JobReviewService { @Transactional public Map decideReview(Long adminUserId, Long reviewId, String decision, String reason, AuditContext ctx) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); JobReview review = reviewRepository.findById(reviewId) .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); if (!JobReview.MODERATION_PENDING.equals(review.getModerationStatus())) { @@ -371,19 +364,11 @@ public class JobReviewService { /** 审核历史(分页),返回结果、审核人、时间与原因 */ @Transactional(readOnly = true) public Page> listModerationHistory(Long adminUserId, int page, int size) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); assertValidPage(page, size); return moderationHistoryRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size)) .map(this::toHistoryView); } - - private void assertAdminRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isAdmin = user.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN); - if (!isAdmin) throw new BusinessException(ErrorCode.FORBIDDEN); - } - // ── 八维评分 ────────────────────────────────────────────── /** 校验并归一化维度分:null/空 → 空;key 非法 → 400;value 非 1-5 或 null → 400 */ diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightController.java index e0d8775..c4f7838 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightController.java @@ -1,20 +1,18 @@ package com.mydreamjob.review; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.review.dto.PersonalizedScoreResponse; import com.mydreamjob.review.dto.ReviewWeightResponse; import com.mydreamjob.review.dto.SaveWeightRequest; import com.mydreamjob.review.dto.WeightTemplateResponse; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -24,6 +22,8 @@ import java.util.List; public class StudentReviewWeightController { private final StudentReviewWeightService weightService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; // ── 模板列表(登录即可) ────────────────────────────────── @@ -35,7 +35,7 @@ public class StudentReviewWeightController { }) public ResponseEntity>> getTemplates( HttpServletRequest request) { - return ResponseEntity.ok(ApiResponse.ok(weightService.getTemplates(), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(weightService.getTemplates(), requestIdAccessor.get(request))); } // ── 获取我的权重配置(仅 STUDENT,Service 层校验) ────────── @@ -50,8 +50,8 @@ public class StudentReviewWeightController { }) public ResponseEntity> getMyWeights( HttpServletRequest request) { - Long userId = resolveUserId(request); - return ResponseEntity.ok(ApiResponse.ok(weightService.getMyWeights(userId), getRequestId(request))); + Long userId = currentUserAccessor.requireUserId(); + return ResponseEntity.ok(ApiResponse.ok(weightService.getMyWeights(userId), requestIdAccessor.get(request))); } // ── 保存我的权重配置(仅 STUDENT,Service 层校验) ────────── @@ -67,8 +67,8 @@ public class StudentReviewWeightController { public ResponseEntity> saveWeights( @RequestBody SaveWeightRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); - return ResponseEntity.ok(ApiResponse.ok(weightService.saveWeights(userId, req), getRequestId(request))); + Long userId = currentUserAccessor.requireUserId(); + return ResponseEntity.ok(ApiResponse.ok(weightService.saveWeights(userId, req), requestIdAccessor.get(request))); } // ── 个性化评分(仅 STUDENT,Service 层校验) ──────────────── @@ -84,19 +84,7 @@ public class StudentReviewWeightController { public ResponseEntity> getPersonalizedScore( @PathVariable Long jobId, HttpServletRequest request) { - Long userId = resolveUserId(request); - return ResponseEntity.ok(ApiResponse.ok(weightService.getPersonalizedScore(userId, jobId), getRequestId(request))); + Long userId = currentUserAccessor.requireUserId(); + return ResponseEntity.ok(ApiResponse.ok(weightService.getPersonalizedScore(userId, jobId), requestIdAccessor.get(request))); } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java index 02e7ab3..16e095a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java @@ -1,8 +1,6 @@ package com.mydreamjob.risk; import com.mydreamjob.audit.AuditContext; -import com.mydreamjob.auth.User; -import com.mydreamjob.auth.UserRepository; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.PageResponse; import com.mydreamjob.common.exception.BusinessException; @@ -22,8 +20,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -49,8 +45,13 @@ import java.util.List; @RequiredArgsConstructor public class AdminRiskController { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final RiskGovernanceService riskGovernanceService; - private final UserRepository userRepository; @Operation(summary = "风险概览(ADMIN)") @ApiResponses({ @@ -66,7 +67,7 @@ public class AdminRiskController { HttpServletRequest request) { requireAdmin(request); return ResponseEntity.ok(ApiResponse.ok( - riskGovernanceService.overview(parse(from), parse(to)), requestId(request))); + riskGovernanceService.overview(parse(from), parse(to)), requestIdAccessor.get(request))); } @Operation(summary = "风险趋势(ADMIN)") @@ -84,7 +85,7 @@ public class AdminRiskController { HttpServletRequest request) { requireAdmin(request); return ResponseEntity.ok(ApiResponse.ok( - riskGovernanceService.trend(bucket, parse(from), parse(to)), requestId(request))); + riskGovernanceService.trend(bucket, parse(from), parse(to)), requestIdAccessor.get(request))); } @Operation(summary = "风险分布(ADMIN)") @@ -101,7 +102,7 @@ public class AdminRiskController { HttpServletRequest request) { requireAdmin(request); return ResponseEntity.ok(ApiResponse.ok( - riskGovernanceService.distribution(parse(from), parse(to)), requestId(request))); + riskGovernanceService.distribution(parse(from), parse(to)), requestIdAccessor.get(request))); } @Operation(summary = "风险事件分页(ADMIN)") @@ -126,7 +127,7 @@ public class AdminRiskController { page, size, status, severity, ruleCode, parse(from), parse(to)); return ResponseEntity.ok(ApiResponse.ok(PageResponse.of( result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), - requestId(request))); + requestIdAccessor.get(request))); } @Operation(summary = "风险规则列表(ADMIN)") @@ -138,7 +139,7 @@ public class AdminRiskController { @GetMapping("/rules") public ResponseEntity>> rules(HttpServletRequest request) { requireAdmin(request); - return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.listRules(), requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.listRules(), requestIdAccessor.get(request))); } @Operation(summary = "更新风险规则(ADMIN)") @@ -156,7 +157,7 @@ public class AdminRiskController { HttpServletRequest request) { Long adminUserId = requireAdmin(request); return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.updateRule( - ruleCode, req, adminUserId, riskAuditContext(request)), requestId(request))); + ruleCode, req, adminUserId, riskAuditContext(request)), requestIdAccessor.get(request))); } @Operation(summary = "风险事件人工操作(ADMIN)") @@ -174,36 +175,22 @@ public class AdminRiskController { HttpServletRequest request) { Long adminUserId = requireAdmin(request); return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.actionEvent( - eventId, req, adminUserId, riskAuditContext(request)), requestId(request))); + eventId, req, adminUserId, riskAuditContext(request)), requestIdAccessor.get(request))); } + /** + * R01-B/B1:身份来自 {@link com.mydreamjob.security.CurrentUserAccessor}, + * 角色授权委托给公共 {@link com.mydreamjob.security.UserRoleGuard}。 + */ private Long requireAdmin(HttpServletRequest request) { - Long userId = resolveUserId(request); - User admin = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isAdmin = admin.getRoles() != null - && admin.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN); - if (!isAdmin) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } + Long userId = currentUserAccessor.requireUserId(); + userRoleGuard.requireAdmin(userId); return userId; } - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } - /** 风险审计上下文:仅保留 requestId,禁止写入 IP/UA/请求体等。 */ private AuditContext riskAuditContext(HttpServletRequest request) { - return AuditContext.builder().requestId(request.getHeader("X-Request-Id")).build(); + return AuditContext.builder().requestId(requestIdAccessor.get(request)).build(); } private LocalDateTime parse(String s) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java index 067fa6d..626b1a8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java @@ -17,8 +17,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; /** @@ -32,6 +30,13 @@ import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor public class AdminReportController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + /** R01-B:角色授权统一委托给公共 guard,本类不再自行比较角色。 */ + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; + private final ReportService reportService; private final UserRepository userRepository; @@ -54,7 +59,7 @@ public class AdminReportController { Page result = reportService.adminList(status, targetType, category, page, size); return ResponseEntity.ok(ApiResponse.ok(PageResponse.of( result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), - requestId(request))); + requestIdAccessor.get(request))); } @Operation(summary = "举报详情(ADMIN)", @@ -70,7 +75,7 @@ public class AdminReportController { public ResponseEntity> adminDetail(@PathVariable Long id, HttpServletRequest request) { requireAdmin(request); - return ResponseEntity.ok(ApiResponse.ok(reportService.adminDetail(id), requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(reportService.adminDetail(id), requestIdAccessor.get(request))); } @Operation(summary = "举报处理决定(ADMIN)", @@ -95,29 +100,17 @@ public class AdminReportController { ? (admin.getRole() == null ? "ADMIN" : admin.getRole().name()) : "ADMIN"; ReportResponse data = reportService.decide(adminUserId, role, id, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } + /** + * R01-B/B1:身份来自 {@link com.mydreamjob.security.CurrentUserAccessor}, + * 角色授权委托给公共 {@link com.mydreamjob.security.UserRoleGuard}。 + */ private Long requireAdmin(HttpServletRequest request) { - Long userId = resolveUserId(request); - User admin = userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isAdmin = admin.getRoles() != null - && admin.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN); - if (!isAdmin) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } + Long userId = currentUserAccessor.requireUserId(); + userRoleGuard.requireAdmin(userId); return userId; } - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java index 5dd597b..37482d2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java @@ -13,8 +13,6 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; /** @@ -26,6 +24,11 @@ import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor public class BlockController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final BlockService blockService; @Operation(summary = "屏蔽用户", @@ -40,9 +43,9 @@ public class BlockController { @PutMapping("/blocks/{blockedUserId}") public ResponseEntity> block( @PathVariable Long blockedUserId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); BlockRelationResponse data = blockService.block(userId, blockedUserId, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "解除屏蔽", @@ -55,9 +58,9 @@ public class BlockController { @DeleteMapping("/blocks/{blockedUserId}") public ResponseEntity> unblock( @PathVariable Long blockedUserId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); blockService.unblock(userId, blockedUserId, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(null, requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(null, requestIdAccessor.get(request))); } @Operation(summary = "我的屏蔽列表", @@ -71,22 +74,11 @@ public class BlockController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Page result = blockService.listMine(userId, page, size); return ResponseEntity.ok(ApiResponse.ok(PageResponse.of( result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), - requestId(request))); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); + requestIdAccessor.get(request))); } - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java index c2d047c..f21687f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java @@ -17,8 +17,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; /** @@ -32,6 +30,11 @@ import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor public class ReportController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final ReportService reportService; private final UserRepository userRepository; @@ -51,9 +54,9 @@ public class ReportController { @PostMapping("/reports") public ResponseEntity> create( @Valid @RequestBody CreateReportRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ReportResponse data = reportService.create(userId, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } /** /reports/me 必须在 /reports/{id} 之前注册(路由回归测试覆盖) */ @@ -69,11 +72,11 @@ public class ReportController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Page result = reportService.listMine(userId, status, page, size); return ResponseEntity.ok(ApiResponse.ok(PageResponse.of( result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), - requestId(request))); + requestIdAccessor.get(request))); } @Operation(summary = "我的举报详情", @@ -88,9 +91,9 @@ public class ReportController { @GetMapping("/reports/{id}") public ResponseEntity> getDetail( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ReportResponse data = reportService.getDetail(userId, id, isAdmin(userId)); - return ResponseEntity.ok(ApiResponse.ok(data, requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } private boolean isAdmin(Long userId) { @@ -100,15 +103,4 @@ public class ReportController { .orElse(false); } - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java index 99d24fb..90ee146 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java @@ -8,8 +8,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.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -24,6 +22,11 @@ import java.util.Set; @RequestMapping("/admin/scoring/fairness") @RequiredArgsConstructor public class FairnessMonitoringController { + + /** R01-A6:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A6:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; private final FairnessMonitoringService service; @Operation(summary = "评分完整性概览", description = "仅 ADMIN;按现存月度快照聚合,样本不足时隐藏统计明细") @@ -31,7 +34,7 @@ public class FairnessMonitoringController { public ResponseEntity> overview( @RequestParam String period, HttpServletRequest request) { checkParams(request, Set.of("period")); - return ResponseEntity.ok(ApiResponse.ok(service.overview(userId(), period), requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(service.overview(currentUserAccessor.requireUserId(), period), requestIdAccessor.get(request))); } @Operation(summary = "评分维度分布", description = "仅 ADMIN;dimension 仅允许八个评分维度") @@ -39,21 +42,21 @@ public class FairnessMonitoringController { public ResponseEntity> dimensions( @RequestParam String period, @RequestParam(required = false) String dimension, HttpServletRequest request) { checkParams(request, Set.of("period", "dimension")); - return ResponseEntity.ok(ApiResponse.ok(service.dimensions(userId(), period, dimension), requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(service.dimensions(currentUserAccessor.requireUserId(), period, dimension), requestIdAccessor.get(request))); } @Operation(summary = "评分历史漂移", description = "仅 ADMIN;当前快照可覆盖,历史漂移暂不可计算") @GetMapping("/drift") public ResponseEntity> drift(HttpServletRequest request) { checkParams(request, Set.of()); - return ResponseEntity.ok(ApiResponse.ok(service.drift(userId()), requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(service.drift(currentUserAccessor.requireUserId()), requestIdAccessor.get(request))); } @Operation(summary = "排名稳定性", description = "仅 ADMIN;当前没有持久化排名快照,返回 UNAVAILABLE") @GetMapping("/ranking-stability") public ResponseEntity> rankingStability(HttpServletRequest request) { checkParams(request, Set.of()); - return ResponseEntity.ok(ApiResponse.ok(service.rankingStability(userId()), requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(service.rankingStability(currentUserAccessor.requireUserId()), requestIdAccessor.get(request))); } @Operation(summary = "申诉纠错聚合", description = "仅 ADMIN;按申诉创建队列聚合,隐藏小样本明细") @@ -62,14 +65,7 @@ public class FairnessMonitoringController { @RequestParam(required = false) String from, @RequestParam(required = false) String to, HttpServletRequest request) { checkParams(request, Set.of("from", "to")); - return ResponseEntity.ok(ApiResponse.ok(service.appeals(userId(), parseDate(from), parseDate(to)), requestId(request))); - } - - private Long userId() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) throw new BusinessException(ErrorCode.UNAUTHORIZED); - try { return Long.valueOf(auth.getName()); } - catch (NumberFormatException e) { throw new BusinessException(ErrorCode.UNAUTHORIZED); } + return ResponseEntity.ok(ApiResponse.ok(service.appeals(currentUserAccessor.requireUserId(), parseDate(from), parseDate(to)), requestIdAccessor.get(request))); } private LocalDate parseDate(String value) { @@ -84,5 +80,4 @@ public class FairnessMonitoringController { } } - private String requestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java b/mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java new file mode 100644 index 0000000..dbf839a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java @@ -0,0 +1,106 @@ +package com.mydreamjob.security; + +import com.mydreamjob.auth.SessionAuthenticationDetails; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +/** + * 安全关键路径的当前用户解析器。 + * + *

    身份只来自 JWT / SecurityContext,不接受任何请求参数传入的 userId。 + * 所有非法主体一律转换为既有的“未登录”语义({@link InsufficientAuthenticationException} + * 由 {@code GlobalExceptionHandler} 映射为 HTTP 401),从而避免 + * {@code NumberFormatException} 逃逸为 HTTP 500。

    + * + *

    明确拒绝:Authentication 为 null、未认证、{@link AnonymousAuthenticationToken}、 + * 名称为空、名称为 anonymousUser、名称不是合法 Long、userId <= 0。

    + * + *

    本类只负责身份解析,不承担角色校验与资源归属校验—— + * 那两类判断仍由既有业务逻辑负责。

    + */ +@Component +public class CurrentUserAccessor { + + /** 固定安全文本:不回显任何非法 principal。 */ + static final String UNAUTHENTICATED_MESSAGE = "authentication required"; + + private static final String ANONYMOUS_PRINCIPAL = "anonymousUser"; + + /** 解析当前请求的用户 ID(读取 SecurityContext)。 */ + public Long requireUserId() { + return requireUserId(SecurityContextHolder.getContext().getAuthentication()); + } + + /** + * 解析当前请求**已验证**的登录会话 ID(S02)。 + * + *

    会话 ID 只可能由认证过滤器在 Access Token 通过完整校验后写入 + * {@code Authentication.details};客户端无法通过参数或请求体提供。 + * 取不到时一律按未登录处理。

    + */ + public String requireSessionId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null + || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + throw unauthenticated(); + } + if (authentication.getDetails() instanceof SessionAuthenticationDetails details) { + String sessionId = details.getSessionId(); + if (sessionId != null && !sessionId.isBlank()) { + return sessionId; + } + } + throw unauthenticated(); + } + + /** + * 解析用户 ID。任何非法输入都抛出未登录异常,绝不抛出 NumberFormatException。 + * + * @param authentication 待校验的认证对象(可为 null) + * @return 大于 0 的已验证用户 ID + */ + public Long requireUserId(Authentication authentication) { + if (authentication == null + || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + throw unauthenticated(); + } + String name = authentication.getName(); + if (name == null || name.isBlank() || ANONYMOUS_PRINCIPAL.equals(name)) { + throw unauthenticated(); + } + if (!isAsciiDigits(name)) { + throw unauthenticated(); + } + long userId; + try { + userId = Long.parseLong(name); + } catch (NumberFormatException e) { + // 位数溢出等极端情况:同样按未登录处理,不回显原始值 + throw unauthenticated(); + } + if (userId <= 0) { + throw unauthenticated(); + } + return userId; + } + + /** 严格校验:仅接受 ASCII 数字,排除 "+42"、"-42"、空白与 Unicode 数字。 */ + private boolean isAsciiDigits(String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + return true; + } + + private InsufficientAuthenticationException unauthenticated() { + return new InsufficientAuthenticationException(UNAUTHENTICATED_MESSAGE); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java b/mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java new file mode 100644 index 0000000..c2a0c45 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java @@ -0,0 +1,142 @@ +package com.mydreamjob.security; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 业务角色授权守卫(R01-B/B1)。 + * + *

    把散落在各 Service/Controller 中的"读取用户 → 判断角色 → 抛错"重复实现, + * 收束到唯一实现点。

    + * + *

    职责边界

    + *
      + *
    • 只做角色授权,不做资源归属(ownership)校验 —— + * 归属仍由各业务 Service 负责(如 {@code assertOwnerOrThrow});
    • + *
    • 不解析 SecurityContext:"当前登录用户是谁"仍由 + * {@link CurrentUserAccessor} 负责,本类只接收已解析出的 {@code userId};
    • + *
    • 不处理 requestId,不读请求头;
    • + *
    • 不提供任何"由调用方传入角色"的入口:角色只能来自 + * {@link User.Role} 枚举常量,客户端输入无法影响判定;
    • + *
    • 不给 ADMIN 隐式超级权限:{@code requireStudent}/{@code requireCompany} + * 对 ADMIN 同样拒绝,除非调用方显式传入 {@code requireAny(..., ADMIN)};
    • + *
    • 无副作用:只读用户表,不写库、不写审计、不记日志(尤其不记 userId / 手机号 / Token)。
    • + *
    + * + *

    角色来源

    + *

    角色取自 {@code user.getRoles()},即 {@code user_roles} 关联表 + * —— 与本类替换掉的全部既有 guard 完全一致。{@code users.role} 单值列用于 JWT 声明签发, + * 不参与此处授权判定:把两者混用会改变既有语义。

    + * + *

    失败关闭

    + *
      + *
    • 用户不存在 → {@link ErrorCode#USER_NOT_FOUND}(沿用既有错误码);
    • + *
    • 用户存在但 inactive → {@link ErrorCode#FORBIDDEN} + * (比既有实现更严格:被禁用的用户不再因"角色正确"而通过);
    • + *
    • {@code user_roles} 中缺少所需角色 → {@link ErrorCode#FORBIDDEN} + * (数据不一致时同样是拒绝,而不是放行)。
    • + *
    + * + *

    查询次数

    + *

    {@code requireXxx(Long)} 每次调用恰好 1 次 {@code findById}, + * 不做跨请求缓存(用户被注销/禁用后必须立即失效)。调用方若已加载 + * {@link User},应使用 {@code requireXxx(User)} 重载以避免第二次查询。

    + */ +@Component +@RequiredArgsConstructor +public class UserRoleGuard { + + private final UserRepository userRepository; + + // ── 按 userId 校验(每次 1 次查询)────────────────────────── + + public void requireStudent(Long userId) { + requireRole(requireUser(userId), User.Role.STUDENT); + } + + public void requireCompany(Long userId) { + requireRole(requireUser(userId), User.Role.COMPANY); + } + + public void requireAdmin(Long userId) { + requireRole(requireUser(userId), User.Role.ADMIN); + } + + /** 允许集合中的任意一个角色(集合由调用方以枚举常量给出,不接受客户端输入)。 */ + public void requireAny(Long userId, User.Role... allowedRoles) { + requireRole(requireUser(userId), allowedRoles); + } + + // ── 按已加载实体校验(0 次查询)──────────────────────────── + + public void requireStudent(User user) { + requireRole(user, User.Role.STUDENT); + } + + public void requireCompany(User user) { + requireRole(user, User.Role.COMPANY); + } + + public void requireAdmin(User user) { + requireRole(user, User.Role.ADMIN); + } + + public void requireAny(User user, User.Role... allowedRoles) { + requireRole(user, allowedRoles); + } + + // ── 基础能力 ─────────────────────────────────────────────── + + /** + * 加载用户并校验其可用(存在且 active)。 + * + * @throws BusinessException {@link ErrorCode#USER_NOT_FOUND} 用户不存在; + * {@link ErrorCode#FORBIDDEN} 用户存在但已禁用 + */ + public User requireUser(Long userId) { + if (userId == null || userId <= 0) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (!isActive(user)) { + // 失败关闭:被禁用的用户不得因角色正确而通过授权。 + throw new BusinessException(ErrorCode.FORBIDDEN); + } + return user; + } + + /** + * 校验已加载用户是否具备所需角色之一。 + * + *

    角色来源为 {@code user_roles};缺失任一所需角色即拒绝。

    + */ + public void requireRole(User user, User.Role... allowedRoles) { + if (user == null) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + if (!isActive(user)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + if (allowedRoles == null || allowedRoles.length == 0) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + if (user.getRoles() == null) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + for (User.Role allowed : allowedRoles) { + if (allowed != null && user.getRoles().contains(allowed)) { + return; + } + } + throw new BusinessException(ErrorCode.FORBIDDEN); + } + + private static boolean isActive(User user) { + return user.getActive() != null && user.getActive(); + } +} 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/talentdiscovery/TalentDiscoveryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java index 6b37525..67f5f1a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java @@ -12,6 +12,7 @@ import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobRepository; +import com.mydreamjob.recommendation.JobPreferenceScoreCalculator; import com.mydreamjob.recommendation.JobRecommendationProfile; import com.mydreamjob.recommendation.JobRecommendationProfileRepository; import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryCandidateResponse; @@ -46,6 +47,7 @@ public class TalentDiscoveryService { private static final int BATCH_SIZE = 500; private static final Pattern SAFE_SKILL = Pattern.compile("[\\p{L}\\p{N}+#./()()·\\-_ ]+"); + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final StudentTalentDiscoveryPreferenceRepository preferenceRepository; private final UserRepository userRepository; private final CompanyRepository companyRepository; @@ -58,7 +60,7 @@ public class TalentDiscoveryService { @Transactional(readOnly = true) public TalentDiscoveryPreferenceResponse getPreference(Long userId) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); StudentTalentDiscoveryPreference value = preferenceRepository.findByStudentUserId(userId).orElse(null); return toPreference(value); } @@ -67,7 +69,7 @@ public class TalentDiscoveryService { public TalentDiscoveryPreferenceResponse updatePreference(Long userId, TalentDiscoveryPreferenceUpdateRequest request, AuditContext context) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); if (request == null || request.getEnabled() == null) { throw new BusinessException(ErrorCode.BAD_REQUEST); } @@ -177,17 +179,29 @@ public class TalentDiscoveryService { .build(); } + /** + * R01-C:改为委托 {@link JobPreferenceScoreCalculator}。 + * + *

    原先此处内联了与 {@code JobRecommendationService} 完全相同的一套 30/20/20/15/15 规则 + * (维度顺序、权重、匹配语义、{@code matched*100/available}、scale=2、HALF_UP 与 + * available==0 → null 均逐字一致)。收束后两处共用唯一实现,**行为不变**。

    + */ private BigDecimal preferenceScore(TalentDiscoveryCandidateSource.PreferenceSignal preference, JobRecommendationProfile profile, Job job) { if (preference == null) return null; - int got = 0; - int total = 0; - if (!preference.cities().isEmpty()) { total += 30; if (preference.cities().contains(job.getCity())) got += 30; } - if (!preference.employmentTypes().isEmpty()) { total += 20; if (preference.employmentTypes().contains(job.getEmploymentType())) got += 20; } - if (preference.expectedSalaryMin() != null) { total += 20; if (job.getSalaryMax() != null && job.getSalaryMax() >= preference.expectedSalaryMin()) got += 20; } - if (!preference.industries().isEmpty()) { total += 15; if (preference.industries().contains(profile.getIndustryCode())) got += 15; } - if (!preference.jobTypes().isEmpty()) { total += 15; if (preference.jobTypes().contains(profile.getJobFamilyCode())) got += 15; } - return total == 0 ? null : BigDecimal.valueOf(got * 100L).divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP); + return JobPreferenceScoreCalculator.calculate( + new JobPreferenceScoreCalculator.Criteria( + preference.cities(), + preference.employmentTypes(), + preference.expectedSalaryMin(), + preference.industries(), + preference.jobTypes()), + new JobPreferenceScoreCalculator.Target( + job.getCity(), + job.getEmploymentType(), + job.getSalaryMax(), + profile.getIndustryCode(), + profile.getJobFamilyCode())); } private BigDecimal weighted(Map scores, Map expected, Set allowed) { @@ -255,11 +269,6 @@ public class TalentDiscoveryService { if (!Boolean.TRUE.equals(company.getVerified())) throw new BusinessException(ErrorCode.FORBIDDEN); return company; } - - private void requireStudent(Long userId) { - if (!hasRole(requireUser(userId), User.Role.STUDENT)) throw new BusinessException(ErrorCode.FORBIDDEN); - } - private User requireUser(Long userId) { if (userId == null) throw new BusinessException(ErrorCode.UNAUTHORIZED); return userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); 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/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java index 475e094..152df9a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java @@ -48,6 +48,7 @@ public class TalentInvitationService { private static final int DAILY_JOB_LIMIT = 20; private static final ObjectMapper JSON = new ObjectMapper(); + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final TalentInvitationRepository invitationRepository; private final TalentInvitationHistoryRepository historyRepository; private final UserRepository userRepository; @@ -163,7 +164,7 @@ public class TalentInvitationService { @Transactional(readOnly = true) public TalentInvitationPageResponse listStudent(Long userId, int page, int size) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); validatePage(page, size); Page invitations = invitationRepository.findByStudentUserIdOrderByCreatedAtDescIdDesc( userId, PageRequest.of(page, size)); @@ -180,7 +181,7 @@ public class TalentInvitationService { @Transactional public StudentTalentInvitationResponse getStudent(Long userId, Long invitationId, AuditContext context) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); TalentInvitation invitation = invitationRepository.findByIdAndStudentUserId(invitationId, userId) .orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); if (expireIfDue(invitation, context)) { @@ -197,7 +198,7 @@ public class TalentInvitationService { @Transactional public StudentTalentInvitationResponse decide(Long userId, Long invitationId, TalentInvitationDecisionRequest request, AuditContext context) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); if (request == null || request.getDecision() == null || request.getExpectedVersion() == null || !("ACCEPT".equals(request.getDecision()) || "REJECT".equals(request.getDecision()))) { throw new BusinessException(ErrorCode.TALENT_INVITATION_CONTENT_INVALID); @@ -311,11 +312,6 @@ public class TalentInvitationService { if (!company.getId().equals(job.getCompanyId())) throw new BusinessException(ErrorCode.FORBIDDEN); return job; } - - private void requireStudent(Long userId) { - if (!hasRole(requireUser(userId), User.Role.STUDENT)) throw new BusinessException(ErrorCode.FORBIDDEN); - } - private User requireUser(Long userId) { if (userId == null) throw new BusinessException(ErrorCode.UNAUTHORIZED); return userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java index fb4995c..80eed45 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java @@ -14,8 +14,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @@ -34,6 +32,11 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class CompanyVerificationController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final CompanyVerificationService verificationService; // ── 企业:提交认证 ────────────────────────────────────────── @@ -43,10 +46,10 @@ public class CompanyVerificationController { public ResponseEntity>> submit( @Valid @RequestBody VerificationSubmitRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); CompanyVerification verification = verificationService.submit(userId, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), requestIdAccessor.get(request))); } // ── 企业:查询认证状态 ────────────────────────────────────── @@ -55,9 +58,9 @@ public class CompanyVerificationController { @GetMapping("/companies/me/verification") public ResponseEntity>> getMyVerification( HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); CompanyVerification verification = verificationService.getMyVerification(userId); - return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), requestIdAccessor.get(request))); } // ── 管理员:待审核列表 ────────────────────────────────────── @@ -71,7 +74,7 @@ public class CompanyVerificationController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); verificationService.assertAdminRole(userId); Page result = verificationService.listPending(status, from, to, page, size); Map data = new HashMap<>(); @@ -80,7 +83,7 @@ public class CompanyVerificationController { data.put("page", result.getNumber()); data.put("size", result.getSize()); data.put("totalPages", result.getTotalPages()); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } // ── 管理员:认证详情 ──────────────────────────────────────── @@ -90,10 +93,10 @@ public class CompanyVerificationController { public ResponseEntity>> getDetail( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); verificationService.assertAdminRole(userId); CompanyVerification verification = verificationService.getDetail(id); - return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), requestIdAccessor.get(request))); } // ── 管理员:审核决定 ──────────────────────────────────────── @@ -104,11 +107,11 @@ public class CompanyVerificationController { @PathVariable Long id, @Valid @RequestBody VerificationDecisionRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); verificationService.assertAdminRole(userId); CompanyVerification verification = verificationService.decide(userId, id, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), requestIdAccessor.get(request))); } // ── 管理员:审核历史 ──────────────────────────────────────── @@ -118,22 +121,12 @@ public class CompanyVerificationController { public ResponseEntity>>> listHistory( @RequestParam Long verificationId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); verificationService.assertAdminRole(userId); List> data = verificationService.getHistory(verificationId); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } // ── 辅助 ──────────────────────────────────────────────────── - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) - throw new BusinessException(ErrorCode.UNAUTHORIZED); - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationService.java index 416bda8..adc4697 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationService.java @@ -13,6 +13,7 @@ import com.mydreamjob.verification.dto.VerificationDecisionRequest; import com.mydreamjob.verification.dto.VerificationSubmitRequest; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import com.mydreamjob.security.UserRoleGuard; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.OptimisticLockingFailureException; @@ -34,6 +35,7 @@ public class CompanyVerificationService { private static final int MAX_PAGE_SIZE = 100; + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final CompanyVerificationRepository verificationRepository; private final CompanyVerificationHistoryRepository historyRepository; private final CompanyRepository companyRepository; @@ -244,11 +246,7 @@ public class CompanyVerificationService { } public void assertAdminRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - if (!hasRole(user, User.Role.ADMIN)) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } + userRoleGuard.requireAdmin(userId); } private void recordHistory(Long verificationId, String fromStatus, String toStatus, diff --git a/mydreamjob-server/src/main/resources/application-dev.example.yml b/mydreamjob-server/src/main/resources/application-dev.example.yml index ac0f869..c8173b9 100644 --- a/mydreamjob-server/src/main/resources/application-dev.example.yml +++ b/mydreamjob-server/src/main/resources/application-dev.example.yml @@ -21,6 +21,9 @@ spring: app: jwt: secret: ${JWT_SECRET} + access-secret: ${JWT_ACCESS_SECRET} + refresh-secret: ${JWT_REFRESH_SECRET} + token-hash-secret: ${JWT_TOKEN_HASH_SECRET} expiration-ms: 86400000 refresh-expiration-ms: 604800000 diff --git a/mydreamjob-server/src/main/resources/application.yml b/mydreamjob-server/src/main/resources/application.yml index 13e8ac7..09211e7 100644 --- a/mydreamjob-server/src/main/resources/application.yml +++ b/mydreamjob-server/src/main/resources/application.yml @@ -74,15 +74,35 @@ spring: fail-on-unknown-properties: false # JWT +# S02:Access 与 Refresh 使用独立签名密钥,另设独立的 Token 摘要密钥。 +# 三项密钥均无默认值,缺失即启动失败(见 AppJwtProperties 校验)。 app: jwt: - secret: ${JWT_SECRET} + # 旧配置迁移项:S02 签发/校验的 Token **不使用**该密钥,不得回退到它。 + # 仍保留是因为 talentdiscovery.CandidateKeyGenerator 用它派生候选人展示键; + # 该组件在密钥为空时自行失败关闭。这里改为**非强制解析**,使未配置 JWT_SECRET + # 的环境也能启动(Token 安全性完全由下面三项独立密钥承担)。 + secret: ${JWT_SECRET:} + access-secret: ${JWT_ACCESS_SECRET} + refresh-secret: ${JWT_REFRESH_SECRET} + token-hash-secret: ${JWT_TOKEN_HASH_SECRET} expiration-ms: ${JWT_EXPIRATION_MS:86400000} + refresh-expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000} + issuer: mydreamjob + access-audience: mydreamjob-api + refresh-audience: mydreamjob-auth + + # S03-A:一次性管理员初始化。默认关闭;手机号无默认值,必须由部署方显式提供。 + # 初始化成功后部署方必须移除该开关,普通启动不得隐式创建管理员。 + bootstrap: + admin: + enabled: ${ADMIN_BOOTSTRAP_ENABLED:false} + phone: ${ADMIN_BOOTSTRAP_PHONE:} + nickname: ${ADMIN_BOOTSTRAP_NICKNAME:平台管理员} resume: storage-type: ${RESUME_STORAGE_TYPE:local} storage-dir: ${RESUME_STORAGE_DIR:./resumes} export-dir: ${RESUME_EXPORT_DIR:./resume-exports} - refresh-expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000} async: resume-export: @@ -129,6 +149,20 @@ app: header-name: X-Request-Id generate-on-missing: true + # R01-D4-2:GET 评分接口只读,快照由写侧登记任务、后台物化和补偿扫描维护。 + # 生产默认启用;测试环境可在 profile 中显式关闭,避免后台线程干扰确定性测试。 + scoring: + refresh: + enabled: ${SCORING_REFRESH_ENABLED:true} + worker-interval-ms: ${SCORING_REFRESH_WORKER_INTERVAL_MS:60000} + compensation-enabled: ${SCORING_REFRESH_COMPENSATION_ENABLED:true} + compensation-interval-ms: ${SCORING_REFRESH_COMPENSATION_INTERVAL_MS:3600000} + compensation-limit: ${SCORING_REFRESH_COMPENSATION_LIMIT:100} + batch-size: ${SCORING_REFRESH_BATCH_SIZE:20} + max-attempts: ${SCORING_REFRESH_MAX_ATTEMPTS:3} + retry-backoff-ms: ${SCORING_REFRESH_RETRY_BACKOFF_MS:300000} + processing-timeout-ms: ${SCORING_REFRESH_PROCESSING_TIMEOUT_MS:600000} + deletion: cooling-days: ${DELETION_COOLING_DAYS:30} phone-hash-secret: ${DELETION_PHONE_HASH_SECRET:mydreamjob-deletion-dev-secret-not-for-prod} @@ -142,14 +176,27 @@ recommendation: enabled: ${RECOMMENDATION_ASSESSMENT_ENABLED:false} management: + # S03-C:Actuator 隔离到独立管理端口,默认只绑定回环地址。 + # 业务端口不再提供 /actuator/metrics 与 /actuator/prometheus; + # 需要采集指标时由部署层通过受控网络或反向代理访问管理端口。 + server: + port: ${MANAGEMENT_SERVER_PORT:8081} + address: ${MANAGEMENT_SERVER_ADDRESS:127.0.0.1} endpoints: web: exposure: include: health,info,metrics,prometheus + # S03-C:显式开启 Prometheus 指标导出。仅暴露端点 ID 而不开启导出时, + # /actuator/prometheus 不会注册(暴露清单会指向一个不存在的端点)。 + prometheus: + metrics: + export: + enabled: true endpoint: health: - show-details: when-authorized - show-components: always + # 健康探针只返回最小 UP/DOWN,不暴露数据库、Redis、RabbitMQ、磁盘或连接池细节。 + show-details: never + show-components: never health: db: enabled: true diff --git a/mydreamjob-server/src/main/resources/db/migration/V42__auth_token_sessions.sql b/mydreamjob-server/src/main/resources/db/migration/V42__auth_token_sessions.sql new file mode 100644 index 0000000..0c001e9 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V42__auth_token_sessions.sql @@ -0,0 +1,28 @@ +-- S02:Access/Refresh Token 隔离、轮换与吊销 +-- +-- 扩展 auth_tokens,使其能表达“登录会话 + Refresh Token 轮换 + 撤销”: +-- session_id 每次登录的会话 UUID +-- token_jti Refresh Token 的 jti(唯一) +-- parent_token_id 轮换前一条记录 +-- consumed_at 成功刷新时的消费时间 +-- revoked_at 撤销时间 +-- revocation_reason 固定白名单原因 +-- version 乐观锁/条件更新版本 +-- +-- 约束: +-- * 不修改既有列、不删除既有数据、不新增级联删除; +-- * 旧记录 session_id 为 NULL,永远不被新鉴权逻辑视为活动会话; +-- * token_hash 的业务含义固定为 Refresh Token 的 HMAC-SHA256 摘要; +-- * token_jti 允许 NULL(旧行),UNIQUE 在 MySQL 与 H2 下均允许多个 NULL。 + +ALTER TABLE auth_tokens ADD COLUMN session_id VARCHAR(36) NULL; +ALTER TABLE auth_tokens ADD COLUMN token_jti VARCHAR(36) NULL; +ALTER TABLE auth_tokens ADD COLUMN parent_token_id BIGINT NULL; +ALTER TABLE auth_tokens ADD COLUMN consumed_at DATETIME NULL; +ALTER TABLE auth_tokens ADD COLUMN revoked_at DATETIME NULL; +ALTER TABLE auth_tokens ADD COLUMN revocation_reason VARCHAR(40) NULL; +ALTER TABLE auth_tokens ADD COLUMN version BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE auth_tokens ADD INDEX idx_auth_tokens_session_active (session_id, revoked, consumed_at, expired_at); +ALTER TABLE auth_tokens ADD INDEX idx_auth_tokens_user_session (user_id, session_id); +ALTER TABLE auth_tokens ADD CONSTRAINT uk_auth_tokens_token_jti UNIQUE (token_jti); diff --git a/mydreamjob-server/src/main/resources/db/migration/V43__disable_legacy_seed_accounts.sql b/mydreamjob-server/src/main/resources/db/migration/V43__disable_legacy_seed_accounts.sql new file mode 100644 index 0000000..bdb72b1 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V43__disable_legacy_seed_accounts.sql @@ -0,0 +1,40 @@ +-- S03-A:禁用仍保持 V1_1 原始指纹的 Flyway 演示账号 +-- +-- 背景:V1_1__seed_data.sql 无环境隔离地插入了 ADMIN / STUDENT / COMPANY 三个演示账号, +-- 并带有公开的 bcrypt 口令哈希。这些账号不能在生产继续可用。 +-- +-- 原则: +-- * 只将 users.is_active 由 1 置为 0;不删除用户、角色、审计或任何业务数据,不重置自增; +-- * 不依赖固定 id(不使用 id = 1/2/3); +-- * 每条 UPDATE 同时匹配 V1_1 中的**原始手机号 + 原始邮箱 + 原始公开 bcrypt 哈希 + +-- 原始主角色 + is_active = 1**;任一指纹被运营人员改过的账号都不会命中,因而保持不受影响; +-- * 幂等:命中行被置为 is_active = 0 后,再次执行不会再匹配(条件含 is_active = 1)。 +-- +-- 认证签发链已拒绝 inactive 用户,因此被禁用的账号无法再取得会话。 + +-- ADMIN +UPDATE users + SET is_active = 0 + WHERE phone = '13800000001' + AND email = 'admin@mydreamjob.com' + AND password_hash = '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi' + AND role = 'ADMIN' + AND is_active = 1; + +-- STUDENT +UPDATE users + SET is_active = 0 + WHERE phone = '13800000002' + AND email = 'student@mydreamjob.com' + AND password_hash = '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi' + AND role = 'STUDENT' + AND is_active = 1; + +-- COMPANY +UPDATE users + SET is_active = 0 + WHERE phone = '13800000003' + AND email = 'company@mydreamjob.com' + AND password_hash = '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi' + AND role = 'COMPANY' + AND is_active = 1; diff --git a/mydreamjob-server/src/main/resources/db/migration/V44__candidate_scoring_refresh_tasks.sql b/mydreamjob-server/src/main/resources/db/migration/V44__candidate_scoring_refresh_tasks.sql new file mode 100644 index 0000000..1a94ecd --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V44__candidate_scoring_refresh_tasks.sql @@ -0,0 +1,31 @@ +-- V44__candidate_scoring_refresh_tasks.sql +-- R01-D4-1:评分快照刷新任务表(持久化物化基础设施) +-- 只登记"该学生+周期需要重新物化",不保存评分源数据;GET 兜底写入在 D4-2 前保留。 + +CREATE TABLE candidate_scoring_refresh_tasks ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + student_user_id BIGINT NOT NULL, + target_period VARCHAR(7) NOT NULL COMMENT 'yyyy-MM', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + requested_at DATETIME NOT NULL, + processing_started_at DATETIME NULL, + processed_at DATETIME NULL, + next_attempt_at DATETIME NULL, + attempts INT NOT NULL DEFAULT 0, + last_error_code VARCHAR(40) NULL, + version BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_refresh_task_student_period UNIQUE (student_user_id, target_period), + CONSTRAINT ck_refresh_task_status CHECK (status IN ('PENDING', 'PROCESSING', 'RETRY', 'COMPLETED', 'FAILED')), + INDEX idx_refresh_task_claim (status, next_attempt_at), + CONSTRAINT fk_refresh_task_student FOREIGN KEY (student_user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 补偿扫描持久游标:跨实例、跨重启延续扫描位置;单轮只处理配置预算内的学生。 +CREATE TABLE candidate_scoring_refresh_scan_checkpoints ( + scan_key VARCHAR(40) PRIMARY KEY, + last_user_id BIGINT NOT NULL DEFAULT 0, + version BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V45__add_scoring_refresh_claim_token.sql b/mydreamjob-server/src/main/resources/db/migration/V45__add_scoring_refresh_claim_token.sql new file mode 100644 index 0000000..823efd6 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V45__add_scoring_refresh_claim_token.sql @@ -0,0 +1,13 @@ +-- V45__add_scoring_refresh_claim_token.sql +-- R01-D4-2 claim fence 定向整改:为刷新任务引入不可复用的领取令牌。 +-- +-- 背景:V44 的 processing_started_at 为秒级 DATETIME,当租约回收与重新领取落在同一秒时, +-- 新旧两次领取得到完全相同的所有权标识,旧 Worker 的 PENDING 回退会误改新持有者。 +-- 本迁移新增 claim_token:每次成功领取写入全新随机 UUID,终态/PENDING 时清空。 +-- +-- 只新增列,不改动 V1–V44,不改动业务数据,不新增公网接口或 ErrorCode。 +-- processing_started_at 继续保留用于超时扫描与观测,但不再承担所有权证明。 + +ALTER TABLE candidate_scoring_refresh_tasks + ADD COLUMN claim_token VARCHAR(36) NULL + COMMENT '本次领取的不可复用令牌(UUID),终态/待处理时清空;仅用于并发所有权判定'; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityAttemptServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityAttemptServiceTest.java index 35e01b4..22e1815 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityAttemptServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityAttemptServiceTest.java @@ -8,6 +8,8 @@ import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import org.junit.jupiter.api.BeforeEach; @@ -56,12 +58,18 @@ class AbilityAttemptServiceTest { @Mock private AuditService auditService; @Mock private Clock clock; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private AbilityAttemptService service; private final AuditContext ctx = AuditContext.builder().requestId("r").build(); @BeforeEach void time() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + org.mockito.Mockito.lenient().when(clock.getZone()).thenReturn(ZoneId.systemDefault()); org.mockito.Mockito.lenient().when(clock.instant()).thenReturn(Instant.parse("2026-09-03T08:00:00Z")); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java index c79f873..62b1f77 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java @@ -76,8 +76,7 @@ class AbilityControllerTest { } private String token(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java index 1469230..22c1a53 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java @@ -78,18 +78,15 @@ class ApplicationControllerTest { } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken( - COMPANY_USER_ID, "1", Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(COMPANY_USER_ID, "COMPANY"); } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken( - STUDENT_USER_ID, "1", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_USER_ID, "STUDENT"); } private String adminToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken( - ADMIN_USER_ID, "1", Map.of("roles", "ADMIN")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(ADMIN_USER_ID, "ADMIN"); } private void mockCompanyExists() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java index 092393d..0af13ac 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java @@ -37,6 +37,7 @@ class ApplicationServiceTest { @Mock private ApplicationStatusHistoryRepository historyRepository; @Mock private com.mydreamjob.notification.NotificationService notificationService; @Mock private EntityManager em; + @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; @InjectMocks private ApplicationService applicationService; private static final Long STUDENT_ID = 100L; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java new file mode 100644 index 0000000..6fdf1dc --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java @@ -0,0 +1,320 @@ +package com.mydreamjob.application; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.privacy.ResumeVisibility; +import com.mydreamjob.privacy.StudentPrivacySettings; +import com.mydreamjob.privacy.StudentPrivacySettingsRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +/** + * S03-B 企业简历授权决策矩阵(设计 §4.3)。 + * + *

    真实策略 + Mock Repository;覆盖三种可见性 × 全部投递状态 × 公司归属 × 跨企业 × 未认证, + * 以及下载入口的拒绝语义与错误码。

    + */ +@ExtendWith(MockitoExtension.class) +// 决策矩阵用例会一次性准备好「企业 + 岗位 + 投递 + 隐私」的完整上下文, +// 而拒绝分支会提前返回、不消费全部 stub —— 这是刻意覆盖矩阵的必然结果, +// 因此关闭「未使用 stub」的严格校验,断言本身仍逐条生效。 +@MockitoSettings(strictness = Strictness.LENIENT) +class CompanyResumeAccessPolicyTest { + + private static final Long STUDENT_ID = 100L; + private static final Long COMPANY_ID = 200L; + private static final Long OTHER_COMPANY_ID = 201L; + private static final Long COMPANY_USER_ID = 300L; + private static final Long APP_ID = 400L; + private static final Long JOB_ID = 500L; + + private static final List ALL_STATUSES = List.of( + "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); + private static final List EFFECTIVE = List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED"); + private static final List INEFFECTIVE = List.of("REJECTED", "WITHDRAWN"); + + @Mock private CompanyRepository companyRepository; + @Mock private ApplicationRepository applicationRepository; + @Mock private JobRepository jobRepository; + @Mock private StudentPrivacySettingsRepository privacyRepository; + + @InjectMocks private CompanyResumeAccessPolicy policy; + + // ── 工具 ──────────────────────────────────────────────────── + + private void givenApplication(String status) { + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of( + Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(STUDENT_ID).status(status).build())); + } + + private void givenJobOwnedBy(Long companyId) { + when(jobRepository.findById(JOB_ID)).thenReturn(Optional.of( + Job.builder().id(JOB_ID).companyId(companyId).build())); + } + + private void givenVisibility(ResumeVisibility visibility) { + when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of( + StudentPrivacySettings.builder().studentUserId(STUDENT_ID) + .resumeVisibility(visibility).contactVisible(false).version(0L).build())); + } + + private void givenCompany(Long companyId, boolean verified) { + when(companyRepository.findById(companyId)).thenReturn(Optional.of( + Company.builder().id(companyId).verified(verified).build())); + } + + // ── 参数与记录缺失:一律拒绝 ──────────────────────────────── + + @Test + void nullArgumentsAreRejected() { + assertFalse(policy.isVisible(null, COMPANY_ID, APP_ID)); + assertFalse(policy.isVisible(STUDENT_ID, null, APP_ID)); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, null)); + } + + @Test + void missingApplicationIsRejected() { + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.empty()); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + @Test + void studentMismatchIsRejected() { + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of( + Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(999L).status("SUBMITTED").build())); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + @Test + void missingJobIsRejected() { + givenApplication("SUBMITTED"); + when(jobRepository.findById(JOB_ID)).thenReturn(Optional.empty()); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + // ── PRIVATE / 无记录 ─────────────────────────────────────── + + @Test + void privateIsRejectedForEveryStatusEvenWhenCompanyVerified() { + for (String status : ALL_STATUSES) { + givenApplication(status); + givenJobOwnedBy(COMPANY_ID); + givenVisibility(ResumeVisibility.PRIVATE); + givenCompany(COMPANY_ID, true); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "PRIVATE 必须拒绝:" + status); + } + } + + @Test + void noSettingsRecordIsTreatedAsPrivate() { + for (String status : ALL_STATUSES) { + givenApplication(status); + givenJobOwnedBy(COMPANY_ID); + when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "无记录必须按 PRIVATE 拒绝:" + status); + } + } + + @Test + void nullVisibilityIsTreatedAsPrivate() { + givenApplication("SUBMITTED"); + givenJobOwnedBy(COMPANY_ID); + when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of( + StudentPrivacySettings.builder().studentUserId(STUDENT_ID) + .resumeVisibility(null).contactVisible(false).version(0L).build())); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + // ── APPLIED_COMPANIES ────────────────────────────────────── + + @Test + void appliedCompaniesAllowsOnlyEffectiveStatusesWhenCompanyOwnsJob() { + givenJobOwnedBy(COMPANY_ID); + givenVisibility(ResumeVisibility.APPLIED_COMPANIES); + for (String status : ALL_STATUSES) { + givenApplication(status); + boolean expected = EFFECTIVE.contains(status); + assertEquals(expected, policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), + "APPLIED_COMPANIES 状态 " + status + " 判定不符"); + } + } + + // ── APPLIED_COMPANIES 必须是白名单(失败关闭)────────────── + + @Test + void appliedCompaniesAllowsExactlyFourReviewedStatuses() { + givenJobOwnedBy(COMPANY_ID); + givenVisibility(ResumeVisibility.APPLIED_COMPANIES); + + for (String status : ALL_STATUSES) { + givenApplication(status); + boolean expected = EFFECTIVE.contains(status); + assertEquals(expected, policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), + "只有四个已审阅状态可放行:" + status); + } + } + + @Test + void appliedCompaniesRejectsNullStatus() { + givenJobOwnedBy(COMPANY_ID); + givenVisibility(ResumeVisibility.APPLIED_COMPANIES); + givenApplication(null); + + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "null 状态必须拒绝"); + } + + @Test + void appliedCompaniesRejectsBlankStatus() { + givenJobOwnedBy(COMPANY_ID); + givenVisibility(ResumeVisibility.APPLIED_COMPANIES); + for (String blank : new String[]{"", " ", " ", "\t"}) { + givenApplication(blank); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "空白状态必须拒绝:'" + blank + "'"); + } + } + + @Test + void appliedCompaniesRejectsUnknownStatus() { + givenJobOwnedBy(COMPANY_ID); + givenVisibility(ResumeVisibility.APPLIED_COMPANIES); + for (String unknown : new String[]{"PENDING", "DRAFT", "ARCHIVED", "DELETED", "REVIEWING"}) { + givenApplication(unknown); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "未知状态必须拒绝:" + unknown); + } + } + + @Test + void appliedCompaniesRejectsLowercaseStatus() { + givenJobOwnedBy(COMPANY_ID); + givenVisibility(ResumeVisibility.APPLIED_COMPANIES); + for (String lower : new String[]{"submitted", "viewed", "interview", "hired"}) { + givenApplication(lower); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), + "大小写变体必须拒绝(不得宽松匹配):" + lower); + } + } + + @Test + void appliedCompaniesRejectsOtherCompanyEvenWithEffectiveApplication() { + givenJobOwnedBy(OTHER_COMPANY_ID); + givenVisibility(ResumeVisibility.APPLIED_COMPANIES); + for (String status : EFFECTIVE) { + givenApplication(status); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), + "跨企业必须拒绝(投递属于其他公司的岗位):" + status); + } + } + + // ── VERIFIED_COMPANIES ───────────────────────────────────── + + @Test + void verifiedCompaniesAllowsAnyStatusWhenVerifiedAndOwnsJob() { + givenJobOwnedBy(COMPANY_ID); + givenVisibility(ResumeVisibility.VERIFIED_COMPANIES); + givenCompany(COMPANY_ID, true); + for (String status : ALL_STATUSES) { + givenApplication(status); + assertTrue(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), + "VERIFIED_COMPANIES 已认证且拥有岗位时状态不额外限制:" + status); + } + } + + @Test + void verifiedCompaniesRejectsUnverifiedCompany() { + givenJobOwnedBy(COMPANY_ID); + givenVisibility(ResumeVisibility.VERIFIED_COMPANIES); + givenCompany(COMPANY_ID, false); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + @Test + void verifiedCompaniesRejectsUnknownCompany() { + givenJobOwnedBy(COMPANY_ID); + givenVisibility(ResumeVisibility.VERIFIED_COMPANIES); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty()); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + @Test + void verifiedCompaniesRejectsWhenCompanyDoesNotOwnJob() { + givenJobOwnedBy(OTHER_COMPANY_ID); + givenVisibility(ResumeVisibility.VERIFIED_COMPANIES); + givenCompany(COMPANY_ID, true); + for (String status : ALL_STATUSES) { + givenApplication(status); + assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), + "已验证企业也必须拥有该投递岗位:" + status); + } + } + + // ── 下载入口的拒绝语义 ──────────────────────────────────── + + @Test + void authorizeForDownloadRejectsUserWithoutCompany() { + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> policy.authorizeForDownload(COMPANY_USER_ID, APP_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void authorizeForDownloadReturnsNotFoundForMissingApplication() { + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of( + Company.builder().id(COMPANY_ID).verified(true).build())); + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.empty()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> policy.authorizeForDownload(COMPANY_USER_ID, APP_ID)); + assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test + void authorizeForDownloadRejectsCrossCompanyWithForbidden() { + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of( + Company.builder().id(COMPANY_ID).verified(true).build())); + givenApplication("SUBMITTED"); + givenJobOwnedBy(OTHER_COMPANY_ID); + givenVisibility(ResumeVisibility.APPLIED_COMPANIES); + + BusinessException ex = assertThrows(BusinessException.class, + () -> policy.authorizeForDownload(COMPANY_USER_ID, APP_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void authorizeForDownloadReturnsStudentIdWhenAllowed() { + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of( + Company.builder().id(COMPANY_ID).verified(false).build())); + givenApplication("SUBMITTED"); + givenJobOwnedBy(COMPANY_ID); + givenVisibility(ResumeVisibility.APPLIED_COMPANIES); + + assertEquals(STUDENT_ID, policy.authorizeForDownload(COMPANY_USER_ID, APP_ID).studentUserId()); + } + + @Test + void authorizeForDownloadRejectsNullArguments() { + assertEquals(ErrorCode.FORBIDDEN.getCode(), + assertThrows(BusinessException.class, + () -> policy.authorizeForDownload(null, APP_ID)).getErrorCodeInt()); + assertEquals(ErrorCode.FORBIDDEN.getCode(), + assertThrows(BusinessException.class, + () -> policy.authorizeForDownload(COMPANY_USER_ID, null)).getErrorCodeInt()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java new file mode 100644 index 0000000..d6e2518 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java @@ -0,0 +1,606 @@ +package com.mydreamjob.application; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.auth.support.TestAuthTokens; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.job.JobStatus; +import com.mydreamjob.privacy.ResumeVisibility; +import com.mydreamjob.privacy.StudentPrivacySettings; +import com.mydreamjob.privacy.StudentPrivacySettingsRepository; +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import com.mydreamjob.profile.storage.ResumeStorageService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * S03-B 企业简历下载权限矩阵(设计 §4.3),真实 SecurityFilterChain + 真实 H2 + 真实本地存储。 + * + *

    只用一个计数包装器包住存储实现,用于证明**拒绝路径零文件读取**; + * 授权链本身完全真实,未被 Mock。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = "app.resume.storage-dir=target/s03-test-resumes") +class CompanyResumeDownloadIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(8800); + private static final List ALL_STATUSES = + List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); + private static final List EFFECTIVE = List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED"); + + /** 统计 read() 调用次数,用于断言拒绝路径不触碰文件。 */ + static final AtomicInteger READ_CALLS = new AtomicInteger(); + + @TestConfiguration + static class CountingStorageConfig { + @Bean + @Primary + ResumeStorageService countingResumeStorageService( + com.mydreamjob.profile.storage.LocalResumeStorageService delegate) { + return new ResumeStorageService() { + @Override + public String store(Long userId, org.springframework.web.multipart.MultipartFile file) { + return delegate.store(userId, file); + } + + @Override + public byte[] read(String storagePath) { + READ_CALLS.incrementAndGet(); + return delegate.read(storagePath); + } + + @Override + public void delete(String storagePath) { + delegate.delete(storagePath); + } + }; + } + } + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired private MockMvc mockMvc; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JobRepository jobRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private StudentProfileRepository profileRepository; + @Autowired private StudentPrivacySettingsRepository privacyRepository; + @Autowired private ResumeStorageService resumeStorageService; + @Autowired private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; + + private Long companyUserId; + private Long otherCompanyUserId; + private Long companyId; + private Long otherCompanyId; + private Long studentUserId; + private Long adminUserId; + private Long jobId; + private Long applicationId; + private String storagePath; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + READ_CALLS.set(0); + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + companyUserId = createUser(User.Role.COMPANY, "S03 企业"); + otherCompanyUserId = createUser(User.Role.COMPANY, "S03 他企"); + studentUserId = createUser(User.Role.STUDENT, "S03 学生"); + adminUserId = createUser(User.Role.ADMIN, "S03 管理员"); + + companyId = companyRepository.save(Company.builder() + .ownerUserId(companyUserId).name("S03 甲企业").verified(false).build()).getId(); + otherCompanyId = companyRepository.save(Company.builder() + .ownerUserId(otherCompanyUserId).name("S03 乙企业").verified(true).build()).getId(); + + jobId = jobRepository.save(Job.builder() + .companyId(companyId).title("S03 岗位").description("d").city("杭州") + .employmentType("FULL_TIME").status(JobStatus.PUBLISHED.name()).build()).getId(); + + applicationId = applicationRepository.save(Application.builder() + .jobId(jobId).studentUserId(studentUserId).status("SUBMITTED").build()).getId(); + + // 真实写入一个 PDF 文件并挂到学生档案 + MockMultipartFile file = new MockMultipartFile("file", "resume.pdf", "application/pdf", + "%PDF-1.4 s03 content".getBytes(StandardCharsets.UTF_8)); + storagePath = resumeStorageService.store(studentUserId, file); + profileRepository.save(StudentProfile.builder() + .userId(studentUserId) + .resumeUrl(storagePath) + .resumeOriginalName("我的简历.pdf") + .resumeContentType("application/pdf") + .resumeSize(file.getSize()) + .build()); + } + + @AfterEach + void tearDown() { + if (storagePath != null) { + resumeStorageService.delete(storagePath); + } + profileRepository.findByUserId(studentUserId).ifPresent(profileRepository::delete); + privacyRepository.findByStudentUserId(studentUserId).ifPresent(privacyRepository::delete); + if (applicationId != null) applicationRepository.deleteById(applicationId); + if (jobId != null) jobRepository.deleteById(jobId); + if (companyId != null) companyRepository.deleteById(companyId); + if (otherCompanyId != null) companyRepository.deleteById(otherCompanyId); + List.of(companyUserId, otherCompanyUserId, studentUserId, adminUserId) + .forEach(id -> { if (id != null) userRepository.deleteById(id); }); + } + + private Long createUser(User.Role role, String nickname) { + return userRepository.save(User.builder() + .phone("17" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname(nickname) + .role(role) + .roles(Set.of(role)) + .active(true) + .verified(true) + .build()).getId(); + } + + private void setVisibility(ResumeVisibility visibility) { + privacyRepository.findByStudentUserId(studentUserId) + .ifPresent(privacyRepository::delete); + if (visibility != null) { + privacyRepository.saveAndFlush(StudentPrivacySettings.builder() + .studentUserId(studentUserId).resumeVisibility(visibility) + .contactVisible(false).version(0L).build()); + } + } + + private void setStatus(String status) { + Application app = applicationRepository.findById(applicationId).orElseThrow(); + app.setStatus(status); + applicationRepository.saveAndFlush(app); + } + + private String url() { + return "/company/candidates/" + applicationId + "/resume"; + } + + private String companyToken() { + return TestAuthTokens.bearer(companyUserId, "COMPANY"); + } + + // ── 决策矩阵:3 种可见性 × 6 种状态 ───────────────────────── + + @Test + void privateIsDeniedForEveryStatusAndNeverReadsFile() { + setVisibility(ResumeVisibility.PRIVATE); + for (String status : ALL_STATUSES) { + setStatus(status); + READ_CALLS.set(0); + int code = perform(companyToken()); + assertThat(code).as("PRIVATE 必须 403:" + status).isEqualTo(403); + assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + status).isZero(); + } + } + + @Test + void missingSettingsIsDeniedAsPrivate() { + setVisibility(null); + for (String status : ALL_STATUSES) { + setStatus(status); + READ_CALLS.set(0); + assertThat(perform(companyToken())).as("无设置按 PRIVATE 拒绝:" + status).isEqualTo(403); + assertThat(READ_CALLS.get()).isZero(); + } + } + + @Test + void appliedCompaniesAllowsOnlyEffectiveStatusesForOwningCompany() throws Exception { + setVisibility(ResumeVisibility.APPLIED_COMPANIES); + for (String status : ALL_STATUSES) { + setStatus(status); + READ_CALLS.set(0); + if (EFFECTIVE.contains(status)) { + mockMvc.perform(get(url()).header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(content().contentType("application/pdf")); + assertThat(READ_CALLS.get()).as("授权通过应读取一次:" + status).isEqualTo(1); + } else { + assertThat(perform(companyToken())).as("WITHDRAWN/REJECTED 应 403:" + status).isEqualTo(403); + assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + status).isZero(); + } + } + } + + @Test + void verifiedCompaniesAllowsAnyStatusOnlyForVerifiedOwningCompany() { + // 甲企业未认证 → 全部拒绝 + setVisibility(ResumeVisibility.VERIFIED_COMPANIES); + for (String status : ALL_STATUSES) { + setStatus(status); + READ_CALLS.set(0); + assertThat(perform(companyToken())).as("未认证企业应 403:" + status).isEqualTo(403); + assertThat(READ_CALLS.get()).isZero(); + } + + // 认证后 → 全部状态允许 + Company company = companyRepository.findById(companyId).orElseThrow(); + company.setVerified(true); + companyRepository.saveAndFlush(company); + for (String status : ALL_STATUSES) { + setStatus(status); + READ_CALLS.set(0); + assertThat(perform(companyToken())).as("已认证且拥有岗位应 200:" + status).isEqualTo(200); + assertThat(READ_CALLS.get()).isEqualTo(1); + } + } + + // ── APPLIED_COMPANIES 白名单:退化状态必须 403 且零读取 ────── + + @Test + void appliedCompaniesUnknownStatusReturns403WithoutReadingStorage() throws Exception { + setVisibility(ResumeVisibility.APPLIED_COMPANIES); + + for (String unknown : new String[]{"PENDING", "DRAFT", "ARCHIVED", "submitted"}) { + setStatus(unknown); + READ_CALLS.set(0); + + var result = mockMvc.perform(get(url()).header("Authorization", companyToken())) + .andReturn().getResponse(); + assertThat(result.getStatus()).as("未知/变体状态必须 403:" + unknown).isEqualTo(403); + assertThat(result.getContentAsString()).as("业务码必须 2002:" + unknown).contains("\"code\":2002"); + assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + unknown).isZero(); + } + } + + @Test + void appliedCompaniesBlankStatusReturns403WithoutReadingStorage() throws Exception { + // 说明:applications.status 在 V2 中是 NOT NULL,因此 DB 层无法保存 NULL; + // NULL 情形由 CompanyResumeAccessPolicyTest 在内存中覆盖,这里用可达的空白值验证。 + setVisibility(ResumeVisibility.APPLIED_COMPANIES); + + for (String blank : new String[]{"", " "}) { + setStatus(blank); + READ_CALLS.set(0); + + var result = mockMvc.perform(get(url()).header("Authorization", companyToken())) + .andReturn().getResponse(); + assertThat(result.getStatus()).as("空白状态必须 403").isEqualTo(403); + assertThat(result.getContentAsString()).as("业务码必须 2002").contains("\"code\":2002"); + assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件").isZero(); + } + } + + // ── 跨企业 ───────────────────────────────────────────────── + + @Test + void otherCompanyIsAlwaysDenied() { + setVisibility(ResumeVisibility.APPLIED_COMPANIES); + READ_CALLS.set(0); + assertThat(perform(TestAuthTokens.bearer(otherCompanyUserId, "COMPANY"))) + .as("跨企业必须 403").isEqualTo(403); + assertThat(READ_CALLS.get()).isZero(); + + setVisibility(ResumeVisibility.VERIFIED_COMPANIES); + READ_CALLS.set(0); + assertThat(perform(TestAuthTokens.bearer(otherCompanyUserId, "COMPANY"))) + .as("其他企业即使已认证也必须 403").isEqualTo(403); + assertThat(READ_CALLS.get()).isZero(); + } + + // ── 角色与非认证 ─────────────────────────────────────────── + + @Test + void studentAdminAndAnonymousAreDenied() { + READ_CALLS.set(0); + setVisibility(ResumeVisibility.VERIFIED_COMPANIES); + Company company = companyRepository.findById(companyId).orElseThrow(); + company.setVerified(true); + companyRepository.saveAndFlush(company); + + assertThat(perform(TestAuthTokens.bearer(studentUserId, "STUDENT"))).as("STUDENT 应 403").isEqualTo(403); + assertThat(perform(TestAuthTokens.bearer(adminUserId, "ADMIN"))).as("ADMIN 应 403").isEqualTo(403); + assertThat(performNoAuth()).as("匿名应 401").isEqualTo(401); + assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件").isZero(); + } + + @Test + void nonexistentApplicationIsNotFound() { + setVisibility(ResumeVisibility.APPLIED_COMPANIES); + READ_CALLS.set(0); + String token = companyToken(); + int code; + try { + code = mockMvc.perform(get("/company/candidates/99999999/resume").header("Authorization", token)) + .andReturn().getResponse().getStatus(); + } catch (Exception e) { + code = -1; + } + assertThat(code).as("不存在的投递应 404").isEqualTo(404); + assertThat(READ_CALLS.get()).isZero(); + } + + @Test + void missingResumeIsProfileNotFound() { + setVisibility(ResumeVisibility.APPLIED_COMPANIES); + StudentProfile profile = profileRepository.findByUserId(studentUserId).orElseThrow(); + profile.setResumeUrl(null); + profileRepository.saveAndFlush(profile); + + assertThat(perform(companyToken())).as("授权通过但无简历应 404").isEqualTo(404); + } + + // ── 响应脱敏 ─────────────────────────────────────────────── + + @Test + void responseDoesNotLeakPathOrIdentity() throws Exception { + setVisibility(ResumeVisibility.APPLIED_COMPANIES); + + String body = mockMvc.perform(get(url()).header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(body).as("不得回显存储路径").doesNotContain(storagePath); + assertThat(body).doesNotContain("resumeUrl"); + assertThat(body).doesNotContain(String.valueOf(studentUserId)); + assertThat(body).doesNotContain("studentUserId"); + } + + @Test + void contentDispositionUsesSanitizedOriginalName() throws Exception { + setVisibility(ResumeVisibility.APPLIED_COMPANIES); + + mockMvc.perform(get(url()).header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", + org.hamcrest.Matchers.containsString("attachment"))) + .andExpect(header().string("Content-Disposition", + org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("../")))) + .andExpect(header().string("Cache-Control", "no-store")); + } + + // ── 企业候选人列表/详情仍不含 resumeUrl ───────────────────── + + @Test + void companyCandidateEndpointsStillExcludeResumeUrl() throws Exception { + setVisibility(ResumeVisibility.APPLIED_COMPANIES); + String token = companyToken(); + + String detail = mockMvc.perform(get("/company/candidates/" + applicationId) + .header("Authorization", token)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertThat(detail).doesNotContain("resumeUrl"); + + String list = mockMvc.perform(get("/company/candidates").header("Authorization", token)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertThat(list).doesNotContain("resumeUrl"); + } + + // ── 运行时 OpenAPI 契约(S03 只新增一个企业下载 operation)── + + @Test + void openApiContractForCompanyResumeDownload() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + var root = new com.fasterxml.jackson.databind.ObjectMapper().readTree(json); + var paths = root.path("paths"); + var schemas = root.path("components").path("schemas"); + + long pathCount = paths.size(); + long opCount = 0; + var it = paths.fieldNames(); + while (it.hasNext()) { + var ops = paths.path(it.next()); + var f = ops.fieldNames(); + while (f.hasNext()) { + if (java.util.Set.of("get", "post", "put", "delete", "patch") + .contains(f.next())) { + opCount++; + } + } + } + assertThat(pathCount).as("S03 基线应为 176 paths").isEqualTo(176); + assertThat(opCount).as("S03 基线应为 214 operations").isEqualTo(214); + + var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); + assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); + assertThat(download.has("requestBody")).as("下载接口不得有 requestBody").isFalse(); + + // 不得存在按 studentUserId / candidateKey 下载简历的入口 + var names = new java.util.ArrayList(); + paths.fieldNames().forEachRemaining(names::add); + for (String p : names) { + String lower = p.toLowerCase(); + if (lower.contains("resume") || lower.contains("download")) { + assertThat(lower).as("不得按 studentUserId 下载:" + p).doesNotContain("studentuserid"); + assertThat(lower).as("不得按 candidateKey 下载:" + p).doesNotContain("candidatekey"); + } + } + + // 企业候选人 DTO 不得含 resumeUrl / studentUserId / phone / email / storagePath + for (String dto : new String[]{"CandidateDetailView", "CandidateItemView"}) { + var node = schemas.path(dto); + if (node.isMissingNode()) { + continue; + } + String text = node.toString(); + for (String forbidden : new String[]{"resumeUrl", "studentUserId", "phone", "email", "storagePath"}) { + assertThat(text).as(dto + " 不得包含 " + forbidden).doesNotContain(forbidden); + } + } + + // Actuator 不得进入业务 OpenAPI + for (String p : names) { + assertThat(p).as("Actuator 不得出现在业务契约").doesNotStartWith("/actuator"); + } + + // 开发辅助:-Ds03.openapi.snapshot=<目录> 时导出快照 + String snapshotDir = System.getProperty("s03.openapi.snapshot"); + if (snapshotDir != null && !snapshotDir.isBlank()) { + java.nio.file.Files.write( + java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"), + json.getBytes(StandardCharsets.UTF_8)); + } + } + + // ── 副作用:企业下载只读 ─────────────────────────────────── + + /** + * 表名**固定白名单**(不做任意外部表名拼接)+ 每张表明确选择的稳定业务字段。 + * 字段名以真实表结构为准。 + */ + private static java.util.Map digestColumns() { + java.util.Map m = new java.util.LinkedHashMap<>(); + m.put("users", "id, phone, email, role, is_active, is_verified, updated_at"); + m.put("user_roles", "user_id, role"); + m.put("student_profiles", + "id, user_id, resume_url, resume_original_name, resume_content_type, resume_size, updated_at"); + m.put("resume_versions", + "id, user_id, version_no, status, file_storage_path, file_name, content_type, file_size, updated_at"); + m.put("applications", "id, job_id, student_user_id, status, updated_at"); + m.put("jobs", "id, company_id, status, updated_at"); + m.put("companies", "id, owner_user_id, verified, name, updated_at"); + m.put("auth_tokens", "id, user_id, session_id, revoked, consumed_at, revoked_at, version, created_at"); + m.put("audit_logs", "id, user_id, action, entity_type, entity_id, details, created_at"); + m.put("candidate_dimension_scores", + "id, student_user_id, dimension, period, score, status, weight, rules_version, calculated_at, version"); + m.put("notifications", "id, user_id, type, title, is_read, read_at, created_at"); + m.put("messages", "id, conversation_id, sender_user_id, content, status, recalled_at, version, created_at"); + m.put("scoring_risk_events", + "id, rule_config_id, subject_user_id, source_action, severity, occurrence_count, status, detected_at, version"); + m.put("scoring_risk_actions", "id, risk_event_id, operator_user_id, action, reason_code, created_at"); + m.put("talent_invitations", + "id, company_id, job_id, student_user_id, status, expires_at, decided_at, version, created_at"); + return m; + } + + /** + * 行级快照:按选定字段排序后取全部行,做深比较。 + * + *

    不使用 COUNT / SUM(id) / CRC32(id) 这类弱摘要;查询异常直接抛出使测试失败, + * 不允许被吞掉后写入占位值。

    + */ + private java.util.Map>> snapshot() { + java.util.Map>> out = new java.util.LinkedHashMap<>(); + for (java.util.Map.Entry e : digestColumns().entrySet()) { + String columns = e.getValue(); + String sql = "SELECT " + columns + " FROM " + e.getKey() + " ORDER BY " + columns; + out.put(e.getKey(), jdbcTemplate.queryForList(sql)); + } + return out; + } + + @Test + void companyDownloadHasNoSideEffectsOnBusinessTables() { + setVisibility(ResumeVisibility.APPLIED_COMPANIES); + + // 先取 token:测试夹具在签发时会写入 auth_tokens/users,必须发生在基线快照之前, + // 否则夹具自身的记录会被误判为「下载接口的副作用」。 + String token = companyToken(); + + var before = snapshot(); + + assertThat(perform(token)).as("授权通过应 200").isEqualTo(200); + + var after = snapshot(); + for (String table : before.keySet()) { + assertThat(after.get(table)) + .as(table + " 不得因企业下载而变化(逐行内容比较)") + .isEqualTo(before.get(table)); + } + } + + /** + * 反证:摘要必须能发现单行变化,否则前面的「零变化」断言没有说服力。 + */ + @Test + void snapshotDetectsSingleRowChange() { + var before = snapshot(); + + Application app = applicationRepository.findById(applicationId).orElseThrow(); + String original = app.getStatus(); + app.setStatus(original.equals("INTERVIEW") ? "HIRED" : "INTERVIEW"); + applicationRepository.saveAndFlush(app); + + try { + var after = snapshot(); + assertThat(after.get("applications")) + .as("单行 status 变化必须被摘要发现").isNotEqualTo(before.get("applications")); + } finally { + Application restore = applicationRepository.findById(applicationId).orElseThrow(); + restore.setStatus(original); + applicationRepository.saveAndFlush(restore); + } + } + + @Test + void snapshotCoversEveryRequiredTableAndColumn() { + var snap = snapshot(); + for (String table : new String[]{ + "users", "user_roles", "student_profiles", "resume_versions", "applications", + "jobs", "companies", "auth_tokens", "audit_logs", "candidate_dimension_scores", + "notifications", "messages", "scoring_risk_events", "scoring_risk_actions", + "talent_invitations"}) { + assertThat(snap).as("必须覆盖 " + table).containsKey(table); + } + // 抽查关键字段确实被取到(而非空 Map) + assertThat(snap.get("applications").get(0)).containsKeys("status", "updated_at"); + assertThat(snap.get("auth_tokens").get(0)).containsKeys("revoked", "version"); + } + + // ── 工具 ─────────────────────────────────────────────────── + + private int perform(String token) { + try { + return mockMvc.perform(get(url()).header("Authorization", token)) + .andReturn().getResponse().getStatus(); + } catch (Exception e) { + return -1; + } + } + + private int performNoAuth() { + try { + return mockMvc.perform(get(url())).andReturn().getResponse().getStatus(); + } catch (Exception e) { + return -1; + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentAttemptServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentAttemptServiceTest.java index 885a7e4..f9b15c7 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentAttemptServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentAttemptServiceTest.java @@ -7,6 +7,8 @@ import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import org.junit.jupiter.api.BeforeEach; @@ -55,12 +57,18 @@ class AssessmentAttemptServiceTest { @Mock private UserRepository userRepository; @Mock private AuditService auditService; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private AssessmentAttemptService service; private final AuditContext ctx = AuditContext.builder().requestId("req").build(); @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + when(attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(anyLong())).thenReturn(List.of()); when(answerRepository.findByAttemptId(anyLong())).thenReturn(List.of()); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java index 15281ea..d401545 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java @@ -86,8 +86,7 @@ class AssessmentControllerTest { } private String token(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapConcurrencyLoggingTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapConcurrencyLoggingTest.java new file mode 100644 index 0000000..231248f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapConcurrencyLoggingTest.java @@ -0,0 +1,299 @@ +package com.mydreamjob.auth; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.StackTraceElementProxy; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * S03 定向整改:管理员初始化并发冲突**不得**把完整手机号写进任何日志。 + * + *

    并发竞争由数据库唯一约束兜底时,Hibernate 的 {@code SqlExceptionHelper} 会在业务代码 + * 捕获异常**之前**记录整条 SQL 与绑定参数 —— 手机号因此进入日志。本类捕获 + * root / {@code com.mydreamjob} / {@code org.hibernate} / {@code org.springframework.jdbc} + * 四类 logger,并把 {@code throwableProxy} 的完整文本(含 cause 链与堆栈)纳入扫描。

    + */ +@SpringBootTest(properties = { + "app.bootstrap.admin.enabled=true", + "app.bootstrap.admin.phone=13800000666", + "app.bootstrap.admin.nickname=并发引导管理员", +}) +@ActiveProfiles("integration-test") +class AdminBootstrapConcurrencyLoggingTest { + + private static final String BOOTSTRAP_PHONE = "13800000666"; + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private AdminBootstrapRunner runner; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private final List> appenders = new ArrayList<>(); + private final List attachedLoggers = new ArrayList<>(); + private final List deactivatedAdmins = new ArrayList<>(); + + private static final String[] CAPTURED_LOGGERS = { + org.slf4j.Logger.ROOT_LOGGER_NAME, + "com.mydreamjob", + "org.hibernate", + "org.springframework.jdbc", + }; + + @BeforeEach + void setUp() { + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", + BOOTSTRAP_PHONE); + jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); + + // 保证「无活动 ADMIN」前置条件与执行顺序无关 + deactivatedAdmins.clear(); + for (Long id : jdbcTemplate.queryForList( + "SELECT id FROM users WHERE role = 'ADMIN' AND is_active = 1", Long.class)) { + jdbcTemplate.update("UPDATE users SET is_active = 0 WHERE id = ?", id); + deactivatedAdmins.add(id); + } + + for (String name : CAPTURED_LOGGERS) { + Logger logger = (Logger) LoggerFactory.getLogger(name); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + // 刻意**不**下调级别:按应用自身配置(root=WARN、com.mydreamjob=INFO)捕获, + // 扫描结果才等于「生产到底写出了什么」。 + // 框架在 TRACE 下的绑定参数日志不属于生产可见输出,不应计入本断言。 + appenders.add(appender); + attachedLoggers.add(logger); + } + } + + @AfterEach + void tearDown() { + for (int i = 0; i < attachedLoggers.size(); i++) { + attachedLoggers.get(i).detachAppender(appenders.get(i)); + appenders.get(i).stop(); + } + attachedLoggers.clear(); + appenders.clear(); + + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", + BOOTSTRAP_PHONE); + jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); + for (Long id : deactivatedAdmins) { + jdbcTemplate.update("UPDATE users SET is_active = 1 WHERE id = ?", id); + } + deactivatedAdmins.clear(); + } + + // ── 日志采集(含 throwableProxy 全文)──────────────────────── + + /** 把 throwableProxy 的类名、message、堆栈与 cause 链完整渲染为文本。 */ + private static String renderThrowable(IThrowableProxy proxy) { + if (proxy == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + sb.append(proxy.getClassName()).append(": ").append(proxy.getMessage()).append('\n'); + StackTraceElementProxy[] stack = proxy.getStackTraceElementProxyArray(); + if (stack != null) { + for (StackTraceElementProxy s : stack) { + sb.append(" at ").append(s.getStackTraceElement()).append('\n'); + } + } + if (proxy.getCause() != null) { + sb.append("Caused by: ").append(renderThrowable(proxy.getCause())); + } + return sb.toString(); + } + + /** + * 所有被捕获 logger 的完整文本:formattedMessage + 参数 + throwable 全文。 + * + *

    排除 {@code org.springframework.test.*}:测试框架自身会在 DEBUG 记录 + * 上下文缓存键(其中包含本次 {@code @SpringBootTest} 的属性列表,因而含手机号)。 + * 那是测试基础设施的输出,不是产品日志 —— 生产运行不存在该 logger。

    + */ + private String capturedText() { + StringBuilder sb = new StringBuilder(); + for (ListAppender appender : appenders) { + for (ILoggingEvent e : appender.list) { + if (e.getLoggerName() != null && e.getLoggerName().startsWith("org.springframework.test")) { + continue; + } + sb.append(e.getFormattedMessage()).append('\n'); + Object[] args = e.getArgumentArray(); + if (args != null) { + for (Object a : args) { + sb.append(String.valueOf(a)).append('\n'); + } + } + sb.append(renderThrowable(e.getThrowableProxy())); + } + } + return sb.toString(); + } + + /** 扫描器有效性探针:注入一个标记,必须能被同一个扫描函数命中。 */ + @Test + void scannerDetectsInjectedProbe() { + Logger logger = (Logger) LoggerFactory.getLogger("com.mydreamjob"); + String probe = "S03_SCANNER_PROBE_" + BOOTSTRAP_PHONE; + logger.error("probe {}", probe); + assertThat(capturedText()).as("扫描器必须能命中注入的探针,证明不是空转") + .contains(probe); + } + + // ── 并发冲突不得泄漏手机号 ────────────────────────────────── + + private List runConcurrently() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch ready = new CountDownLatch(2); + List> futures = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + futures.add(pool.submit(() -> { + ready.countDown(); + start.await(20, TimeUnit.SECONDS); + try { + runner.run(null); + return "SUCCESS"; + } catch (Throwable t) { + return t.getClass().getName(); + } + })); + } + assertThat(ready.await(20, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + List results = new ArrayList<>(); + for (Future f : futures) { + results.add(f.get(60, TimeUnit.SECONDS)); + } + pool.shutdownNow(); + return results; + } + + @Test + void adminBootstrapConcurrentConflictNeverLogsConfiguredPhone() throws Exception { + runConcurrently(); + + String log = capturedText(); + assertThat(log).as("完整管理员手机号不得出现在任何 logger(含 throwableProxy)") + .doesNotContain(BOOTSTRAP_PHONE); + assertThat(log).as("不得出现 SQL 绑定参数回显").doesNotContain("binding parameter"); + assertThat(log).as("不得出现 User.toString()").doesNotContain("User(id="); + assertThat(log).as("不得出现 password/token/secret 值") + .doesNotContainIgnoringCase("password=") + .doesNotContainIgnoringCase("secret="); + } + + @Test + void concurrentBootstrapNeverLogsPhoneAcrossAllLoggers() throws Exception { + List results = runConcurrently(); + + String log = capturedText(); + assertThat(log).doesNotContain(BOOTSTRAP_PHONE); + + // 冲突方不得把原始 Hibernate/JDBC 异常带出来 + assertThat(results).as("并发结果不得包含原始约束异常类型") + .noneMatch(r -> r.contains("DataIntegrityViolationException") + || r.contains("ConstraintViolationException") + || r.contains("SQLException")); + } + + @Test + void concurrentBootstrapProducesOneValidAdmin() throws Exception { + runConcurrently(); + + assertThat(countByPhone()).as("并发后同手机号用户恰好一行").isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id " + + "WHERE u.phone = ? AND ur.role = 'ADMIN'", Integer.class, BOOTSTRAP_PHONE)) + .as("user_roles 中 ADMIN 恰好一行").isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id " + + "WHERE u.phone = ? AND ur.role IN ('STUDENT','COMPANY')", Integer.class, BOOTSTRAP_PHONE)) + .as("不得出现 STUDENT/COMPANY 附加角色").isZero(); + + List> row = jdbcTemplate.queryForList( + "SELECT is_active, is_verified, password_hash FROM users WHERE phone = ?", BOOTSTRAP_PHONE); + assertThat(row).hasSize(1); + assertThat(((Number) row.get(0).get("is_active")).intValue()).isEqualTo(1); + assertThat(((Number) row.get(0).get("is_verified")).intValue()).isEqualTo(1); + assertThat(row.get(0).get("password_hash")).as("不得设置默认密码").isNull(); + } + + @Test + void repeatedBootstrapRemainsIdempotent() { + runner.run(null); + runner.run(null); + runner.run(null); + + assertThat(countByPhone()).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE role='ADMIN' AND is_active=1", Integer.class)) + .isEqualTo(1); + } + + @Test + void existingStudentWithSamePhoneStillFailsWithoutPrivilegeEscalation() { + jdbcTemplate.update( + "INSERT INTO users (phone, nickname, password_hash, role, is_active, is_verified) " + + "VALUES (?, '普通学生', NULL, 'STUDENT', 1, 1)", BOOTSTRAP_PHONE); + + try { + assertThat(org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> runner.run(null)).getMessage()) + .as("冲突文案必须固定且不含手机号") + .doesNotContain(BOOTSTRAP_PHONE); + } finally { + jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); + } + + assertThat(capturedText()).as("冲突路径也不得把手机号写进日志") + .doesNotContain(BOOTSTRAP_PHONE); + } + + @Test + void conflictExceptionHasNoSensitiveCause() { + jdbcTemplate.update( + "INSERT INTO users (phone, nickname, password_hash, role, is_active, is_verified) " + + "VALUES (?, '占位用户', NULL, 'STUDENT', 1, 1)", BOOTSTRAP_PHONE); + + try { + RuntimeException ex = org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> runner.run(null)); + assertThat(ex.getMessage()).doesNotContain(BOOTSTRAP_PHONE); + assertThat(ex.getCause()).as("不得携带原始数据库异常作为 cause").isNull(); + assertThat(ex.getClass().getName()).doesNotContain("DataIntegrityViolation"); + } finally { + jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); + } + } + + private int countByPhone() { + Integer n = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, BOOTSTRAP_PHONE); + return n == null ? 0 : n; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapDisabledByDefaultTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapDisabledByDefaultTest.java new file mode 100644 index 0000000..b1a0356 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapDisabledByDefaultTest.java @@ -0,0 +1,41 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.config.AdminBootstrapProperties; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationContext; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * S03-A:不显式开启时,初始化器**不得**被装配,普通启动不会隐式创建管理员。 + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class AdminBootstrapDisabledByDefaultTest { + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private ApplicationContext context; + + @Autowired + private AdminBootstrapProperties properties; + + @Test + void runnerIsNotAssembledByDefault() { + assertThat(context.getBeanNamesForType(AdminBootstrapRunner.class)) + .as("默认(开关未开启)不得装配管理员初始化器").isEmpty(); + } + + @Test + void defaultPropertiesAreDisabledAndHaveNoPhone() { + assertThat(properties.getEnabled()).isEqualTo("false"); + assertThat(properties.getPhone()).isEmpty(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapIntegrationTest.java new file mode 100644 index 0000000..d4934ef --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapIntegrationTest.java @@ -0,0 +1,235 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.config.AdminBootstrapProperties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationContext; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.concurrent.*; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * S03-A 管理员初始化的真实上下文验证:条件装配、真实 H2 建号、 + * {@code users.role} 与 {@code user_roles} 一致、幂等、并发冲突。 + */ +@SpringBootTest(properties = { + "app.bootstrap.admin.enabled=true", + "app.bootstrap.admin.phone=13800000777", + "app.bootstrap.admin.nickname=引导管理员", +}) +@ActiveProfiles("integration-test") +class AdminBootstrapIntegrationTest { + + private static final String BOOTSTRAP_PHONE = "13800000777"; + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private ApplicationContext context; + + @Autowired + private AdminBootstrapRunner runner; + + @Autowired + private AdminBootstrapProperties properties; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private ch.qos.logback.core.read.ListAppender appender; + private ch.qos.logback.classic.Logger appLogger; + /** 用例开始前被临时停用、结束后需恢复的其他 ADMIN id。 */ + private final java.util.List deactivatedAdmins = new java.util.ArrayList<>(); + + @BeforeEach + void clearBootstrapArtifacts() { + // 清掉本测试可能留下的引导账号,使「无活动 ADMIN」前置条件成立 + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", + BOOTSTRAP_PHONE); + jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); + + // 其他测试类可能留下活动 ADMIN,破坏「无活动 ADMIN」前置条件。这里临时停用它们, + // 用例结束恢复,使本类的断言不依赖测试执行顺序。 + deactivatedAdmins.clear(); + for (Long id : jdbcTemplate.queryForList( + "SELECT id FROM users WHERE role = 'ADMIN' AND is_active = 1", Long.class)) { + jdbcTemplate.update("UPDATE users SET is_active = 0 WHERE id = ?", id); + deactivatedAdmins.add(id); + } + + appLogger = (ch.qos.logback.classic.Logger) + org.slf4j.LoggerFactory.getLogger("com.mydreamjob"); + appender = new ch.qos.logback.core.read.ListAppender<>(); + appender.start(); + appLogger.addAppender(appender); + appLogger.setLevel(ch.qos.logback.classic.Level.TRACE); + } + + @AfterEach + void tearDown() { + if (appLogger != null && appender != null) { + appLogger.detachAppender(appender); + appender.stop(); + } + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", + BOOTSTRAP_PHONE); + jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); + + // 恢复本用例开始前临时停用的其他 ADMIN + for (Long id : deactivatedAdmins) { + jdbcTemplate.update("UPDATE users SET is_active = 1 WHERE id = ?", id); + } + deactivatedAdmins.clear(); + } + + private long activeAdmins() { + return userRepository.countByRoleAndActiveTrue(User.Role.ADMIN); + } + + @Test + void runnerIsAssembledWhenPropertyIsTrue() { + assertThat(runner).as("开关为 true 时应装配初始化器").isNotNull(); + assertThat(properties.getNickname()).isEqualTo("引导管理员"); + } + + @Test + void createsAdminConsistentAcrossUsersAndUserRoles() { + runner.run(null); + + List> rows = jdbcTemplate.queryForList( + "SELECT id, role, is_active, is_verified FROM users WHERE phone = ?", BOOTSTRAP_PHONE); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).get("role")).isEqualTo("ADMIN"); + assertThat(((Number) rows.get(0).get("is_active")).intValue()).isEqualTo(1); + assertThat(((Number) rows.get(0).get("is_verified")).intValue()).isEqualTo(1); + + Long id = ((Number) rows.get(0).get("id")).longValue(); + List roles = jdbcTemplate.queryForList( + "SELECT role FROM user_roles WHERE user_id = ?", String.class, id); + assertThat(roles).as("user_roles 必须与 users.role 一致").containsExactly("ADMIN"); + + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, BOOTSTRAP_PHONE)).isEqualTo(1); + } + + @Test + void repeatedRunIsIdempotent() { + runner.run(null); + long afterFirst = activeAdmins(); + + runner.run(null); + runner.run(null); + + assertThat(activeAdmins()).as("重复启动不得创建第二个管理员").isEqualTo(afterFirst); + assertThat(countByPhone()).isEqualTo(1); + } + + /** + * 并发初始化的部署契约(**方案 A**):一个线程创建成功,另一个重新查询后判定现存账号 + * 正是本次配置要求的合法 ADMIN,返回**幂等成功** —— 两个调用均不泄漏异常,且最终只有 + * 一条 users 与一条 ADMIN user_roles。不使用任何 JVM 锁。 + */ + @Test + void concurrentBootstrapCannotCreateTwoAdmins() throws Exception { + // 前置条件:不得已有活动 ADMIN,否则初始化器会按设计跳过,本用例失去意义。 + assertThat(activeAdmins()).as("并发用例前置条件:不得已有活动 ADMIN").isZero(); + + int threads = 2; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch finished = new CountDownLatch(threads); + + List> outcomes = new java.util.ArrayList<>(); + for (int i = 0; i < threads; i++) { + outcomes.add(pool.submit(() -> { + ready.countDown(); + start.await(20, TimeUnit.SECONDS); // 屏障:两线程同时开始 + try { + runner.run(null); + return "SUCCESS"; // 方案 A:创建成功或幂等成功 + } catch (Throwable t) { + return "UNKNOWN:" + t.getClass().getName(); + } finally { + finished.countDown(); + } + })); + } + + assertThat(ready.await(20, TimeUnit.SECONDS)).as("两线程都已到达屏障").isTrue(); + start.countDown(); + + List results = new java.util.ArrayList<>(); + for (Future f : outcomes) { + results.add(f.get(60, TimeUnit.SECONDS)); + } + pool.shutdownNow(); + assertThat(finished.await(5, TimeUnit.SECONDS)).as("两线程都已结束").isTrue(); + + long success = results.stream().filter("SUCCESS"::equals).count(); + long unknown = results.stream().filter(r -> r.startsWith("UNKNOWN")).count(); + + // 方案 A:一个创建、一个幂等成功 —— 两个调用都必须成功,且都不得抛出异常。 + assertThat(success).as("两个调用都应成功(实测 " + results + ")").isEqualTo(2); + assertThat(unknown).as("未知异常必须为 0(实测 " + results + ")").isZero(); + + // 最终数据状态 + assertThat(countByPhone()).as("同手机号用户恰好 1 个").isEqualTo(1); + assertThat(activeAdmins()).as("活动 ADMIN 恰好 1 个").isEqualTo(1); + assertThat(adminRoleRows()).as("user_roles 中 ADMIN 恰好 1 行").isEqualTo(1); + assertThat(templateHash()).as("不得设置默认密码").isNull(); + assertThat(otherRoleRows()).as("不得出现 STUDENT/COMPANY 角色").isZero(); + + // 异常与日志不得泄漏手机号、SQL 参数或用户对象 + String log = logAppenderText(); + assertThat(log).as("日志不得包含手机号").doesNotContain(BOOTSTRAP_PHONE); + assertThat(log).as("日志不得包含 SQL 参数占位").doesNotContain("bind"); + assertThat(log).as("日志不得包含用户对象").doesNotContain("User(id="); + } + + private int adminRoleRows() { + Integer n = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id " + + "WHERE u.phone = ? AND ur.role = 'ADMIN'", Integer.class, BOOTSTRAP_PHONE); + return n == null ? 0 : n; + } + + private int otherRoleRows() { + Integer n = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id " + + "WHERE u.phone = ? AND ur.role IN ('STUDENT','COMPANY')", + Integer.class, BOOTSTRAP_PHONE); + return n == null ? 0 : n; + } + + private String templateHash() { + return jdbcTemplate.queryForObject( + "SELECT password_hash FROM users WHERE phone = ?", String.class, BOOTSTRAP_PHONE); + } + + private String logAppenderText() { + return appender == null ? "" + : appender.list.stream() + .map(ch.qos.logback.classic.spi.ILoggingEvent::getFormattedMessage) + .collect(java.util.stream.Collectors.joining("\n")); + } + + private int countByPhone() { + Integer n = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, BOOTSTRAP_PHONE); + return n == null ? 0 : n; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapRunnerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapRunnerTest.java new file mode 100644 index 0000000..63bda52 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapRunnerTest.java @@ -0,0 +1,262 @@ +package com.mydreamjob.auth; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.mydreamjob.config.AdminBootstrapProperties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * S03-A 管理员初始化的规则分支(设计 §3.2)。 + * + *

    真实 {@link AdminBootstrapRunner} + Mock 仓储,逐条覆盖任务书 §四 要求的场景。 + * 日志脱敏用真实 Logback 捕获验证。

    + */ +@ExtendWith(MockitoExtension.class) +class AdminBootstrapRunnerTest { + + private static final String PHONE = "13800000009"; + private static final String OTHER_PHONE = "13800000008"; + + @Mock private UserRepository userRepository; + /** S03 整改后创建走独立事务组件(JdbcTemplate),单测以 mock 替代。 */ + @Mock private AdminBootstrapCreator creator; + + private AdminBootstrapProperties properties; + private AdminBootstrapRunner runner; + + private ListAppender appender; + private Logger appLogger; + + @BeforeEach + void setUp() { + properties = new AdminBootstrapProperties(); + runner = new AdminBootstrapRunner(properties, userRepository, creator); + + appLogger = (Logger) LoggerFactory.getLogger("com.mydreamjob"); + appender = new ListAppender<>(); + appender.start(); + appLogger.addAppender(appender); + appLogger.setLevel(Level.TRACE); + } + + @AfterEach + void tearDown() { + if (appLogger != null && appender != null) { + appLogger.detachAppender(appender); + appender.stop(); + } + } + + private String capturedLog() { + return appender.list.stream().map(ILoggingEvent::getFormattedMessage).collect(Collectors.joining("\n")); + } + + private void enableWith(String phone) { + properties.setEnabled("true"); + properties.setPhone(phone); + } + + // ── 关闭 / 未启用 ────────────────────────────────────────── + + @Test + void disabledDoesNothing() { + properties.setEnabled("false"); + properties.setPhone(PHONE); + + runner.run(null); + + verifyNoInteractions(userRepository); + verifyNoInteractions(creator); + } + + @Test + void nonLiteralTrueValueDoesNotEnable() { + // 只有字面量 "true" 才算启用;大小写变体一律不生效。 + for (String value : new String[]{"TRUE", "True", "yes", "1", "on", " true "}) { + properties.setEnabled(value); + properties.setPhone(PHONE); + runner.run(null); + } + verifyNoInteractions(userRepository); + verifyNoInteractions(creator); + } + + // ── 手机号校验 ───────────────────────────────────────────── + + @Test + void missingPhoneFailsAtStartup() { + properties.setEnabled("true"); + properties.setPhone(""); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); + assertEquals(AdminBootstrapRunner.ERROR_INVALID_PHONE, ex.getMessage()); + verifyNoInteractions(userRepository); + verifyNoInteractions(creator); + } + + @Test + void invalidPhoneFailsAtStartup() { + for (String bad : new String[]{"123", "1380000000", "138000000012", "1380000000a", " 1380000000a "}) { + properties.setEnabled("true"); + properties.setPhone(bad); + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); + assertEquals(AdminBootstrapRunner.ERROR_INVALID_PHONE, ex.getMessage()); + } + verifyNoInteractions(userRepository); + verifyNoInteractions(creator); + } + + // ── 已有活动 ADMIN ───────────────────────────────────────── + + @Test + void existingActiveAdminIsSkippedWithoutSideEffects() { + enableWith(PHONE); + when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(1L); + + runner.run(null); + + verify(creator, never()).createAdmin(any(), any()); + assertTrue(capturedLog().contains("admin bootstrap skipped"), "应输出固定跳过文本"); + } + + // ── 手机号已被占用 ───────────────────────────────────────── + + @Test + void phoneBelongingToStudentFails() { + enableWith(PHONE); + when(creator.findByPhone(PHONE)).thenReturn(Optional.of( + new AdminBootstrapCreator.BootstrapUser(9L, "STUDENT", true, true, true, List.of("STUDENT")))); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); + assertEquals(AdminBootstrapRunner.ERROR_PHONE_IN_USE, ex.getMessage()); + verify(creator, never()).createAdmin(any(), any()); + } + + @Test + void phoneBelongingToCompanyFails() { + enableWith(PHONE); + when(creator.findByPhone(PHONE)).thenReturn(Optional.of( + new AdminBootstrapCreator.BootstrapUser(9L, "COMPANY", true, true, true, List.of("COMPANY")))); + + assertThrows(IllegalStateException.class, () -> runner.run(null)); + verify(creator, never()).createAdmin(any(), any()); + } + + @Test + void phoneBelongingToInactiveUserFailsAndIsNotRevived() { + enableWith(PHONE); + when(creator.findByPhone(PHONE)).thenReturn(Optional.of( + new AdminBootstrapCreator.BootstrapUser(9L, "ADMIN", false, true, true, List.of("ADMIN")))); + + assertThrows(IllegalStateException.class, () -> runner.run(null)); + verify(creator, never()).createAdmin(any(), any()); + } + + @Test + void activeAdminMissingAdminRoleCannotBypassStrictIdempotencyCheck() { + assertInvalidExistingAdminRejected( + new AdminBootstrapCreator.BootstrapUser(9L, "ADMIN", true, true, true, List.of())); + } + + @Test + void activeAdminWithAdditionalRoleCannotBypassStrictIdempotencyCheck() { + assertInvalidExistingAdminRejected( + new AdminBootstrapCreator.BootstrapUser( + 9L, "ADMIN", true, true, true, List.of("ADMIN", "STUDENT"))); + } + + @Test + void activeAdminWithPasswordCannotBypassStrictIdempotencyCheck() { + assertInvalidExistingAdminRejected( + new AdminBootstrapCreator.BootstrapUser(9L, "ADMIN", true, true, false, List.of("ADMIN"))); + } + + // ── 正常创建 ─────────────────────────────────────────────── + + @Test + void createsActiveVerifiedAdminWithConsistentRole() { + enableWith(PHONE); + when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L); + when(creator.findByPhone(PHONE)).thenReturn(Optional.empty()); + when(creator.createAdmin(any(), any())).thenReturn(AdminBootstrapCreator.CreateResult.CREATED); + + runner.run(null); + + // 创建由独立事务组件完成(JdbcTemplate),单测只验证编排参数; + // 「users.role / user_roles 一致、active、verified、password_hash 为空」由 + // AdminBootstrapIntegrationTest 与 AdminBootstrapConcurrencyLoggingTest 在真实库上断言。 + verify(creator).createAdmin(eq(PHONE), any()); + verify(userRepository, never()).save(any()); + } + + @Test + void customNicknameIsApplied() { + enableWith(PHONE); + properties.setNickname("运维管理员"); + when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L); + when(creator.findByPhone(PHONE)).thenReturn(Optional.empty()); + when(creator.createAdmin(any(), any())).thenReturn(AdminBootstrapCreator.CreateResult.CREATED); + + runner.run(null); + + verify(creator).createAdmin(PHONE, "运维管理员"); + } + + // ── 日志脱敏 ─────────────────────────────────────────────── + + @Test + void logNeverContainsPhone() { + enableWith(PHONE); + when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L); + when(creator.findByPhone(OTHER_PHONE)).thenReturn(Optional.empty()); + when(creator.createAdmin(any(), any())).thenReturn(AdminBootstrapCreator.CreateResult.CREATED); + properties.setPhone(OTHER_PHONE); + + runner.run(null); + + String log = capturedLog(); + assertThatNotContains(log, OTHER_PHONE); + assertThatNotContains(log, PHONE); + } + + @Test + void failureMessagesNeverContainPhone() { + enableWith(PHONE); + when(creator.findByPhone(PHONE)).thenReturn(Optional.of( + new AdminBootstrapCreator.BootstrapUser(9L, "STUDENT", true, true, true, List.of("STUDENT")))); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); + assertThatNotContains(ex.getMessage(), PHONE); + } + + private static void assertThatNotContains(String haystack, String needle) { + assertFalse(haystack != null && haystack.contains(needle), + "输出不得包含手机号"); + } + + private void assertInvalidExistingAdminRejected(AdminBootstrapCreator.BootstrapUser existing) { + enableWith(PHONE); + when(creator.findByPhone(PHONE)).thenReturn(Optional.of(existing)); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); + + assertEquals(AdminBootstrapRunner.ERROR_PHONE_IN_USE, ex.getMessage()); + verify(creator, never()).createAdmin(any(), any()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapTransactionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapTransactionTest.java new file mode 100644 index 0000000..5361a1d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapTransactionTest.java @@ -0,0 +1,112 @@ +package com.mydreamjob.auth; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; + +/** + * S03 定向整改:{@code users} 与 {@code user_roles} 必须**同事务**提交 —— + * 角色写入失败时用户创建整体回滚,不得留下「有用户、无角色」的半个管理员。 + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class AdminBootstrapTransactionTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(8600); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private DataSource dataSource; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private String phone; + + @BeforeEach + void setUp() { + phone = "13" + String.format("%09d", PHONE_SEQ.incrementAndGet()); + } + + @AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", phone); + jdbcTemplate.update("DELETE FROM users WHERE phone = ?", phone); + } + + private int userRows() { + Integer n = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, phone); + return n == null ? 0 : n; + } + + private int roleRows() { + Integer n = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id WHERE u.phone = ?", + Integer.class, phone); + return n == null ? 0 : n; + } + + @Test + void successfulCreateWritesUserAndRoleInSameTransaction() { + AdminBootstrapCreator creator = new AdminBootstrapCreator(jdbcTemplate, transactionManager); + + assertThat(creator.createAdmin(phone, "事务管理员")) + .isEqualTo(AdminBootstrapCreator.CreateResult.CREATED); + assertThat(userRows()).as("用户恰好一行").isEqualTo(1); + assertThat(roleRows()).as("user_roles 恰好一行").isEqualTo(1); + } + + @Test + void roleInsertFailureRollsBackUser() { + JdbcTemplate failing = spy(new JdbcTemplate(dataSource)); + // 让 user_roles 的写入失败,users 的写入成功 —— 验证两者同事务。 + doThrow(new DataAccessResourceFailureException("simulated role insert failure")) + .when(failing).update(startsWith("INSERT INTO user_roles"), any(Object[].class)); + + AdminBootstrapCreator creator = new AdminBootstrapCreator(failing, transactionManager); + + assertThat(creator.createAdmin(phone, "回滚管理员")) + .as("角色写入失败必须返回 CONFLICT,而不是抛出携带原始信息的异常") + .isEqualTo(AdminBootstrapCreator.CreateResult.CONFLICT); + + assertThat(userRows()).as("users 必须随角色失败整体回滚").isZero(); + assertThat(roleRows()).isZero(); + } + + @Test + void concurrentCreateLeavesExactlyOneUserAndOneRole() { + AdminBootstrapCreator creator = new AdminBootstrapCreator(jdbcTemplate, transactionManager); + + AdminBootstrapCreator.CreateResult first = creator.createAdmin(phone, "并发甲"); + AdminBootstrapCreator.CreateResult second = creator.createAdmin(phone, "并发乙"); + + assertThat(List.of(first, second)) + .as("第二次同号创建必须被识别为冲突").contains(AdminBootstrapCreator.CreateResult.CONFLICT); + assertThat(userRows()).as("并发后用户恰好一行").isEqualTo(1); + assertThat(roleRows()).as("并发后 ADMIN 角色恰好一行").isEqualTo(1); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdvancingClock.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdvancingClock.java new file mode 100644 index 0000000..0e3b3e1 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdvancingClock.java @@ -0,0 +1,55 @@ +package com.mydreamjob.auth; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 测试专用时钟:第一次 {@link #instant()} 返回 T,之后返回一个**明显不同**的时间(T + 偏移)。 + * + *

    用途:证明认证入口只读取一次 Clock。若某个入口读了两次,第二次取到的是 T+偏移, + * 于是 JWT {@code iat}/{@code exp}、数据库 {@code created_at}/{@code expired_at} + * 或审计时间就会出现两个不同的值,测试即可捕获。

    + * + *

    固定使用项目业务时区 {@code Asia/Shanghai},不依赖系统默认时区。

    + */ +class AdvancingClock extends Clock { + + static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + + private final Instant first; + private final Instant subsequent; + private final AtomicInteger calls = new AtomicInteger(); + + AdvancingClock(Instant first, Instant subsequent) { + this.first = first; + this.subsequent = subsequent; + } + + @Override + public ZoneId getZone() { + return ZONE; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + int call = calls.incrementAndGet(); + return call == 1 ? first : subsequent; + } + + /** 已读取次数。 */ + int callCount() { + return calls.get(); + } + + /** 每个入口开始前重置,便于分别断言。 */ + void reset() { + calls.set(0); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshConcurrencyIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshConcurrencyIntegrationTest.java new file mode 100644 index 0000000..eb462e3 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshConcurrencyIntegrationTest.java @@ -0,0 +1,319 @@ +package com.mydreamjob.auth; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import com.mydreamjob.auth.AuthSessionService.SessionTokens; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * S02 并发刷新与登出竞态(设计 D17,任务书 §十八)。 + * + *

    真实 Service + Repository + 事务 + EntityManager,两个线程用 CountDownLatch + * 同时开始,不 Mock 条件更新。

    + * + *

    独立复验整改后:重放被识别时要求该会话**活动 Token 数为 0**(而不是 ≤1), + * 且并发胜出方刚签发的 Access Token 也必须失效。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AuthRefreshConcurrencyIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9600); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private AuthTokenRepository authTokenRepository; + + private Long userId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + userId = userRepository.save(User.builder() + .phone("16" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("S02 并发") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + } + + @AfterEach + void tearDown() { + if (userId != null) { + userRepository.deleteById(userId); + } + } + + private AuthSessionService.SessionTokens newSession() { + return authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + } + + /** 该会话下仍满足"活动"条件的 Refresh 记录数。 */ + private long activeCount(String sessionId) { + return authTokenRepository.countActiveSessionTokens(userId, sessionId, AuthSessionService.toLocal(Instant.now())); + } + + private record Attempt(boolean success, SessionTokens tokens, Throwable error) { + } + + private record AttemptOutcome(List attempts) { + long successes() { + return attempts.stream().filter(Attempt::success).count(); + } + + List failures() { + return attempts.stream().filter(a -> !a.success()).map(Attempt::error).toList(); + } + } + + private AttemptOutcome refreshConcurrently(String refreshToken) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch ready = new CountDownLatch(2); + List> futures = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + futures.add(pool.submit(() -> { + ready.countDown(); + start.await(10, TimeUnit.SECONDS); + try { + return new Attempt(true, authSessionService.refresh(refreshToken, Instant.now(), null), null); + } catch (Throwable t) { + return new Attempt(false, null, t); + } + })); + } + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + List results = new ArrayList<>(); + for (Future f : futures) { + results.add(f.get(30, TimeUnit.SECONDS)); + } + pool.shutdownNow(); + return new AttemptOutcome(results); + } + + private void assertOnlyBusinessFailure(AttemptOutcome outcome) { + outcome.failures().forEach(t -> assertThat(t) + .as("并发失败方只能是既定的业务拒绝,不得泄漏锁异常或 500") + .isInstanceOf(InvalidTokenException.class)); + } + + // ── 并发刷新:恰好一个成功 ─────────────────────────────────── + + @Test + void concurrentRefreshOnlyOneRequestSucceeds() throws Exception { + AuthSessionService.SessionTokens session = newSession(); + + AttemptOutcome outcome = refreshConcurrently(session.refreshToken()); + + assertThat(outcome.successes()).as("同一 Refresh Token 最多一个刷新成功").isEqualTo(1); + assertOnlyBusinessFailure(outcome); + } + + // ── 重放处置:整个会话必须被撤销 ───────────────────────────── + + @Test + void concurrentRefreshReplayRevokesEntireSession() throws Exception { + AuthSessionService.SessionTokens session = newSession(); + + AttemptOutcome outcome = refreshConcurrently(session.refreshToken()); + assertThat(outcome.successes()).isEqualTo(1); + assertOnlyBusinessFailure(outcome); + + assertThat(activeCount(session.sessionId())) + .as("重放处置后该会话不得残留任何活动 Refresh Token") + .isZero(); + assertThat(authSessionService.isSessionActive(userId, session.sessionId(), Instant.now())) + .as("该会话必须处于失效状态") + .isFalse(); + } + + @Test + void concurrentRefreshLeavesAtMostOneActiveSuccessor() throws Exception { + AuthSessionService.SessionTokens session = newSession(); + + AttemptOutcome outcome = refreshConcurrently(session.refreshToken()); + assertOnlyBusinessFailure(outcome); + + List active = authTokenRepository + .findByUserIdAndSessionIdOrderByIdAsc(userId, session.sessionId()).stream() + .filter(t -> !Boolean.TRUE.equals(t.getRevoked())) + .filter(t -> t.getConsumedAt() == null) + .toList(); + assertThat(active).as("不得留下两个活动后继 Token(重放后应为 0)").isEmpty(); + } + + @Test + void concurrentRefreshInvalidatesWinningAccessTokenAfterReplay() throws Exception { + AuthSessionService.SessionTokens session = newSession(); + + AttemptOutcome outcome = refreshConcurrently(session.refreshToken()); + assertOnlyBusinessFailure(outcome); + + // 并发胜出方刚签发的 Access Token 也必须失效 + String winningAccess = outcome.attempts().stream() + .filter(Attempt::success).map(a -> a.tokens().accessToken()).findFirst().orElseThrow(); + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + winningAccess)) + .andExpect(status().isUnauthorized()); + + // 原 Access Token 同样失效 + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + session.accessToken())) + .andExpect(status().isUnauthorized()); + } + + @Test + void concurrentReplayDoesNotRevokeOtherSession() throws Exception { + AuthSessionService.SessionTokens victim = newSession(); + AuthSessionService.SessionTokens other = newSession(); + + AttemptOutcome outcome = refreshConcurrently(victim.refreshToken()); + assertOnlyBusinessFailure(outcome); + assertThat(activeCount(victim.sessionId())).isZero(); + + assertThat(authSessionService.isSessionActive(userId, other.sessionId(), Instant.now())) + .as("其他设备会话不受影响").isTrue(); + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + other.accessToken())) + .andExpect(status().isOk()); + } + + @Test + void concurrentRefreshDoesNotLeakLockExceptionAs500() throws Exception { + AuthSessionService.SessionTokens session = newSession(); + + AttemptOutcome outcome = refreshConcurrently(session.refreshToken()); + + // 失败方不得出现锁异常、乐观锁异常或任何非业务异常 + outcome.failures().forEach(t -> assertThat(t) + .as("不得出现未处理的锁异常或 500 级异常") + .isNotInstanceOf(org.springframework.dao.DataAccessException.class) + .isInstanceOf(InvalidTokenException.class)); + } + + // ── 重放(串行)与其他会话 ─────────────────────────────────── + + @Test + void refreshReplayRevokesOnlyCurrentSession() throws Exception { + AuthSessionService.SessionTokens victim = newSession(); + AuthSessionService.SessionTokens other = newSession(); + assertThat(victim.sessionId()).isNotEqualTo(other.sessionId()); + + authSessionService.refresh(victim.refreshToken(), Instant.now(), null); + try { + authSessionService.refresh(victim.refreshToken(), Instant.now(), null); + } catch (InvalidTokenException expected) { + // 预期 + } + + assertThat(activeCount(victim.sessionId())).isZero(); + assertThat(activeCount(other.sessionId())).isPositive(); + } + + @Test + void differentSessionsRefreshIndependently() throws Exception { + AuthSessionService.SessionTokens a = newSession(); + AuthSessionService.SessionTokens b = newSession(); + + authSessionService.refresh(a.refreshToken(), Instant.now(), null); + authSessionService.refresh(b.refreshToken(), Instant.now(), null); + + assertThat(authSessionService.isSessionActive(userId, a.sessionId(), Instant.now())).isTrue(); + assertThat(authSessionService.isSessionActive(userId, b.sessionId(), Instant.now())).isTrue(); + } + + // ── 登出与刷新竞态 ────────────────────────────────────────── + + @Test + void logoutRacingWithRefreshLeavesNoActiveSuccessorWithoutCleanupLogout() throws Exception { + AuthSessionService.SessionTokens session = newSession(); + + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch ready = new CountDownLatch(2); + + Future refresher = pool.submit(() -> { + ready.countDown(); + try { + start.await(10, TimeUnit.SECONDS); + authSessionService.refresh(session.refreshToken(), Instant.now(), null); + return null; + } catch (InvalidTokenException expected) { + // 与登出竞争时唯一的预期业务异常 + return expected; + } catch (Throwable unexpected) { + return unexpected; + } + }); + Future logger = pool.submit(() -> { + ready.countDown(); + try { + start.await(10, TimeUnit.SECONDS); + authSessionService.logout(userId, session.sessionId(), Instant.now(), null); + return null; + } catch (Throwable unexpected) { + return unexpected; + } + }); + + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + Throwable refreshError = refresher.get(30, TimeUnit.SECONDS); + Throwable logoutError = logger.get(30, TimeUnit.SECONDS); + pool.shutdownNow(); + + // 只允许既定的业务异常;登出本身不应失败 + assertThat(logoutError).as("登出不应抛异常").isNull(); + if (refreshError != null) { + assertThat(refreshError) + .as("刷新失败时只能是 InvalidTokenException") + .isInstanceOf(InvalidTokenException.class); + } + + // 线程结束后直接检查最终状态:不得再补一次 logout(那会掩盖竞争缺陷) + assertThat(activeCount(session.sessionId())) + .as("登出与刷新竞争后不得残留活动 Token").isZero(); + assertThat(authSessionService.isSessionActive(userId, session.sessionId(), Instant.now())).isFalse(); + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + session.accessToken())) + .andExpect(status().isUnauthorized()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshControllerTest.java new file mode 100644 index 0000000..2af6f9f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshControllerTest.java @@ -0,0 +1,138 @@ +package com.mydreamjob.auth; + +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * S02 刷新接口的入参校验(设计 9.2,任务书 §十四)。 + * + *

    请求体只允许 {@code refreshToken},且必须有合理的必填与长度约束。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AuthRefreshControllerTest { + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private MockMvc mockMvc; + + private static String body(String json) { + return json; + } + + @Test + void missingRefreshTokenIsRejected() throws Exception { + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(body("{}"))) + .andExpect(status().is4xxClientError()); + } + + @Test + void blankRefreshTokenIsRejected() throws Exception { + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(body("{\"refreshToken\":\" \"}"))) + .andExpect(status().is4xxClientError()); + } + + @Test + void oversizedRefreshTokenIsRejected() throws Exception { + String huge = "x".repeat(5000); + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(body("{\"refreshToken\":\"" + huge + "\"}"))) + .andExpect(status().is4xxClientError()); + } + + @Test + void refreshEndpointIsReachableWithoutAuthentication() throws Exception { + // permitAll 表示可进入 Controller;是否成功仍取决于 Token 校验。 + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(body("{\"refreshToken\":\"not-a-valid-token\"}"))) + .andExpect(status().isUnauthorized()); + } + + @Test + void logoutRequiresAuthentication() throws Exception { + mockMvc.perform(post("/auth/logout")) + .andExpect(status().isUnauthorized()); + } + + // ── OpenAPI 契约(S02 新增两个 operation)──────────────────── + + @Test + void openApiExposesExactlyTheTwoNewAuthOperations() throws Exception { + String json = mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + com.fasterxml.jackson.databind.JsonNode root = new com.fasterxml.jackson.databind.ObjectMapper().readTree(json); + com.fasterxml.jackson.databind.JsonNode paths = root.path("paths"); + com.fasterxml.jackson.databind.JsonNode components = root.path("components").path("schemas"); + + org.assertj.core.api.Assertions.assertThat(paths.has("/auth/refresh")).isTrue(); + org.assertj.core.api.Assertions.assertThat(paths.has("/auth/logout")).isTrue(); + + com.fasterxml.jackson.databind.JsonNode refresh = paths.path("/auth/refresh").path("post"); + com.fasterxml.jackson.databind.JsonNode logout = paths.path("/auth/logout").path("post"); + org.assertj.core.api.Assertions.assertThat(refresh.isMissingNode()).isFalse(); + org.assertj.core.api.Assertions.assertThat(logout.isMissingNode()).isFalse(); + + // 刷新请求体只允许 refreshToken + com.fasterxml.jackson.databind.JsonNode bodySchema = deref( + refresh.path("requestBody").path("content").path("application/json").path("schema"), components); + java.util.List properties = new java.util.ArrayList<>(); + bodySchema.path("properties").fieldNames().forEachRemaining(properties::add); + org.assertj.core.api.Assertions.assertThat(properties) + .as("刷新请求体只允许 refreshToken").containsExactly("refreshToken"); + + // 登出无请求体 + org.assertj.core.api.Assertions.assertThat(logout.has("requestBody")) + .as("登出不得声明 requestBody").isFalse(); + + // 刷新响应不得泄漏数据库字段 + String refreshJson = refresh.toString(); + for (String forbidden : new String[]{"tokenHash", "refreshTokenHash", "sessionId", "phone", "secret"}) { + org.assertj.core.api.Assertions.assertThat(refreshJson) + .as("刷新响应 schema 不得包含 " + forbidden).doesNotContain(forbidden); + } + + // 登录原有字段不得删除 + com.fasterxml.jackson.databind.JsonNode login = paths.path("/auth/login").path("post"); + org.assertj.core.api.Assertions.assertThat(login.isMissingNode()).isFalse(); + + // 开发辅助:-Ds02.openapi.snapshot=<目录> 时导出快照 + String snapshotDir = System.getProperty("s02.openapi.snapshot"); + if (snapshotDir != null && !snapshotDir.isBlank()) { + java.nio.file.Files.write( + java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"), + json.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + } + + private static com.fasterxml.jackson.databind.JsonNode deref(com.fasterxml.jackson.databind.JsonNode node, + com.fasterxml.jackson.databind.JsonNode components) { + if (node.has("$ref")) { + String ref = node.path("$ref").asText(); + String name = ref.substring(ref.lastIndexOf('/') + 1); + return components.path(name); + } + return node; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshHttpIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshHttpIntegrationTest.java new file mode 100644 index 0000000..6a6b38d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshHttpIntegrationTest.java @@ -0,0 +1,212 @@ +package com.mydreamjob.auth; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * S02 刷新接口的 HTTP 语义(设计 D08 / D09 / D11,任务书 §十四 与 §十九 HTTP 组)。 + * + *

    覆盖:匿名携带有效 Refresh 刷新成功、轮换后旧 Token 永久失效、 + * 二次使用触发重放处置并撤销当前会话、以及错误响应不泄漏。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AuthRefreshHttpIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9400); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private AuthTokenRepository authTokenRepository; + + private Long userId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + userId = userRepository.save(User.builder() + .phone("17" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("S02 刷新") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + } + + @AfterEach + void tearDown() { + if (userId != null) { + userRepository.deleteById(userId); + } + } + + private AuthSessionService.SessionTokens newSession() { + return authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + } + + private String refreshBody(String refreshToken) { + return "{\"refreshToken\":\"" + refreshToken + "\"}"; + } + + // ── 正常轮换 ──────────────────────────────────────────────── + + @Test + void anonymousRefreshWithValidTokenRotatesPair() throws Exception { + AuthSessionService.SessionTokens first = newSession(); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.refreshToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()) + .andExpect(jsonPath("$.data.expiresIn").value(86400)) + // 响应不得回显数据库字段或会话标识 + .andExpect(content().string(not(containsString("sessionId")))) + .andExpect(content().string(not(containsString("tokenHash")))); + } + + @Test + void rotatedAccessTokenWorksAndOldRefreshIsConsumed() throws Exception { + AuthSessionService.SessionTokens first = newSession(); + + String body = mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.refreshToken()))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + String newAccess = body.replaceAll(".*\"accessToken\":\"([^\"]+)\".*", "$1"); + + // 新的 Access Token 可用 + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + newAccess)) + .andExpect(status().isOk()); + + // 旧 Refresh Token 已被消费 + AuthToken consumed = authTokenRepository.findTopByTokenHashOrderByIdDesc( + new AuthTokenHasher(hashProperties()).hash(first.refreshToken())).orElseThrow(); + org.junit.jupiter.api.Assertions.assertNotNull(consumed.getConsumedAt()); + } + + // ── 二次使用 / 重放 ──────────────────────────────────────── + + @Test + void sameRefreshTokenCannotBeConsumedTwice() throws Exception { + AuthSessionService.SessionTokens first = newSession(); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.refreshToken()))) + .andExpect(status().isOk()); + + // 二次使用同一 Refresh Token → 401,且不说明已消费/已撤销 + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.refreshToken()))) + .andExpect(status().isUnauthorized()) + .andExpect(content().string(not(containsString("consumed")))) + .andExpect(content().string(not(containsString("revoked")))) + .andExpect(content().string(not(containsString("sessionId")))); + } + + @Test + void replayRevokesCurrentSessionSoOldAccessStopsWorking() throws Exception { + AuthSessionService.SessionTokens first = newSession(); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.refreshToken()))) + .andExpect(status().isOk()); + + // 重放:撤销当前会话 + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.refreshToken()))) + .andExpect(status().isUnauthorized()); + + // 该会话的原 Access Token 随即失效 + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + first.accessToken())) + .andExpect(status().isUnauthorized()); + } + + // ── 非 Refresh Token 一律拒绝 ─────────────────────────────── + + @Test + void accessTokenAndGarbageAreRejectedAtRefresh() throws Exception { + AuthSessionService.SessionTokens first = newSession(); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.accessToken()))) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody("not-a-token"))) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"refreshToken\":\"\"}")) + .andExpect(status().is4xxClientError()); + } + + @Test + void refreshTokenCannotBePassedAsQueryParameter() throws Exception { + AuthSessionService.SessionTokens first = newSession(); + + // Token 只能出现在请求体;作为 query 参数不产生任何效果。 + mockMvc.perform(post("/auth/refresh?refreshToken=" + first.refreshToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().is4xxClientError()); + } + + private com.mydreamjob.config.AppJwtProperties hashProperties() { + com.mydreamjob.config.AppJwtProperties p = new com.mydreamjob.config.AppJwtProperties(); + p.setTokenHashSecret("test-token-hash-secret-key-for-integration-tests-only-not-for-production"); + return p; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java index afc76f2..53db2d4 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java @@ -1,5 +1,6 @@ package com.mydreamjob.auth; +import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; @@ -14,21 +15,33 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.Optional; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; import java.util.Map; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +/** + * S02 {@link AuthService} 单元测试。 + * + *

    登录链路改为「验证码校验 → 用户解析 → 创建服务端会话」,因此 Mockito 目标 + * 由旧的 {@code JwtTokenProvider} 换成 {@link AuthSessionService}; + * 原有断言语义(响应字段、自动建号、注销墓碑、验证码用途透传)保持不变。

    + */ @ExtendWith(MockitoExtension.class) class AuthServiceTest { + private static final Instant FIXED_NOW = Instant.parse("2026-09-14T02:00:00Z"); + @Mock private UserRepository userRepository; @Mock - private JwtTokenProvider jwtTokenProvider; + private AuthSessionService authSessionService; @Mock private SmsVerificationService smsVerificationService; @@ -42,9 +55,18 @@ class AuthServiceTest { @Mock private AccountDeletionPhoneHasher accountDeletionPhoneHasher; + @Mock + private Clock clock; + @InjectMocks private AuthService authService; + private void stubSessionIssuance() { + when(clock.instant()).thenReturn(FIXED_NOW); + when(authSessionService.createSession(any(), any(), any())) + .thenReturn(new AuthSessionService.SessionTokens("access-token", "refresh-token", "sid-1")); + } + @Test void mockLogin_shouldReturnTokenWhenCodeValid() { User user = User.builder() @@ -53,17 +75,19 @@ class AuthServiceTest { when(smsVerificationService.verifyCode("13800000001", "123456", "login")).thenReturn(true); when(userRepository.findByPhone("13800000001")).thenReturn(Optional.of(user)); - when(jwtTokenProvider.generateAccessToken(anyLong(), anyString(), anyMap())) - .thenReturn("access-token"); - when(jwtTokenProvider.generateRefreshToken(anyLong(), anyString())) - .thenReturn("refresh-token"); + stubSessionIssuance(); Map result = authService.mockLogin("13800000001", "123456", "login", "req-1", "10.0.0.1", "JUnit"); assertEquals("access-token", result.get("accessToken")); assertEquals("refresh-token", result.get("refreshToken")); assertEquals(1L, result.get("userId")); - verify(auditService, times(1)).logLogin(1L, "10.0.0.1", "JUnit", "req-1"); + // expiresIn 契约保持不变(设计 D04) + assertEquals(86400L, result.get("expiresIn")); + // 登录审计仍写入,且时间来自入口单次捕获的 capturedNow + verify(auditService, times(1)).log(eq(1L), eq("LOGIN"), eq("USER"), eq(1L), + eq("User logged in"), any(AuditContext.class), + eq(LocalDateTime.ofInstant(FIXED_NOW, AuthSessionService.BUSINESS_ZONE))); } @Test @@ -75,10 +99,7 @@ class AuthServiceTest { u.setId(99L); return u; }); - when(jwtTokenProvider.generateAccessToken(anyLong(), anyString(), anyMap())) - .thenReturn("access-token"); - when(jwtTokenProvider.generateRefreshToken(anyLong(), anyString())) - .thenReturn("refresh-token"); + stubSessionIssuance(); Map result = authService.mockLogin("13800000099", "123456", "login", null, null, null); @@ -92,10 +113,7 @@ class AuthServiceTest { .thenReturn(true); User user = User.builder().id(1L).phone("13800000001").role(User.Role.STUDENT).active(true).verified(true).build(); when(userRepository.findByPhone("13800000001")).thenReturn(Optional.of(user)); - when(jwtTokenProvider.generateAccessToken(anyLong(), anyString(), anyMap())) - .thenReturn("token"); - when(jwtTokenProvider.generateRefreshToken(anyLong(), anyString())) - .thenReturn("refresh"); + stubSessionIssuance(); authService.mockLogin("13800000001", "123456", "password-reset", null, null, null); verify(smsVerificationService, times(1)).verifyCode("13800000001", "123456", "password-reset"); @@ -108,6 +126,8 @@ class AuthServiceTest { assertThrows(BusinessException.class, () -> authService.mockLogin("13800000001", "000000", "login", null, null, null)); + // 验证码失败不得创建任何会话 + verify(authSessionService, never()).createSession(any(), any(), any()); } @Test @@ -123,6 +143,7 @@ class AuthServiceTest { assertEquals(ErrorCode.ACCOUNT_DELETED.getCode(), ex.getErrorCodeInt()); // 不得自动创建新用户 verify(userRepository, never()).save(any()); + verify(authSessionService, never()).createSession(any(), any(), any()); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionPersistenceIntegrationTest.java new file mode 100644 index 0000000..74b0a73 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionPersistenceIntegrationTest.java @@ -0,0 +1,219 @@ +package com.mydreamjob.auth; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * S02 持久化契约(设计第七节,任务书 §九)。 + * + *

    覆盖:V42 迁移字段、新记录字段正确、token_jti 唯一、默认 version=0、 + * 乐观锁冲突、旧格式行仍可存在、数据库不保存 Token 原文。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class AuthSessionPersistenceIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9900); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private AuthTokenRepository authTokenRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @PersistenceContext + private EntityManager entityManager; + + private Long userId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + userId = userRepository.save(User.builder() + .phone("14" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("S02 持久化") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + } + + @AfterEach + void tearDown() { + if (userId != null) { + userRepository.deleteById(userId); + } + } + + // ── 迁移字段 ──────────────────────────────────────────────── + + @Test + void v42ColumnsExist() { + List columns = jdbcTemplate.queryForList( + "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE UPPER(TABLE_NAME) = 'AUTH_TOKENS'", + String.class); + + assertThat(columns.stream().map(String::toLowerCase).toList()).contains( + "session_id", "token_jti", "parent_token_id", + "consumed_at", "revoked_at", "revocation_reason", "version"); + } + + @Test + void newSessionRecordHasExpectedFields() { + AuthSessionService.SessionTokens session = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + + List rows = authTokenRepository.findByUserIdAndSessionIdOrderByIdAsc(userId, session.sessionId()); + assertThat(rows).hasSize(1); + + AuthToken row = rows.get(0); + assertThat(row.getSessionId()).isEqualTo(session.sessionId()); + assertThat(row.getTokenJti()).isNotBlank(); + assertThat(row.getTokenType()).isEqualTo(AuthToken.TYPE_REFRESH); + assertThat(row.getRevoked()).isFalse(); + assertThat(row.getConsumedAt()).isNull(); + assertThat(row.getRevokedAt()).isNull(); + assertThat(row.getVersion()).as("新记录默认 version=0").isZero(); + assertThat(row.getExpiredAt()).isAfter(LocalDateTime.now()); + } + + @Test + void databaseDoesNotStoreRawJwt() { + AuthSessionService.SessionTokens session = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + + List hashes = jdbcTemplate.queryForList( + "SELECT token_hash FROM auth_tokens WHERE session_id = ?", String.class, session.sessionId()); + + assertThat(hashes).hasSize(1); + String stored = hashes.get(0); + assertThat(stored).as("必须是固定长度摘要").hasSize(64); + assertThat(stored).matches("[0-9a-f]{64}"); + // 原文的两个部分都不得出现 + assertThat(stored).doesNotContain(session.refreshToken()); + assertThat(session.refreshToken()).doesNotContain(stored); + } + + @Test + void refreshJtiIsUnique() { + AuthSessionService.SessionTokens session = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + AuthToken existing = authTokenRepository + .findByUserIdAndSessionIdOrderByIdAsc(userId, session.sessionId()).get(0); + + assertThatThrownBy(() -> jdbcTemplate.update( + "INSERT INTO auth_tokens (user_id, token_hash, token_type, expired_at, revoked, session_id, token_jti, version, created_at)" + + " VALUES (?, ?, 'REFRESH', ?, 0, ?, ?, 0, CURRENT_TIMESTAMP)", + userId, "a".repeat(64), LocalDateTime.now().plusDays(1), + "other-session", existing.getTokenJti())) + .as("token_jti 必须唯一") + .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + } + + @Test + void legacyRowsWithoutSessionIdRemainAllowed() { + // 迁移前写入的旧格式行(session_id IS NULL)必须仍然可以存在 + jdbcTemplate.update( + "INSERT INTO auth_tokens (user_id, token_hash, token_type, expired_at, revoked, created_at)" + + " VALUES (?, ?, 'ACCESS', ?, 0, CURRENT_TIMESTAMP)", + userId, "b".repeat(64), LocalDateTime.now().plusDays(1)); + + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM auth_tokens WHERE user_id = ? AND session_id IS NULL", + Integer.class, userId)).isPositive(); + + // 且旧行永远不会被新鉴权逻辑视为活动会话 + assertThat(authSessionService.isSessionActive(userId, null, Instant.now())).isFalse(); + } + + // ── 乐观锁 / 条件更新 ─────────────────────────────────────── + + @Test + @Transactional + void consumeIfCurrentRejectsStaleVersion() { + AuthSessionService.SessionTokens session = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + AuthToken row = authTokenRepository + .findByUserIdAndSessionIdOrderByIdAsc(userId, session.sessionId()).get(0); + + Long staleVersion = row.getVersion(); + LocalDateTime now = LocalDateTime.now(); + + // 第一次消费成功并把 version 递增 + assertThat(authTokenRepository.consumeIfCurrent(row.getId(), staleVersion, now, now)).isEqualTo(1); + // 用旧 version 再次消费必须失败(条件更新失效) + assertThat(authTokenRepository.consumeIfCurrent(row.getId(), staleVersion, now, now)).isZero(); + } + + @Test + @Transactional + void consumeIfCurrentRejectsRevokedConsumedOrExpired() { + AuthSessionService.SessionTokens session = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + AuthToken row = authTokenRepository + .findByUserIdAndSessionIdOrderByIdAsc(userId, session.sessionId()).get(0); + LocalDateTime now = LocalDateTime.now(); + + // 过期后不可消费 + assertThat(authTokenRepository.consumeIfCurrent( + row.getId(), row.getVersion(), now, row.getExpiredAt().plusSeconds(1))).isZero(); + + // 已撤销不可消费 + authTokenRepository.revokeSession(userId, session.sessionId(), now, AuthSessionService.REASON_LOGOUT); + AuthToken reloaded = authTokenRepository.findById(row.getId()).orElseThrow(); + assertThat(authTokenRepository.consumeIfCurrent( + reloaded.getId(), reloaded.getVersion(), now, now)).isZero(); + } + + @Test + @Transactional + void conditionUpdateReturnsAffectedRowsAndIsChecked() { + AuthSessionService.SessionTokens session = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + + int first = authTokenRepository.revokeSession( + userId, session.sessionId(), LocalDateTime.now(), AuthSessionService.REASON_LOGOUT); + int second = authTokenRepository.revokeSession( + userId, session.sessionId(), LocalDateTime.now(), AuthSessionService.REASON_LOGOUT); + + assertThat(first).isEqualTo(1); + assertThat(second).as("重复撤销不再影响任何行").isZero(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionRevocationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionRevocationIntegrationTest.java new file mode 100644 index 0000000..cc64a96 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionRevocationIntegrationTest.java @@ -0,0 +1,200 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.deletion.AccountDeletion; +import com.mydreamjob.deletion.AccountDeletionCompletionWorker; +import com.mydreamjob.deletion.AccountDeletionRepository; +import com.mydreamjob.deletion.DeletionStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.test.context.ActiveProfiles; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * S02 会话撤销语义(设计 D11 / D12,任务书 §十五 与 §十九 生命周期组)。 + * + *

    覆盖:登出只撤销当前设备、多设备隔离、重复撤销幂等、 + * 注销完成撤销全部会话、其他用户不受影响。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class AuthSessionRevocationIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9800); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private AccountDeletionRepository accountDeletionRepository; + + @Autowired + private AccountDeletionCompletionWorker accountDeletionCompletionWorker; + + private Long userId; + private Long otherUserId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + userId = createUser("S02 撤销"); + otherUserId = createUser("S02 他人"); + } + + @AfterEach + void tearDown() { + accountDeletionRepository.findByUserId(userId).ifPresent(accountDeletionRepository::delete); + accountDeletionRepository.findByUserId(otherUserId).ifPresent(accountDeletionRepository::delete); + userRepository.deleteById(otherUserId); + userRepository.deleteById(userId); + } + + private Long createUser(String nickname) { + return userRepository.save(User.builder() + .phone("15" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname(nickname) + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + } + + private AuthSessionService.SessionTokens sessionFor(Long uid) { + return authSessionService.createSession(userRepository.findById(uid).orElseThrow(), Instant.now(), null); + } + + // ── 单设备登出 ────────────────────────────────────────────── + + @Test + void logoutRevokesOnlyCurrentSession() { + AuthSessionService.SessionTokens deviceA = sessionFor(userId); + AuthSessionService.SessionTokens deviceB = sessionFor(userId); + assertThat(deviceA.sessionId()).isNotEqualTo(deviceB.sessionId()); + + authSessionService.logout(userId, deviceA.sessionId(), Instant.now(), null); + + assertThat(authSessionService.isSessionActive(userId, deviceA.sessionId(), Instant.now())).isFalse(); + assertThat(authSessionService.isSessionActive(userId, deviceB.sessionId(), Instant.now())) + .as("其他设备会话不得被误伤").isTrue(); + } + + @Test + void repeatedLogoutIsIdempotent() { + AuthSessionService.SessionTokens device = sessionFor(userId); + + authSessionService.logout(userId, device.sessionId(), Instant.now(), null); + // 第二次登出不得抛异常 + authSessionService.logout(userId, device.sessionId(), Instant.now(), null); + + assertThat(authSessionService.isSessionActive(userId, device.sessionId(), Instant.now())).isFalse(); + } + + @Test + void logoutDoesNotAffectOtherUsers() { + AuthSessionService.SessionTokens mine = sessionFor(userId); + AuthSessionService.SessionTokens theirs = sessionFor(otherUserId); + + authSessionService.logout(userId, mine.sessionId(), Instant.now(), null); + + assertThat(authSessionService.isSessionActive(otherUserId, theirs.sessionId(), Instant.now())) + .as("其他用户会话不受影响").isTrue(); + } + + // ── 注销完成撤销全部会话 ──────────────────────────────────── + + @Test + void accountDeletionCompletionRevokesAllSessions() { + AuthSessionService.SessionTokens deviceA = sessionFor(userId); + AuthSessionService.SessionTokens deviceB = sessionFor(userId); + AuthSessionService.SessionTokens other = sessionFor(otherUserId); + + requestDeletionPastCooling(userId); + assertThat(accountDeletionCompletionWorker.completeIfExpired(userId)).isTrue(); + + assertThat(authSessionService.isSessionActive(userId, deviceA.sessionId(), Instant.now())).isFalse(); + assertThat(authSessionService.isSessionActive(userId, deviceB.sessionId(), Instant.now())).isFalse(); + assertThat(authSessionService.isSessionActive(otherUserId, other.sessionId(), Instant.now())) + .as("其他用户会话不受影响").isTrue(); + } + + @Test + void pendingDeletionDoesNotForceLogout() { + AuthSessionService.SessionTokens device = sessionFor(userId); + + // 尚在冷静期(未到期)的 PENDING 不应撤销会话 + accountDeletionRepository.save(AccountDeletion.builder() + .userId(userId).status(DeletionStatus.PENDING) + .requestedAt(LocalDateTime.now()) + .effectiveAt(LocalDateTime.now().plusDays(30)) + .build()); + assertThat(accountDeletionCompletionWorker.completeIfExpired(userId)).isFalse(); + + assertThat(authSessionService.isSessionActive(userId, device.sessionId(), Instant.now())) + .as("注销申请 PENDING 时不强制退出").isTrue(); + } + + @Test + void cancelledDeletionDoesNotRestoreRevokedSessions() { + AuthSessionService.SessionTokens device = sessionFor(userId); + + requestDeletionPastCooling(userId); + assertThat(accountDeletionCompletionWorker.completeIfExpired(userId)).isTrue(); + assertThat(authSessionService.isSessionActive(userId, device.sessionId(), Instant.now())).isFalse(); + + // 回退注销状态(等价于取消)不得让已撤销的会话复活 + AccountDeletion rec = accountDeletionRepository.findByUserId(userId).orElseThrow(); + rec.setStatus(DeletionStatus.NONE); + accountDeletionRepository.saveAndFlush(rec); + + assertThat(authSessionService.isSessionActive(userId, device.sessionId(), Instant.now())) + .as("取消注销不恢复旧 session").isFalse(); + } + + @Test + void revokeAllUserSessionsReportsAffectedRows() { + sessionFor(userId); + sessionFor(userId); + sessionFor(userId); + + int affected = authSessionService.revokeAllUserSessions( + userId, AuthSessionService.REASON_USER_DISABLED, Instant.now()); + + assertThat(affected).as("应撤销该用户全部活动会话").isGreaterThanOrEqualTo(3); + // 再次吊销:已无活动会话,受影响行数为 0(幂等) + assertThat(authSessionService.revokeAllUserSessions( + userId, AuthSessionService.REASON_USER_DISABLED, Instant.now())).isZero(); + } + + private void requestDeletionPastCooling(Long uid) { + accountDeletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.PENDING) + .requestedAt(LocalDateTime.now().minusDays(31)) + .effectiveAt(LocalDateTime.now().minusDays(1)) + .build()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionServiceTest.java new file mode 100644 index 0000000..0512796 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionServiceTest.java @@ -0,0 +1,118 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.audit.AuditService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +/** + * S02 会话服务的单元级语义(失败关闭、权限判定)。 + * + *

    并发与持久化由集成测试覆盖;这里只验证拒绝路径不产生副作用。

    + */ +@ExtendWith(MockitoExtension.class) +class AuthSessionServiceTest { + + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + + @Mock + private AuthTokenRepository authTokenRepository; + + @Mock + private AuthTokenHasher authTokenHasher; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private UserRepository userRepository; + + @Mock + private AuditService auditService; + + @Mock + private PlatformTransactionManager transactionManager; + + @InjectMocks + private AuthSessionService authSessionService; + + private User activeUser; + + @BeforeEach + void setUp() { + activeUser = User.builder().id(1L).active(true).role(User.Role.STUDENT).build(); + } + + @Test + void isSessionActiveReturnsFalseForNullArguments() { + assertThat(authSessionService.isSessionActive(null, "sid", java.time.Instant.now())).isFalse(); + assertThat(authSessionService.isSessionActive(1L, null, java.time.Instant.now())).isFalse(); + assertThat(authSessionService.isSessionActive(1L, " ", java.time.Instant.now())).isFalse(); + } + + @Test + void isSessionActiveReturnsFalseWhenUserMissingOrInactive() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + assertThat(authSessionService.isSessionActive(1L, "sid", java.time.Instant.now())).isFalse(); + + User inactive = User.builder().id(2L).active(false).role(User.Role.STUDENT).build(); + when(userRepository.findById(2L)).thenReturn(Optional.of(inactive)); + assertThat(authSessionService.isSessionActive(2L, "sid", java.time.Instant.now())).isFalse(); + + User nullActive = User.builder().id(3L).active(null).role(User.Role.STUDENT).build(); + when(userRepository.findById(3L)).thenReturn(Optional.of(nullActive)); + assertThat(authSessionService.isSessionActive(3L, "sid", java.time.Instant.now())).isFalse(); + } + + @Test + void isSessionActiveFailsClosedWhenDatabaseErrors() { + when(userRepository.findById(1L)).thenReturn(Optional.of(activeUser)); + when(authTokenRepository.countActiveSessionTokens(anyLong(), anyString(), any(LocalDateTime.class))) + .thenThrow(new DataAccessResourceFailureException("db down")); + + assertThat(authSessionService.isSessionActive(1L, "sid", java.time.Instant.now())) + .as("数据库异常必须失败关闭").isFalse(); + } + + @Test + void isSessionActiveTrueOnlyWhenActiveRecordExists() { + when(userRepository.findById(1L)).thenReturn(Optional.of(activeUser)); + when(authTokenRepository.countActiveSessionTokens(eq(1L), eq("sid"), any(LocalDateTime.class))) + .thenReturn(1L); + + assertThat(authSessionService.isSessionActive(1L, "sid", java.time.Instant.now())).isTrue(); + } + + @Test + void revokeAllUserSessionsReturnsZeroForNullUser() { + assertThat(authSessionService.revokeAllUserSessions( + null, AuthSessionService.REASON_USER_DISABLED, java.time.Instant.now())).isZero(); + } + + @Test + void logoutRejectsBlankSessionId() { + org.junit.jupiter.api.Assertions.assertThrows(InvalidTokenException.class, + () -> authSessionService.logout(1L, " ", java.time.Instant.now(), null)); + } + + @Test + void sessionRefIsIrreversibleAndFixedLength() { + when(authTokenHasher.hash(anyString())).thenReturn("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + String ref = authSessionService.sessionRef("session-1"); + assertThat(ref).hasSize(16); + assertThat(ref).matches("[0-9a-f]{16}"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenClockConsistencyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenClockConsistencyTest.java new file mode 100644 index 0000000..7b84c3a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenClockConsistencyTest.java @@ -0,0 +1,254 @@ +package com.mydreamjob.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.time.*; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * S02 单次时间捕获(设计 D13,任务书 §十七)。 + * + *

    使用 {@link AdvancingClock}:第一次读返回 T,之后返回明显不同的时间。 + * 只要某个入口读了两次 Clock,断言就会失败。每个入口(登录/刷新/登出) + * 分别验证:调用次数恰为 1,且 JWT、数据库与审计时间全部来自 T。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class AuthTokenClockConsistencyTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9500); + private static final Instant T = Instant.parse("2026-01-02T03:04:05Z"); + /** 第二次及以后读取返回的时间:跨月,确保一旦被使用就能被识别。 */ + private static final Instant T_LATER = Instant.parse("2026-02-05T07:08:09Z"); + private static final ZoneId ZONE = AdvancingClock.ZONE; + + @TestConfiguration + static class AdvancingClockConfiguration { + @Bean + @Primary + AdvancingClock advancingClock() { + return new AdvancingClock(T, T_LATER); + } + } + + @MockBean + private RedisTemplate redisTemplate; + + @MockBean + private SmsVerificationService smsVerificationService; + + @Autowired + private AdvancingClock clock; + + @Autowired + private AuthService authService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Value("${app.jwt.expiration-ms}") + private long accessTtlMs; + + @Value("${app.jwt.refresh-expiration-ms}") + private long refreshTtlMs; + + @Value("${app.jwt.access-secret}") + private String accessSecret; + + @Value("${app.jwt.refresh-secret}") + private String refreshSecret; + + private Long userId; + private String phone; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(smsVerificationService.verifyCode(any(), any(), any())).thenReturn(true); + + phone = "12" + String.format("%09d", PHONE_SEQ.incrementAndGet()); + userId = userRepository.save(User.builder() + .phone(phone) + .nickname("S02 时钟") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + clock.reset(); + } + + @AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM audit_logs WHERE action LIKE 'AUTH_%' AND user_id = ?", userId); + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", userId); + if (userId != null) { + userRepository.deleteById(userId); + } + } + + // ── 工具 ──────────────────────────────────────────────────── + + private Claims rawClaims(String token, String secret) { + SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + return Jwts.parser().verifyWith(key).clock(() -> java.util.Date.from(Instant.EPOCH)) + .build().parseSignedClaims(token).getPayload(); + } + + private LocalDateTime at(Instant instant) { + return LocalDateTime.ofInstant(instant, ZONE); + } + + private Timestamp queryTimestamp(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Timestamp.class, args); + } + + @SuppressWarnings("unchecked") + private Map login() { + return authService.mockLogin(phone, "123456", "login", "req-clock", null, null); + } + + // ── 登录 ──────────────────────────────────────────────────── + + @Test + void loginCapturesClockExactlyOnce() { + clock.reset(); + Map result = login(); + + assertThat(clock.callCount()).as("登录入口只能读取一次 Clock").isEqualTo(1); + + String access = (String) result.get("accessToken"); + String refresh = (String) result.get("refreshToken"); + + Claims accessClaims = rawClaims(access, accessSecret); + assertThat(accessClaims.getIssuedAt().toInstant()).isEqualTo(T); + assertThat(accessClaims.getExpiration().toInstant()).isEqualTo(T.plusMillis(accessTtlMs)); + + Claims refreshClaims = rawClaims(refresh, refreshSecret); + assertThat(refreshClaims.getIssuedAt().toInstant()).isEqualTo(T); + assertThat(refreshClaims.getExpiration().toInstant()).isEqualTo(T.plusMillis(refreshTtlMs)); + + String sessionId = refreshClaims.get("sid", String.class); + Timestamp createdAt = queryTimestamp( + "SELECT created_at FROM auth_tokens WHERE session_id = ?", sessionId); + Timestamp expiredAt = queryTimestamp( + "SELECT expired_at FROM auth_tokens WHERE session_id = ?", sessionId); + + assertThat(createdAt.toLocalDateTime()) + .as("auth_tokens.created_at 必须来自 capturedNow") + .isEqualTo(at(T)); + assertThat(expiredAt.toLocalDateTime()).isEqualTo(at(T.plusMillis(refreshTtlMs))); + + Timestamp auditCreatedAt = queryTimestamp( + "SELECT created_at FROM audit_logs WHERE action = 'AUTH_SESSION_CREATED' AND user_id = ?", userId); + assertThat(auditCreatedAt.toLocalDateTime()).isEqualTo(at(T)); + } + + // ── 刷新 ──────────────────────────────────────────────────── + + @Test + void refreshCapturesClockExactlyOnce() { + Map loginResult = login(); + String refreshToken = (String) loginResult.get("refreshToken"); + String sessionId = rawClaims(refreshToken, refreshSecret).get("sid", String.class); + + clock.reset(); + AuthSessionService.SessionTokens rotated = authService.refresh(refreshToken, "req-refresh", null, null); + + assertThat(clock.callCount()).as("刷新入口只能读取一次 Clock").isEqualTo(1); + + Claims rotatedClaims = rawClaims(rotated.accessToken(), accessSecret); + assertThat(rotatedClaims.getIssuedAt().toInstant()).isEqualTo(T); + assertThat(rotatedClaims.getExpiration().toInstant()).isEqualTo(T.plusMillis(accessTtlMs)); + + Timestamp consumedAt = queryTimestamp(""" + SELECT consumed_at FROM auth_tokens + WHERE session_id = ? AND consumed_at IS NOT NULL + """, sessionId); + assertThat(consumedAt.toLocalDateTime()).as("消费时间来自 capturedNow").isEqualTo(at(T)); + + Timestamp successorCreatedAt = queryTimestamp(""" + SELECT created_at FROM auth_tokens + WHERE session_id = ? AND consumed_at IS NULL + """, sessionId); + assertThat(successorCreatedAt.toLocalDateTime()).as("后继记录 created_at 来自 capturedNow").isEqualTo(at(T)); + + Timestamp auditCreatedAt = queryTimestamp( + "SELECT created_at FROM audit_logs WHERE action = 'AUTH_TOKEN_REFRESHED' AND user_id = ?", userId); + assertThat(auditCreatedAt.toLocalDateTime()).isEqualTo(at(T)); + } + + // ── 登出 ──────────────────────────────────────────────────── + + @Test + void logoutCapturesClockExactlyOnce() { + Map loginResult = login(); + String refreshToken = (String) loginResult.get("refreshToken"); + String sessionId = rawClaims(refreshToken, refreshSecret).get("sid", String.class); + + clock.reset(); + authService.logout(userId, sessionId, "req-logout", null, null); + + assertThat(clock.callCount()).as("登出入口只能读取一次 Clock").isEqualTo(1); + + Timestamp revokedAt = queryTimestamp( + "SELECT revoked_at FROM auth_tokens WHERE session_id = ?", sessionId); + assertThat(revokedAt.toLocalDateTime()).as("撤销时间来自 capturedNow").isEqualTo(at(T)); + + Timestamp auditCreatedAt = queryTimestamp( + "SELECT created_at FROM audit_logs WHERE action = 'AUTH_SESSION_REVOKED' AND user_id = ?", userId); + assertThat(auditCreatedAt.toLocalDateTime()).isEqualTo(at(T)); + } + + // ── 第二次读取的值不得混入 ────────────────────────────────── + + @Test + void laterClockValueNeverLeaksIntoPersistedStateOrAudit() { + clock.reset(); + login(); + + String laterText = at(T_LATER).toString(); + for (String column : new String[]{"created_at", "expired_at"}) { + String value = jdbcTemplate.queryForObject( + "SELECT CAST(" + column + " AS VARCHAR) FROM auth_tokens WHERE user_id = ?", String.class, userId); + assertThat(value).as(column + " 不得混入第二次读取的时钟值").doesNotContain(laterText); + } + String auditValue = jdbcTemplate.queryForObject( + "SELECT CAST(created_at AS VARCHAR) FROM audit_logs WHERE user_id = ? ORDER BY id DESC LIMIT 1", + String.class, userId); + assertThat(auditValue).as("审计时间不得混入第二次读取的时钟值").doesNotContain(laterText); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenHasherTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenHasherTest.java new file mode 100644 index 0000000..bedb521 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenHasherTest.java @@ -0,0 +1,69 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.config.AppJwtProperties; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * S02 Refresh Token 摘要(设计 D07 / 第八节)。 + * + *

    只验证确定性、长度与常量时间比较的语义;不依赖 Spring 上下文。

    + */ +class AuthTokenHasherTest { + + private static final String HASH_SECRET = "test-token-hash-secret-key-for-unit-tests-only-not-for-production"; + private static final String OTHER_SECRET = "another-token-hash-secret-key-for-unit-tests-only-not-for-prod"; + + private static AuthTokenHasher hasher(String secret) { + AppJwtProperties p = new AppJwtProperties(); + p.setAccessSecret("test-access-secret-key-for-unit-tests-only-not-for-production"); + p.setRefreshSecret("test-refresh-secret-key-for-unit-tests-only-not-for-production"); + p.setTokenHashSecret(secret); + return new AuthTokenHasher(p); + } + + @Test + void hashIsStableForSameInput() { + AuthTokenHasher hasher = hasher(HASH_SECRET); + String a = hasher.hash("refresh-token-value"); + String b = hasher.hash("refresh-token-value"); + assertEquals(a, b, "同一输入必须得到同一摘要"); + } + + @Test + void hashIsFixedLengthLowercaseHex() { + String hash = hasher(HASH_SECRET).hash("refresh-token-value"); + assertEquals(64, hash.length(), "HmacSHA256 十六进制摘要固定 64 字符"); + assertTrue(hash.matches("[0-9a-f]{64}"), "必须是规范小写十六进制"); + } + + @Test + void differentInputsProduceDifferentHashes() { + AuthTokenHasher hasher = hasher(HASH_SECRET); + assertNotEquals(hasher.hash("token-a"), hasher.hash("token-b")); + } + + @Test + void differentSecretsProduceDifferentHashes() { + String a = hasher(HASH_SECRET).hash("same-input"); + String b = hasher(OTHER_SECRET).hash("same-input"); + assertNotEquals(a, b, "摘要必须依赖密钥,不能是无密钥裸摘要"); + } + + @Test + void nullAndEmptyAreHandledDeterministically() { + AuthTokenHasher hasher = hasher(HASH_SECRET); + assertEquals(hasher.hash(""), hasher.hash(null)); + } + + @Test + void matchesIsConstantTimeComparisonSemantics() { + AuthTokenHasher hasher = hasher(HASH_SECRET); + String hash = hasher.hash("value"); + assertTrue(hasher.matches(hash, hash)); + assertFalse(hasher.matches(hash, hasher.hash("other"))); + assertFalse(hasher.matches(hash, null)); + assertFalse(hasher.matches(null, hash)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenRedactionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenRedactionTest.java new file mode 100644 index 0000000..84278b5 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenRedactionTest.java @@ -0,0 +1,180 @@ +package com.mydreamjob.auth; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * S02 日志与审计脱敏(设计第十二节,任务书 §十六)。 + * + *

    日志断言捕获**真实 Logback 输出**(不是 Mockito verify logger); + * 审计断言读取真实 audit_logs 行并校验 details 白名单。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class AuthTokenRedactionTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9700); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private AuthTokenHasher authTokenHasher; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private Long userId; + private ListAppender appender; + private Logger appLogger; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + userId = userRepository.save(User.builder() + .phone("13" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("S02 脱敏") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + + appLogger = (Logger) LoggerFactory.getLogger("com.mydreamjob"); + appender = new ListAppender<>(); + appender.start(); + appLogger.addAppender(appender); + appLogger.setLevel(Level.TRACE); + } + + @AfterEach + void tearDown() { + if (appLogger != null && appender != null) { + appLogger.detachAppender(appender); + appender.stop(); + } + jdbcTemplate.update("DELETE FROM audit_logs WHERE action LIKE 'AUTH_%' AND user_id = ?", userId); + if (userId != null) { + userRepository.deleteById(userId); + } + } + + private String capturedLog() { + return appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .collect(Collectors.joining("\n")); + } + + @Test + void loginRefreshAndLogoutNeverLogTokensOrHashes() { + AuthSessionService.SessionTokens first = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + AuthSessionService.SessionTokens rotated = + authSessionService.refresh(first.refreshToken(), Instant.now(), null); + authSessionService.logout(userId, rotated.sessionId(), Instant.now(), null); + + // 重放路径同样不得泄漏 + try { + authSessionService.refresh(first.refreshToken(), Instant.now(), null); + } catch (InvalidTokenException expected) { + // 预期 + } + + String log = capturedLog(); + for (String secret : List.of(first.accessToken(), first.refreshToken(), + rotated.accessToken(), rotated.refreshToken())) { + assertThat(log).as("日志不得包含 Token 原文").doesNotContain(secret); + } + String sessionHash = authTokenHasher.hash("sid:" + first.sessionId()); + assertThat(log).as("日志不得包含会话摘要").doesNotContain(sessionHash); + assertThat(log).doesNotContain("Bearer "); + assertThat(log).doesNotContain("Authorization"); + } + + @Test + void auditDetailsUseWhitelistOnly() { + AuthSessionService.SessionTokens first = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + AuthSessionService.SessionTokens rotated = + authSessionService.refresh(first.refreshToken(), Instant.now(), null); + authSessionService.logout(userId, rotated.sessionId(), Instant.now(), null); + try { + authSessionService.refresh(first.refreshToken(), Instant.now(), null); + } catch (InvalidTokenException expected) { + // 预期 + } + + List> rows = jdbcTemplate.queryForList( + "SELECT action, details FROM audit_logs WHERE user_id = ? AND action LIKE 'AUTH_%'", userId); + + assertThat(rows).as("应写入会话相关审计").isNotEmpty(); + for (Map row : rows) { + String action = (String) row.get("action"); + assertThat(action).isIn( + "AUTH_SESSION_CREATED", "AUTH_TOKEN_REFRESHED", + "AUTH_SESSION_REVOKED", "AUTH_REFRESH_REUSE_DETECTED"); + + String details = String.valueOf(row.get("details")); + assertThat(details).as("审计 details 不得包含 Token/摘要/手机号") + .doesNotContain("accessToken") + .doesNotContain("refreshToken") + .doesNotContain("Bearer") + .doesNotContain("tokenHash") + .doesNotContain(first.refreshToken()) + .doesNotContain(first.accessToken()); + // 只允许 sessionRef / reason 两个键 + assertThat(details).contains("\"sessionRef\""); + } + } + + @Test + void auditNeverContainsRawTokens() { + AuthSessionService.SessionTokens tokens = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + + List allDetails = jdbcTemplate.queryForList( + "SELECT details FROM audit_logs WHERE user_id = ?", String.class, userId); + + for (String details : allDetails) { + assertThat(details).doesNotContain(tokens.accessToken()); + assertThat(details).doesNotContain(tokens.refreshToken()); + // 数据库里的摘要也不得出现在审计中 + assertThat(details).doesNotContain(authTokenHasher.hash(tokens.refreshToken())); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java new file mode 100644 index 0000000..347a276 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java @@ -0,0 +1,431 @@ +package com.mydreamjob.auth; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.job.JobStatus; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * S01 认证与授权边界(真实 SecurityFilterChain + 真实 H2 Service)。 + * + *

    覆盖任务书第三节与第九节 C 组: + * 匿名写请求必须是 401(修复前为 500),越权请求不得产生业务写入, + * 公开 GET 语义保持不变,且失败响应不得回显 anonymousUser、非法 principal 或内部类名。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AuthenticationBoundaryIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(7100); + private static final String BAD_PRINCIPAL = "INTERNAL_BAD_PRINCIPAL_SECRET"; + private static final String VALID_JOB_BODY = """ + {"title":"安全边界测试岗位","description":"desc","city":"杭州","employmentType":"FULL_TIME"} + """; + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private UserRepository userRepository; + + @Autowired + private CompanyRepository companyRepository; + + @Autowired + private JobRepository jobRepository; + + @Value("${app.jwt.secret}") + private String jwtSecret; + + private Long companyUserId; + private Long otherCompanyUserId; + private Long studentUserId; + private Long adminUserId; + private Long companyId; + private Long otherCompanyId; + private Long jobId; + + private ListAppender appender; + private Logger appLogger; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + companyUserId = createUser(User.Role.COMPANY); + otherCompanyUserId = createUser(User.Role.COMPANY); + studentUserId = createUser(User.Role.STUDENT); + adminUserId = createUser(User.Role.ADMIN); + + companyId = createCompany(companyUserId, "安全边界甲公司"); + otherCompanyId = createCompany(otherCompanyUserId, "安全边界乙公司"); + + jobId = jobRepository.save(Job.builder() + .companyId(companyId) + .title("边界夹具岗位") + .description("fixture") + .city("杭州") + .employmentType("FULL_TIME") + .status(JobStatus.DRAFT.name()) + .build()).getId(); + + appLogger = (Logger) LoggerFactory.getLogger("com.mydreamjob"); + appender = new ListAppender<>(); + appender.start(); + appLogger.addAppender(appender); + appLogger.setLevel(Level.TRACE); + } + + @AfterEach + void tearDown() { + if (appLogger != null && appender != null) { + appLogger.detachAppender(appender); + appender.stop(); + } + // 本测试类与其他集成测试共用同一 JVM 内的 H2 命名库,必须清理自身夹具(FK 安全顺序), + // 否则残留的 companies 行会让后续 AuthIntegrationTest 的 userRepository.deleteAll() 触发 FK 约束错误。 + List myCompanyIds = Stream.of(companyId, otherCompanyId) + .filter(Objects::nonNull).toList(); + if (!myCompanyIds.isEmpty()) { + jobRepository.deleteAll(jobRepository.findAll().stream() + .filter(j -> myCompanyIds.contains(j.getCompanyId())) + .toList()); + companyRepository.deleteAllById(myCompanyIds); + } + List myUserIds = Stream.of(companyUserId, otherCompanyUserId, studentUserId, adminUserId) + .filter(Objects::nonNull).toList(); + if (!myUserIds.isEmpty()) { + userRepository.deleteAllById(myUserIds); + } + } + + // ── A. 匿名写请求必须 401,不得 500,且无业务写入 ────────────── + + @Test + void anonymousPostJobsMustReturn401InsteadOf500() throws Exception { + List before = snapshotJobs(); + + mockMvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(VALID_JOB_BODY)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)) + .andExpect(content().string(not(containsString("anonymousUser")))) + .andExpect(content().string(not(containsString("NumberFormatException")))) + .andExpect(content().string(not(containsString("Exception")))) + .andExpect(content().string(not(containsString("com.mydreamjob")))) + .andExpect(content().string(not(containsString("java.")))); + + assertThat(snapshotJobs()).as("匿名创建不得写入任何岗位").isEqualTo(before); + } + + @Test + void anonymousCannotCreateJob() throws Exception { + List before = snapshotJobs(); + + mockMvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(VALID_JOB_BODY)) + .andExpect(status().isUnauthorized()); + + assertThat(snapshotJobs()) + .as("jobs 表完整内容不得变化(不只是行数)") + .isEqualTo(before); + } + + @Test + void anonymousCannotUpdateJob() throws Exception { + List before = snapshotJobs(); + + mockMvc.perform(put("/jobs/" + jobId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"被篡改\"}")) + .andExpect(status().isUnauthorized()); + + assertThat(snapshotJobs()).isEqualTo(before); + } + + @Test + void anonymousCannotDeleteJob() throws Exception { + List before = snapshotJobs(); + + mockMvc.perform(delete("/jobs/" + jobId)) + .andExpect(status().isUnauthorized()); + + assertThat(snapshotJobs()).isEqualTo(before); + } + + @Test + void anonymousCannotPublishOrCloseJob() throws Exception { + List before = snapshotJobs(); + + mockMvc.perform(patch("/jobs/" + jobId + "/status") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"PUBLISHED\"}")) + .andExpect(status().isUnauthorized()); + + assertThat(snapshotJobs()).isEqualTo(before); + assertThat(jobRepository.findById(jobId).orElseThrow().getStatus()) + .isEqualTo(JobStatus.DRAFT.name()); + } + + @Test + void anonymousAuthenticationTokenMustBeRejectedBeforeControllerBusinessLogic() throws Exception { + List before = snapshotJobs(); + + // 匿名请求会被 Spring Security 包装为 AnonymousAuthenticationToken(isAuthenticated() == true)。 + // 修复前:命中 permitAll → 进入 Controller → Long.valueOf("anonymousUser") → 500。 + mockMvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(VALID_JOB_BODY)) + .andExpect(status().isUnauthorized()) + .andExpect(content().string(not(containsString("anonymousUser")))); + + assertThat(snapshotJobs()).isEqualTo(before); + } + + @Test + void nonNumericPrincipalMustReturn401WithoutEcho() throws Exception { + List before = snapshotJobs(); + + mockMvc.perform(post("/jobs") + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_JOB_BODY) + .header("Authorization", "Bearer " + signedTokenWithSubject(BAD_PRINCIPAL))) + .andExpect(status().isUnauthorized()) + .andExpect(content().string(not(containsString(BAD_PRINCIPAL)))) + .andExpect(content().string(not(containsString("NumberFormatException")))) + .andExpect(content().string(not(containsString("java.")))); + + assertThat(capturedLogs()) + .as("日志不得回显非法 principal") + .doesNotContain(BAD_PRINCIPAL); + assertThat(snapshotJobs()).isEqualTo(before); + } + + // ── B. 公开 GET 语义保持不变 ─────────────────────────────────── + + @Test + void anonymousCanListPublishedJobs() throws Exception { + mockMvc.perform(get("/jobs")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.items").isArray()); + } + + @Test + void anonymousCanReadPublicJobDetailIfExistingContractAllows() throws Exception { + Job job = jobRepository.findById(jobId).orElseThrow(); + job.setStatus(JobStatus.PUBLISHED.name()); + jobRepository.save(job); + + mockMvc.perform(get("/jobs/public/" + jobId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void anonymousCanSearchAndFilterJobs() throws Exception { + mockMvc.perform(get("/jobs/search")).andExpect(status().isOk()); + mockMvc.perform(get("/jobs/filters")).andExpect(status().isOk()); + } + + // ── C. 已登录但非企业主体:不得创建岗位 ─────────────────────── + + @Test + void studentCannotCreateJob() throws Exception { + List before = snapshotJobs(); + + // 既有语义:非 COMPANY 角色 → FORBIDDEN(2002) → HTTP 403 + mockMvc.perform(post("/jobs") + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_JOB_BODY) + .header("Authorization", token(studentUserId))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + + assertThat(snapshotJobs()).isEqualTo(before); + } + + @Test + void adminCannotCreateJob() throws Exception { + List before = snapshotJobs(); + + // 既有语义:ADMIN 也不是 COMPANY 角色 → FORBIDDEN(2002) → HTTP 403 + mockMvc.perform(post("/jobs") + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_JOB_BODY) + .header("Authorization", token(adminUserId))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + + assertThat(snapshotJobs()).isEqualTo(before); + } + + // ── D. 企业主体:可创建;他企业不得修改 ──────────────────────── + + @Test + void companyCanCreateJob() throws Exception { + List before = snapshotJobs(); + + mockMvc.perform(post("/jobs") + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_JOB_BODY) + .header("Authorization", token(companyUserId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value(JobStatus.DRAFT.name())); + + List after = snapshotJobs(); + assertThat(after).hasSize(before.size() + 1); + assertThat(jobRepository.findAll()) + .anySatisfy(j -> { + assertThat(j.getTitle()).isEqualTo("安全边界测试岗位"); + assertThat(j.getCompanyId()).isEqualTo(companyOf(companyUserId)); + }); + } + + @Test + void otherCompanyCannotModifyOwnedJob() throws Exception { + List before = snapshotJobs(); + + // 既有语义:归属校验失败为 JOB_OWNER_MISMATCH(7002) → HTTP 400(本轮不改变错误码) + mockMvc.perform(put("/jobs/" + jobId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"他企业篡改\"}") + .header("Authorization", token(otherCompanyUserId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.JOB_OWNER_MISMATCH.getCode())); + + assertThat(snapshotJobs()).isEqualTo(before); + } + + @Test + void otherCompanyCannotPublishOwnedJob() throws Exception { + List before = snapshotJobs(); + + mockMvc.perform(patch("/jobs/" + jobId + "/status") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"PUBLISHED\"}") + .header("Authorization", token(otherCompanyUserId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.JOB_OWNER_MISMATCH.getCode())); + + assertThat(snapshotJobs()).isEqualTo(before); + } + + // ── 辅助 ────────────────────────────────────────────────────── + + private List snapshotJobs() { + return jobRepository.findAll().stream() + .sorted(Comparator.comparing(Job::getId)) + .map(j -> String.join("|", + String.valueOf(j.getId()), String.valueOf(j.getCompanyId()), + String.valueOf(j.getTitle()), String.valueOf(j.getDescription()), + String.valueOf(j.getCity()), String.valueOf(j.getEmploymentType()), + String.valueOf(j.getStatus()), + String.valueOf(j.getSalaryMin()), String.valueOf(j.getSalaryMax()), + String.valueOf(j.getPublishedAt()), String.valueOf(j.getUpdatedAt()))) + .toList(); + } + + private String capturedLogs() { + StringBuilder sb = new StringBuilder(); + for (ILoggingEvent event : appender.list) { + sb.append(event.getLevel()).append(' ').append(event.getFormattedMessage()).append('\n'); + if (event.getThrowableProxy() != null) { + sb.append(event.getThrowableProxy().getMessage()).append('\n'); + } + } + return sb.toString(); + } + + private String token(Long userId) { + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); + } + + private String signedTokenWithSubject(String subject) { + SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); + long now = System.currentTimeMillis(); + return Jwts.builder() + .subject(subject) + .issuedAt(new Date(now)) + .expiration(new Date(now + 3_600_000L)) + .signWith(key) + .compact(); + } + + private Long createUser(User.Role role) { + return userRepository.save(User.builder() + .phone("17" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("边界用户") + .role(role) + // 角色集合为 @ElementCollection,业务角色校验读取的是 roles 而非 role + .roles(new java.util.HashSet<>(java.util.Set.of(role))) + .active(true) + .verified(true) + .build()).getId(); + } + + private Long createCompany(Long ownerUserId, String name) { + return companyRepository.save(Company.builder() + .ownerUserId(ownerUserId) + .name(name) + .build()).getId(); + } + + private Long companyOf(Long ownerUserId) { + return companyRepository.findByOwnerUserId(ownerUserId).orElseThrow().getId(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtAuthenticationTypeIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtAuthenticationTypeIntegrationTest.java new file mode 100644 index 0000000..e0a41fa --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtAuthenticationTypeIntegrationTest.java @@ -0,0 +1,270 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.job.JobStatus; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * S02 认证过滤器的类型隔离与失败关闭(设计 D01/D14,任务书 §五 与 §十三)。 + * + *

    真实 SecurityFilterChain + 真实 H2。核心断言: + * Refresh Token 不能访问普通业务接口,Access Token 不能用于刷新, + * 无 type / 未知 type / 非法 claim 一律 401,且响应不泄漏内部信息。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class JwtAuthenticationTypeIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9200); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private CompanyRepository companyRepository; + + @Autowired + private JobRepository jobRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @org.springframework.beans.factory.annotation.Value("${app.jwt.access-secret}") + private String accessSecret; + + @org.springframework.beans.factory.annotation.Value("${app.jwt.refresh-secret}") + private String refreshSecret; + + private Long userId; + private Long jobId; + private Long companyId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + userId = userRepository.save(User.builder() + .phone("18" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("S02 类型隔离") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + + companyId = companyRepository.save(Company.builder() + .ownerUserId(userId).name("S02 公司").build()).getId(); + jobId = jobRepository.save(Job.builder() + .companyId(companyId).title("S02 岗位").description("d").city("杭州") + .employmentType("FULL_TIME").status(JobStatus.PUBLISHED.name()).build()).getId(); + } + + @AfterEach + void tearDown() { + jobRepository.deleteAll(jobRepository.findAll().stream() + .filter(j -> companyId != null && companyId.equals(j.getCompanyId())).toList()); + if (companyId != null) { + companyRepository.deleteById(companyId); + } + if (userId != null) { + userRepository.deleteById(userId); + } + } + + private AuthSessionService.SessionTokens newSession() { + User user = userRepository.findById(userId).orElseThrow(); + return authSessionService.createSession(user, Instant.now(), null); + } + + // ── 核心缺陷:Refresh Token 访问普通接口 ───────────────────── + + @Test + void refreshTokenCannotAuthenticateOrdinaryEndpoint() throws Exception { + AuthSessionService.SessionTokens tokens = newSession(); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + tokens.refreshToken())) + .andExpect(status().isUnauthorized()) + .andExpect(content().string(not(containsString("Bearer")))) + .andExpect(content().string(not(containsString("com.mydreamjob")))); + } + + @Test + void accessTokenCanAuthenticateOrdinaryEndpoint() throws Exception { + AuthSessionService.SessionTokens tokens = newSession(); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + tokens.accessToken())) + .andExpect(status().isOk()); + } + + // ── Access 不能用于刷新 ───────────────────────────────────── + + @Test + void accessTokenCannotBeUsedAtRefreshEndpoint() throws Exception { + AuthSessionService.SessionTokens tokens = newSession(); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"refreshToken\":\"" + tokens.accessToken() + "\"}")) + .andExpect(status().isUnauthorized()); + } + + // ── 无 type / 未知 type / 非法 claim ──────────────────────── + + @Test + void legacyTokenWithoutTypeIsRejected() throws Exception { + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + legacyToken())) + .andExpect(status().isUnauthorized()); + } + + @Test + void unknownTypeIsRejected() throws Exception { + String token = Jwts.builder() + .subject(String.valueOf(userId)) + .id(UUID.randomUUID().toString()) + .claim("type", "ACCESS") + .claim("sid", UUID.randomUUID().toString()) + .claim("role", "STUDENT") + .issuer("mydreamjob") + .audience().add("mydreamjob-api").and() + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + 3_600_000L)) + .signWith(key(accessSecret)) + .compact(); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()); + } + + @Test + void nonPositiveSubjectIsRejectedWithout500() throws Exception { + String token = Jwts.builder() + .subject("-1") + .id(UUID.randomUUID().toString()) + .claim("type", "access") + .claim("sid", UUID.randomUUID().toString()) + .claim("role", "STUDENT") + .issuer("mydreamjob") + .audience().add("mydreamjob-api").and() + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + 3_600_000L)) + .signWith(key(accessSecret)) + .compact(); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()) + .andExpect(content().string(not(containsString("NumberFormatException")))) + .andExpect(content().string(not(containsString("Exception")))); + } + + @Test + void malformedTokenIsRejectedWithoutLeakingInternals() throws Exception { + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer not.a.jwt")) + .andExpect(status().isUnauthorized()) + .andExpect(content().string(not(containsString("io.jsonwebtoken")))) + .andExpect(content().string(not(containsString("com.mydreamjob")))); + } + + // ── 撤销与禁用 ────────────────────────────────────────────── + + @Test + void revokedSessionCannotAuthenticate() throws Exception { + AuthSessionService.SessionTokens tokens = newSession(); + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + tokens.accessToken())) + .andExpect(status().isOk()); + + authSessionService.logout(userId, tokens.sessionId(), Instant.now(), null); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + tokens.accessToken())) + .andExpect(status().isUnauthorized()); + } + + @Test + void inactiveUserCannotAuthenticate() throws Exception { + AuthSessionService.SessionTokens tokens = newSession(); + + User user = userRepository.findById(userId).orElseThrow(); + user.setActive(false); + userRepository.saveAndFlush(user); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + tokens.accessToken())) + .andExpect(status().isUnauthorized()); + } + + @Test + void tokenForUnknownUserIsRejected() throws Exception { + // 签名合法、claim 齐备,但 sub 指向不存在的用户:服务端会话校验必须失败关闭。 + long unknownUserId = 999_999_999L; + String token = jwtTokenProvider.generateAccessToken( + unknownUserId, "STUDENT", UUID.randomUUID().toString(), UUID.randomUUID().toString(), Instant.now()) + .token(); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()); + } + + // ── 工具 ──────────────────────────────────────────────────── + + private static SecretKey key(String secret) { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + /** 迁移前签发的旧格式 Token:无 type、无 sid、无 jti、无 iss/aud。 */ + private String legacyToken() { + return Jwts.builder() + .subject(String.valueOf(userId)) + .claim("phone", "13800000000") + .claim("role", "STUDENT") + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + 3_600_000L)) + .signWith(key(accessSecret)) + .compact(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtSecretOptionalStartupIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtSecretOptionalStartupIntegrationTest.java new file mode 100644 index 0000000..0d0a337 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtSecretOptionalStartupIntegrationTest.java @@ -0,0 +1,88 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.config.AppJwtProperties; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationContext; +import org.springframework.data.redis.core.RedisTemplate; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * S02 复核项:只配置三个新密钥、**不配置旧 JWT_SECRET** 时应用仍可启动(任务书 §五)。 + * + *

    旧 {@code app.jwt.secret} 对 S02 的 Token 已无任何作用,但仍被 + * {@code talentdiscovery.CandidateKeyGenerator} 使用(该组件在密钥为空时自行失败关闭), + * 因此保留属性但改为非强制解析。本测试证明: + * 1) 旧密钥为空时全量上下文仍能启动;2) 三项 S02 密钥正确绑定; + * 3) {@code application.yml} 中的旧密钥占位符带默认值,不再阻塞启动。

    + */ +@SpringBootTest(properties = { + "app.jwt.secret=", + "app.jwt.access-secret=test-access-secret-key-for-integration-tests-only-not-for-production", + "app.jwt.refresh-secret=test-refresh-secret-key-for-integration-tests-only-not-for-production", + "app.jwt.token-hash-secret=test-token-hash-secret-key-for-integration-tests-only-not-for-production", +}) +@org.springframework.test.context.ActiveProfiles("integration-test") +class JwtSecretOptionalStartupIntegrationTest { + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private AppJwtProperties jwtProperties; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private AuthSessionService authSessionService; + + @Test + void contextStartsWithOnlyTheThreeNewSecrets() { + assertThat(applicationContext).isNotNull(); + assertThat(jwtTokenProvider).isNotNull(); + assertThat(authSessionService).isNotNull(); + + assertThat(jwtProperties.getSecret()) + .as("旧密钥为空也必须能启动").isNullOrEmpty(); + assertThat(jwtProperties.getAccessSecret()).isNotBlank(); + assertThat(jwtProperties.getRefreshSecret()).isNotBlank(); + assertThat(jwtProperties.getTokenHashSecret()).isNotBlank(); + assertThat(jwtProperties.getAccessSecret()) + .isNotEqualTo(jwtProperties.getRefreshSecret()); + } + + @Test + void accessAndRefreshNeverFallBackToLegacySecret() throws Exception { + // 旧密钥为空时,S02 的两类 Token 仍必须能正常签发与校验 —— 证明没有回退到它。 + Instant now = Instant.now(); + String sid = java.util.UUID.randomUUID().toString(); + IssuedToken access = jwtTokenProvider.generateAccessToken( + 1L, "STUDENT", sid, java.util.UUID.randomUUID().toString(), now); + IssuedToken refresh = jwtTokenProvider.generateRefreshToken( + 1L, sid, java.util.UUID.randomUUID().toString(), now); + + assertThat(jwtTokenProvider.parseAndValidateAccessToken(access.token(), now).userId()).isEqualTo(1L); + assertThat(jwtTokenProvider.parseAndValidateRefreshToken(refresh.token(), now).userId()).isEqualTo(1L); + } + + @Test + void applicationYmlNoLongerForcesLegacySecret() throws Exception { + Path yml = Path.of("src/main/resources/application.yml"); + String content = Files.readString(yml); + assertThat(content) + .as("旧密钥占位符必须带默认值,否则未配置 JWT_SECRET 的环境无法启动") + .contains("secret: ${JWT_SECRET:}"); + assertThat(content).doesNotContain("secret: ${JWT_SECRET}\n"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenProviderTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenProviderTest.java index 027fd71..8206512 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenProviderTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenProviderTest.java @@ -1,54 +1,85 @@ package com.mydreamjob.auth; -import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; +import com.mydreamjob.config.AppJwtProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.nio.charset.StandardCharsets; -import java.util.Map; +import java.time.Instant; +import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; +/** + * S02 {@link JwtTokenProvider} 基础行为。 + * + *

    原用例(生成、校验、过期、非法 Token)在 S02 新 API 下重写, + * 断言语义保持不变:合法 Token 可解析出用户、过期与非法 Token 一律拒绝。

    + * + *

    Access/Refresh 的类型与密钥隔离矩阵见 {@code JwtTokenTypeIsolationTest}。

    + */ class JwtTokenProviderTest { - private JwtTokenProvider jwtTokenProvider; + private static final Instant NOW = Instant.parse("2026-09-14T02:00:00Z"); + + private JwtTokenProvider provider; @BeforeEach void setUp() { - String secret = "test-secret-key-for-jwt-testing-at-least-256-bits-required-for-security"; - jwtTokenProvider = new JwtTokenProvider(secret, 3600000L, 86400000L); + provider = new JwtTokenProvider(properties(3_600_000L, 86_400_000L)); + } + + static AppJwtProperties properties(long accessMs, long refreshMs) { + AppJwtProperties p = new AppJwtProperties(); + p.setAccessSecret("test-access-secret-key-for-unit-tests-only-not-for-production"); + p.setRefreshSecret("test-refresh-secret-key-for-unit-tests-only-not-for-production"); + p.setTokenHashSecret("test-token-hash-secret-key-for-unit-tests-only-not-for-production"); + p.setExpirationMs(accessMs); + p.setRefreshExpirationMs(refreshMs); + p.setIssuer("mydreamjob"); + p.setAccessAudience("mydreamjob-api"); + p.setRefreshAudience("mydreamjob-auth"); + return p; + } + + static String uuid() { + return UUID.randomUUID().toString(); } @Test void testTokenGenerationAndValidation() { - String token = jwtTokenProvider.generateAccessToken(1L, "13800000001", Map.of("role", "STUDENT")); - assertNotNull(token); - assertTrue(jwtTokenProvider.validateToken(token)); - assertEquals(1L, jwtTokenProvider.parseUserId(token)); + IssuedToken issued = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + + assertNotNull(issued.token()); + AccessTokenClaims claims = provider.parseAndValidateAccessToken(issued.token(), NOW); + assertEquals(1L, claims.userId()); + assertEquals("STUDENT", claims.role()); + assertEquals(issued.sessionId(), claims.sessionId()); + assertEquals(issued.tokenId(), claims.tokenId()); } @Test void testRefreshToken() { - String token = jwtTokenProvider.generateRefreshToken(2L, "13800000002"); - assertNotNull(token); - assertTrue(jwtTokenProvider.validateToken(token)); - assertEquals(2L, jwtTokenProvider.parseUserId(token)); + IssuedToken issued = provider.generateRefreshToken(2L, uuid(), uuid(), NOW); + + assertNotNull(issued.token()); + RefreshTokenClaims claims = provider.parseAndValidateRefreshToken(issued.token(), NOW); + assertEquals(2L, claims.userId()); + assertEquals(issued.tokenId(), claims.tokenId()); } @Test void testExpiredToken() { - JwtTokenProvider shortLived = new JwtTokenProvider( - "test-secret-key-for-jwt-testing-at-least-256-bits-required-for-security", 1L, 86400000L); - try { - Thread.sleep(10); - } catch (InterruptedException ignored) {} - String token = shortLived.generateAccessToken(1L, "13800000001", null); - assertTrue(shortLived.isTokenExpired(token)); + // exp = NOW + 1ms;在 NOW + 2ms 校验即过期(边界为 exp <= capturedNow)。 + JwtTokenProvider shortLived = new JwtTokenProvider(properties(1L, 86_400_000L)); + IssuedToken issued = shortLived.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + + assertThrows(InvalidTokenException.class, () -> + shortLived.parseAndValidateAccessToken(issued.token(), NOW.plusMillis(2))); } @Test void testInvalidToken() { - assertFalse(jwtTokenProvider.validateToken("invalid-token")); + assertThrows(InvalidTokenException.class, () -> + provider.parseAndValidateAccessToken("invalid-token", NOW)); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenTypeIsolationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenTypeIsolationTest.java new file mode 100644 index 0000000..621371f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenTypeIsolationTest.java @@ -0,0 +1,260 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.config.AppJwtProperties; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * S02 Token 类型与密钥隔离(设计 D01 / D02 / D06,任务书 §五 与 §十九)。 + * + *

    覆盖:类型 claim、独立签名密钥、issuer/audience、subject/sid/jti 校验、 + * 过期边界,以及新 Token 不再包含手机号。

    + */ +class JwtTokenTypeIsolationTest { + + private static final String ACCESS_SECRET = "test-access-secret-key-for-unit-tests-only-not-for-production"; + private static final String REFRESH_SECRET = "test-refresh-secret-key-for-unit-tests-only-not-for-production"; + private static final Instant NOW = Instant.parse("2026-09-14T02:00:00Z"); + + private JwtTokenProvider provider; + + @BeforeEach + void setUp() { + provider = new JwtTokenProvider(JwtTokenProviderTest.properties(3_600_000L, 86_400_000L)); + } + + private static String uuid() { + return UUID.randomUUID().toString(); + } + + // ── 类型隔离 ──────────────────────────────────────────────── + + @Test + void accessTokenCanOnlyBeParsedAsAccess() { + IssuedToken access = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + + AccessTokenClaims claims = provider.parseAndValidateAccessToken(access.token(), NOW); + assertEquals(1L, claims.userId()); + // accessTokenCannotBeUsedAtRefreshEndpoint(Provider 层) + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateRefreshToken(access.token(), NOW)); + } + + @Test + void refreshTokenCanOnlyBeParsedAsRefresh() { + IssuedToken refresh = provider.generateRefreshToken(1L, uuid(), uuid(), NOW); + + RefreshTokenClaims claims = provider.parseAndValidateRefreshToken(refresh.token(), NOW); + assertEquals(1L, claims.userId()); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(refresh.token(), NOW)); + } + + @Test + void accessAndRefreshTokensCannotCrossVerify() { + // 即使忽略 type,两类 Token 也因使用不同密钥而无法交叉验签(第二道隔离)。 + IssuedToken access = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + IssuedToken refresh = provider.generateRefreshToken(1L, uuid(), uuid(), NOW); + + String strippedAccess = resign(access.token(), ACCESS_SECRET, b -> b.claim("type", "refresh")); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateRefreshToken(strippedAccess, NOW)); + + String strippedRefresh = resign(refresh.token(), REFRESH_SECRET, b -> b.claim("type", "access")); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(strippedRefresh, NOW)); + } + + // ── 旧 Token / 未知 type ──────────────────────────────────── + + @Test + void legacyTokenWithoutTypeIsRejected() { + String legacy = Jwts.builder() + .subject("1") + .id(uuid()) + .claim("sid", uuid()) + .claim("role", "STUDENT") + .issuedAt(Date.from(NOW)) + .expiration(Date.from(NOW.plusSeconds(3600))) + .signWith(secretKey(ACCESS_SECRET)) + .compact(); + + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(legacy, NOW)); + } + + @Test + void unknownTypeIsRejected() { + String unknown = Jwts.builder() + .subject("1") + .id(uuid()) + .claim("type", "ACCESS") // 大小写不匹配,必须区分大小写 + .claim("sid", uuid()) + .claim("role", "STUDENT") + .issuedAt(Date.from(NOW)) + .expiration(Date.from(NOW.plusSeconds(3600))) + .signWith(secretKey(ACCESS_SECRET)) + .compact(); + + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(unknown, NOW)); + } + + // ── issuer / audience ─────────────────────────────────────── + + @Test + void wrongIssuerIsRejected() { + IssuedToken access = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + String wrongIssuer = resign(access.token(), ACCESS_SECRET, b -> b.issuer("somebody-else")); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(wrongIssuer, NOW)); + } + + @Test + void wrongAudienceIsRejected() { + IssuedToken access = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + String wrongAudience = resign(access.token(), ACCESS_SECRET, + b -> b.audience().clear().add("wrong-audience").and()); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(wrongAudience, NOW)); + } + + // ── claim 合法性 ──────────────────────────────────────────── + + @Test + void nonPositiveOrNonNumericSubjectIsRejected() { + for (String subject : new String[]{"0", "-5", "abc", ""}) { + String token = baseAccess(subject, uuid(), uuid()); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(token, NOW), + "subject 非法必须拒绝: " + subject); + } + } + + @Test + void nonUuidSidOrJtiIsRejected() { + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(baseAccess("1", "not-a-uuid", uuid()), NOW)); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(baseAccess("1", uuid(), "not-a-uuid"), NOW)); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(baseAccess("1", null, uuid()), NOW)); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(baseAccess("1", uuid(), null), NOW)); + } + + @Test + void accessTokenWithoutRoleIsRejected() { + String token = Jwts.builder() + .subject("1") + .id(uuid()) + .claim("type", "access") + .claim("sid", uuid()) + .issuer("mydreamjob") + .audience().add("mydreamjob-api").and() + .issuedAt(Date.from(NOW)) + .expiration(Date.from(NOW.plusSeconds(3600))) + .signWith(secretKey(ACCESS_SECRET)) + .compact(); + + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(token, NOW)); + } + + // ── 过期边界 ──────────────────────────────────────────────── + + @Test + void expiryBoundaryIsExpLessOrEqualCapturedNow() { + JwtTokenProvider p = new JwtTokenProvider(JwtTokenProviderTest.properties(60_000L, 60_000L)); + IssuedToken access = p.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + Instant expiresAt = access.expiresAt(); + + // 边界前一刻仍有效 + assertEquals(1L, p.parseAndValidateAccessToken(access.token(), expiresAt.minusMillis(1)).userId()); + // 恰好到期即视为过期 + assertThrows(InvalidTokenException.class, + () -> p.parseAndValidateAccessToken(access.token(), expiresAt)); + assertThrows(InvalidTokenException.class, + () -> p.parseAndValidateAccessToken(access.token(), expiresAt.plusMillis(1))); + } + + // ── 不含手机号 ────────────────────────────────────────────── + + @Test + void newTokensDoNotContainPhone() { + IssuedToken access = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + IssuedToken refresh = provider.generateRefreshToken(1L, uuid(), uuid(), NOW); + + for (var entry : java.util.Map.of(access.token(), ACCESS_SECRET, refresh.token(), REFRESH_SECRET).entrySet()) { + var payload = rawParse(entry.getKey(), entry.getValue()); + assertFalse(payload.containsKey("phone"), "S02 新 Token 不得包含手机号"); + assertFalse(payload.containsKey("email")); + assertFalse(payload.containsKey("nickname")); + assertFalse(payload.containsKey("resumeUrl")); + } + } + + // ── 工具 ──────────────────────────────────────────────────── + + private static SecretKey secretKey(String secret) { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + /** 以合法 access token 的 claim 为底,替换 subject/sid/jti 后重新签名。 */ + private static String baseAccess(String subject, String sid, String jti) { + var b = Jwts.builder() + .claim("type", "access") + .claim("role", "STUDENT") + .issuer("mydreamjob") + .audience().add("mydreamjob-api").and() + .issuedAt(Date.from(NOW)) + .expiration(Date.from(NOW.plusSeconds(3600))); + if (subject != null) { + b.subject(subject); + } + if (sid != null) { + b.claim("sid", sid); + } + if (jti != null) { + b.id(jti); + } + return b.signWith(secretKey(ACCESS_SECRET)).compact(); + } + + /** 复制一个已签名 token 的 claim 并改写后重新签名(模拟攻击者无密钥伪造的等价物)。 */ + private static String resign(String token, String secret, + java.util.function.UnaryOperator mutate) { + var claims = rawParse(token, secret); + var builder = Jwts.builder() + .subject(claims.getSubject()) + .id(claims.getId()) + .issuedAt(claims.getIssuedAt()) + .expiration(claims.getExpiration()); + for (var e : claims.entrySet()) { + builder.claim(e.getKey(), e.getValue()); + } + return mutate.apply(builder).signWith(secretKey(secret)).compact(); + } + + /** + * 原始解析:仅用于测试构造与断言,时钟固定在很早的时间以免被真实时间判定过期。 + */ + private static io.jsonwebtoken.Claims rawParse(String token, String secret) { + return Jwts.parser() + .verifyWith(secretKey(secret)) + .clock(() -> Date.from(Instant.EPOCH)) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/LegacySeedAccountDisableMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/LegacySeedAccountDisableMigrationTest.java new file mode 100644 index 0000000..93029ec --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/LegacySeedAccountDisableMigrationTest.java @@ -0,0 +1,223 @@ +package com.mydreamjob.auth; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * S03-A {@code V43__disable_legacy_seed_accounts.sql} 的行为验证。 + * + *

    直接读取并执行迁移文件中的语句,验证:完整指纹命中、任一单字段偏差不命中、 + * 非固定 ID 也能命中、重复执行幂等、关联数据保留、迁移文件本身的约束。

    + * + *

    V1_1 的种子账号仍占用原始手机号(唯一约束),因此用例开始前把它们移到哨兵指纹, + * 结束时原样恢复;用例自身插入的行按昵称前缀清理。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class LegacySeedAccountDisableMigrationTest { + + private static final String HASH = "$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi"; + private static final String TAG = "V43TEST_"; + private static final List SEED_PHONES = List.of("13800000001", "13800000002", "13800000003"); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private String migrationSql; + private final List> movedSeedRows = new ArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + ClassPathResource res = new ClassPathResource("db/migration/V43__disable_legacy_seed_accounts.sql"); + migrationSql = new String(res.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + + cleanupOwnRows(); + moveSeedRowsAside(); + } + + @AfterEach + void tearDown() { + cleanupOwnRows(); + restoreSeedRows(); + } + + private String seedPhoneList() { + return SEED_PHONES.stream().map(p -> "'" + p + "'").reduce((a, b) -> a + "," + b).orElse("''"); + } + + /** 把 V1_1 种子行移到哨兵指纹,腾出原始手机号与邮箱。 */ + private void moveSeedRowsAside() { + movedSeedRows.clear(); + movedSeedRows.addAll(jdbcTemplate.queryForList( + "SELECT id, phone, email FROM users WHERE phone IN (" + seedPhoneList() + ")")); + jdbcTemplate.update( + "UPDATE users SET phone = CONCAT('SEEDMVD', id), email = CONCAT('seedmvd', id, '@example.invalid') " + + "WHERE phone IN (" + seedPhoneList() + ")"); + } + + /** 把种子行恢复为最初的位置(不改动它们的 is_active 等状态)。 */ + private void restoreSeedRows() { + for (Map row : movedSeedRows) { + jdbcTemplate.update("UPDATE users SET phone = ?, email = ? WHERE id = ?", + row.get("phone"), row.get("email"), row.get("id")); + } + movedSeedRows.clear(); + } + + private void cleanupOwnRows() { + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE nickname LIKE ?)", TAG + "%"); + jdbcTemplate.update("DELETE FROM users WHERE nickname LIKE ?", TAG + "%"); + } + + /** + * 执行迁移文件中的全部 UPDATE 语句。 + * + *

    必须先逐行剥离 {@code --} 注释,否则首个语句块会以注释开头而被整体跳过 + * (那样"不命中"类断言会假通过)。

    + */ + private void runMigration() { + StringBuilder stripped = new StringBuilder(); + for (String line : migrationSql.split("\n")) { + if (line.trim().startsWith("--")) { + continue; + } + stripped.append(line).append('\n'); + } + for (String stmt : stripped.toString().split(";")) { + String s = stmt.trim(); + if (s.isEmpty()) { + continue; + } + jdbcTemplate.update(s); + } + } + + private long insertUser(String phone, String email, String hash, String role, int active, String nickname) { + jdbcTemplate.update( + "INSERT INTO users (phone, email, nickname, password_hash, role, is_active, is_verified) " + + "VALUES (?, ?, ?, ?, ?, ?, 1)", + phone, email, nickname, hash, role, active); + Long id = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone); + jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, role); + return id; + } + + private int activeOf(long id) { + Integer v = jdbcTemplate.queryForObject("SELECT is_active FROM users WHERE id = ?", Integer.class, id); + return v == null ? -1 : v; + } + + // ── 完整指纹命中 ──────────────────────────────────────────── + + @Test + void fullFingerprintMatchIsDisabled() { + long admin = insertUser("13800000001", "admin@mydreamjob.com", HASH, "ADMIN", 1, TAG + "admin"); + long student = insertUser("13800000002", "student@mydreamjob.com", HASH, "STUDENT", 1, TAG + "student"); + long company = insertUser("13800000003", "company@mydreamjob.com", HASH, "COMPANY", 1, TAG + "company"); + + runMigration(); + + assertThat(activeOf(admin)).as("ADMIN 原始指纹应被禁用").isZero(); + assertThat(activeOf(student)).as("STUDENT 原始指纹应被禁用").isZero(); + assertThat(activeOf(company)).as("COMPANY 原始指纹应被禁用").isZero(); + } + + @Test + void hitsRegardlessOfNumericId() { + long id = insertUser("13800000001", "admin@mydreamjob.com", HASH, "ADMIN", 1, TAG + "nonfixed"); + assertThat(id).as("用例应使用 1/2/3 之外的 id").isNotIn(1L, 2L, 3L); + + runMigration(); + assertThat(activeOf(id)).isZero(); + } + + // ── 任一单字段偏差不命中 ──────────────────────────────────── + + @Test + void singleFieldDeviationIsNotDisabled() { + long phoneChanged = insertUser("13800000099", "admin@mydreamjob.com", HASH, "ADMIN", 1, TAG + "phone"); + long emailChanged = insertUser("13800000001", "ops@mydreamjob.com", HASH, "ADMIN", 1, TAG + "email"); + long hashChanged = insertUser("13800000002", "student@mydreamjob.com", "$2a$10$DIFFERENTHASHVALUE", "STUDENT", 1, TAG + "hash"); + long roleChanged = insertUser("13800000003", "company@mydreamjob.com", HASH, "STUDENT", 1, TAG + "role"); + + runMigration(); + + assertThat(activeOf(phoneChanged)).as("改过手机号不应被禁用").isEqualTo(1); + assertThat(activeOf(emailChanged)).as("改过邮箱不应被禁用").isEqualTo(1); + assertThat(activeOf(hashChanged)).as("改过口令哈希不应被禁用").isEqualTo(1); + assertThat(activeOf(roleChanged)).as("改过角色不应被禁用").isEqualTo(1); + } + + @Test + void alreadyInactiveAccountIsNotMatched() { + long id = insertUser("13800000001", "admin@mydreamjob.com", HASH, "ADMIN", 0, TAG + "inactive"); + runMigration(); + assertThat(activeOf(id)).as("条件含 is_active = 1,已禁用行不再命中").isZero(); + } + + // ── 重复执行 ──────────────────────────────────────────────── + + @Test + void repeatedExecutionIsStable() { + long id = insertUser("13800000001", "admin@mydreamjob.com", HASH, "ADMIN", 1, TAG + "repeat"); + runMigration(); + assertThat(activeOf(id)).isZero(); + + runMigration(); + runMigration(); + + assertThat(activeOf(id)).as("重复执行后仍为 disabled").isZero(); + Integer rows = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone = '13800000001' AND nickname = ?", Integer.class, TAG + "repeat"); + assertThat(rows).as("不得产生或删除记录").isEqualTo(1); + } + + // ── 关联数据保留 ──────────────────────────────────────────── + + @Test + void relatedRowsArePreserved() { + long id = insertUser("13800000003", "company@mydreamjob.com", HASH, "COMPANY", 1, TAG + "assoc"); + + runMigration(); + + Integer stillThere = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, id); + assertThat(stillThere).as("迁移不得删除用户记录").isEqualTo(1); + List> roles = jdbcTemplate.queryForList( + "SELECT role FROM user_roles WHERE user_id = ?", id); + assertThat(roles).as("迁移不得删除 user_roles 记录").hasSize(1); + assertThat(roles.get(0).get("role")).isEqualTo("COMPANY"); + } + + // ── 迁移文件本身的约束 ────────────────────────────────────── + + @Test + void migrationFileOnlyUpdatesIsActiveWithFiveFingerprints() { + assertThat(migrationSql).as("只允许 UPDATE users").doesNotContainIgnoringCase("DELETE FROM"); + assertThat(migrationSql).doesNotContainIgnoringCase("DROP "); + assertThat(migrationSql).doesNotContainIgnoringCase("TRUNCATE"); + for (String clause : new String[]{"phone =", "email =", "password_hash =", "role =", "is_active = 1"}) { + assertThat(migrationSql).as("匹配条件必须包含 " + clause).contains(clause); + } + int updates = migrationSql.split("(?i)UPDATE users").length - 1; + assertThat(updates).as("ADMIN/STUDENT/COMPANY 三条").isEqualTo(3); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02InactiveUserSessionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02InactiveUserSessionTest.java new file mode 100644 index 0000000..4f5ba42 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02InactiveUserSessionTest.java @@ -0,0 +1,121 @@ +package com.mydreamjob.auth; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * S02 复核项:不可用(active=false)用户不得获得会话或 Token(任务书 §五)。 + * + *

    要求**失败关闭**:不能先签发 Token 再依赖认证过滤器拒绝。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class S02InactiveUserSessionTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9300); + + @MockBean + private RedisTemplate redisTemplate; + + @MockBean + private SmsVerificationService smsVerificationService; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private AuthService authService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private Long inactiveUserId; + private String phone; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(smsVerificationService.verifyCode(any(), any(), any())).thenReturn(true); + + phone = "11" + String.format("%09d", PHONE_SEQ.incrementAndGet()); + inactiveUserId = userRepository.save(User.builder() + .phone(phone) + .nickname("S02 停用用户") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(false) + .verified(true) + .build()).getId(); + } + + @AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM audit_logs WHERE user_id = ?", inactiveUserId); + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", inactiveUserId); + if (inactiveUserId != null) { + userRepository.deleteById(inactiveUserId); + } + } + + private long tokenRows() { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM auth_tokens WHERE user_id = ?", Long.class, inactiveUserId); + } + + @Test + void inactiveUserCannotCreateSession() { + User inactive = userRepository.findById(inactiveUserId).orElseThrow(); + + assertThatThrownBy(() -> authSessionService.createSession(inactive, Instant.now(), null)) + .as("停用用户不得创建会话") + .isInstanceOf(InvalidTokenException.class); + + assertThat(tokenRows()).as("不得写入任何 auth_tokens 记录").isZero(); + } + + @Test + void inactiveUserLoginDoesNotReturnTokenPair() { + assertThatThrownBy(() -> authService.mockLogin(phone, "123456", "login", "req", null, null)) + .as("停用用户登录必须失败关闭"); + + assertThat(tokenRows()).as("登录失败不得留下 Token 记录").isZero(); + } + + @Test + void inactiveUserLoginDoesNotPersistAuthToken() { + try { + Map result = authService.mockLogin(phone, "123456", "login", "req", null, null); + // 若未来语义变化为返回结果,则绝不允许携带 Token + assertThat(result).doesNotContainKeys("accessToken", "refreshToken"); + } catch (RuntimeException expected) { + // 预期路径:失败关闭 + } + assertThat(tokenRows()).isZero(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentClockAcceptanceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentClockAcceptanceTest.java new file mode 100644 index 0000000..5fc8cfd --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentClockAcceptanceTest.java @@ -0,0 +1,86 @@ +package com.mydreamjob.auth; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** Independent S02 acceptance reproduction for persisted timestamp consistency. */ +@SpringBootTest +@ActiveProfiles("integration-test") +class S02IndependentClockAcceptanceTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9800); + private static final Instant CAPTURED_NOW = Instant.parse("2026-01-02T03:04:05Z"); + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private Long userId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations operations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(operations); + + userId = userRepository.save(User.builder() + .phone("12" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("S02 independent clock") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + } + + @AfterEach + void tearDown() { + if (userId != null) { + jdbcTemplate.update("DELETE FROM audit_logs WHERE user_id = ?", userId); + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", userId); + userRepository.deleteById(userId); + } + } + + @Test + void persistedTokenCreatedAtMustUseCapturedNow() { + AuthSessionService.SessionTokens tokens = authSessionService.createSession( + userRepository.findById(userId).orElseThrow(), CAPTURED_NOW, null); + + Timestamp createdAt = jdbcTemplate.queryForObject( + "SELECT created_at FROM auth_tokens WHERE session_id = ?", + Timestamp.class, + tokens.sessionId()); + + assertThat(createdAt.toLocalDateTime()) + .isEqualTo(LocalDateTime.ofInstant(CAPTURED_NOW, ZONE)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentTokenValidationAcceptanceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentTokenValidationAcceptanceTest.java new file mode 100644 index 0000000..43bd8da --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentTokenValidationAcceptanceTest.java @@ -0,0 +1,65 @@ +package com.mydreamjob.auth; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** Independent S02 acceptance reproductions; not part of the implementation test suite. */ +class S02IndependentTokenValidationAcceptanceTest { + + private static final String ACCESS_SECRET = + "test-access-secret-key-for-unit-tests-only-not-for-production"; + private static final Instant NOW = Instant.parse("2026-09-14T02:00:00Z"); + + private JwtTokenProvider provider; + + @BeforeEach + void setUp() { + provider = new JwtTokenProvider(JwtTokenProviderTest.properties(3_600_000L, 86_400_000L)); + } + + @Test + void accessTokenWithoutIssuedAtMustBeRejected() { + String token = Jwts.builder() + .subject("1") + .id(UUID.randomUUID().toString()) + .claim("type", "access") + .claim("sid", UUID.randomUUID().toString()) + .claim("role", "STUDENT") + .issuer("mydreamjob") + .audience().add("mydreamjob-api").and() + .expiration(Date.from(NOW.plusSeconds(3600))) + .signWith(Keys.hmacShaKeyFor(ACCESS_SECRET.getBytes(StandardCharsets.UTF_8))) + .compact(); + + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(token, NOW)); + } + + @Test + void accessTokenIssuedInFutureMustBeRejected() { + String token = Jwts.builder() + .subject("1") + .id(UUID.randomUUID().toString()) + .claim("type", "access") + .claim("sid", UUID.randomUUID().toString()) + .claim("role", "STUDENT") + .issuer("mydreamjob") + .audience().add("mydreamjob-api").and() + .issuedAt(Date.from(NOW.plusSeconds(60))) + .expiration(Date.from(NOW.plusSeconds(3600))) + .signWith(Keys.hmacShaKeyFor(ACCESS_SECRET.getBytes(StandardCharsets.UTF_8))) + .compact(); + + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(token, NOW)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java new file mode 100644 index 0000000..13db68f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java @@ -0,0 +1,79 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.auth.sms.SmsCodeSender; +import com.mydreamjob.auth.sms.UnavailableSmsCodeSender; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationContext; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * S01 定向整改 F3 核心回归:{@code app.sms.mock.enabled=} 空值下的 + * 全应用上下文启动。 + * + *

    整改前该取值会使两个互斥 {@code @ConditionalOnProperty} 都不匹配 → 0 个 + * {@code SmsCodeSender} Bean → 应用以 + * {@code APPLICATION FAILED TO START ... required a bean of type 'SmsCodeSender'} + * 直接启动失败。

    + * + *

    本测试以真实全量 Spring 上下文(H2 + Flyway)启动,证明:上下文启动成功、 + * {@link SmsVerificationService} 可注入、恰好一个 {@link SmsCodeSender}、 + * 类型为 {@link UnavailableSmsCodeSender},且调用发送时安全失败。

    + */ +@SpringBootTest(properties = {"app.sms.mock.enabled="}) +@ActiveProfiles("integration-test") +class SmsEmptyPropertyStartupIntegrationTest { + + private static final String PHONE = "19900005678"; + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private SmsVerificationService smsVerificationService; + + @BeforeEach + @SuppressWarnings("unchecked") + void stubRedis() { + // 不触发锁定/限流分支,使流程到达发送器调用 + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(redisTemplate.opsForValue()).thenReturn(mock(ValueOperations.class)); + } + + @Test + void emptyPropertyStartsContextWithExactlyOneUnavailableSender() { + assertThat(applicationContext.getBeanNamesForType(SmsCodeSender.class)) + .as("空值必须恰好装配一个 SmsCodeSender") + .hasSize(1); + assertThat(applicationContext.getBean(SmsCodeSender.class)) + .isInstanceOf(UnavailableSmsCodeSender.class); + assertThat(applicationContext) + .as("SmsVerificationService 必须可注入") + .isNotNull(); + assertThat(smsVerificationService).isNotNull(); + } + + @Test + void emptyPropertySenderFailsSafely() { + assertThatThrownBy(() -> smsVerificationService.sendCode(PHONE, "login", null, null, null)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt()) + .isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode())); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSenderConfigurationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSenderConfigurationTest.java new file mode 100644 index 0000000..fade500 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSenderConfigurationTest.java @@ -0,0 +1,230 @@ +package com.mydreamjob.auth; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.mydreamjob.auth.sms.MockSmsCodeSender; +import com.mydreamjob.auth.sms.SmsCodeSender; +import com.mydreamjob.auth.sms.SmsSenderConfiguration; +import com.mydreamjob.auth.sms.UnavailableSmsCodeSender; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * S01 定向整改 F3:{@code app.sms.mock.enabled} 在 + * true / false / 缺失 / 空值 / 仅空白 / 大写 / 非法值下都必须装配 + * 恰好一个 {@link SmsCodeSender},且只有显式 true 才启用 Mock。 + * + *

    全部用例使用真实 {@link ApplicationContextRunner} 装配(不直接 new 配置类、 + * 不测试字符串解析辅助函数、不使用 Mockito 验证)。

    + * + *

    {@link SenderConsumerProbe} 是消费者探针:它以构造参数方式依赖 + * {@link SmsCodeSender},用于复现“构造注入时缺少 Bean”的原始缺陷场景; + * 它不生产 any SmsCodeSender,因此不会掩盖生产 Bean 缺失。

    + */ +class SmsSenderConfigurationTest { + + /** 仅消费、不生产 SmsCodeSender 的探针。 */ + static class SenderConsumerProbe { + final SmsCodeSender sender; + + SenderConsumerProbe(SmsCodeSender sender) { + this.sender = sender; + } + } + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(SmsSenderConfiguration.class, SenderConsumerProbe.class); + + private static final String ACC_PHONE = "19900009999"; + private static final String ACC_CODE = "739251"; + + // ── 7 类取值行为 ──────────────────────────────────────────── + + @Test + void trueUsesMockSender() { + runner.withPropertyValues("app.sms.mock.enabled=true").run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(SmsCodeSender.class); + assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(MockSmsCodeSender.class); + assertThat(context).doesNotHaveBean(UnavailableSmsCodeSender.class); + }); + } + + @Test + void falseUsesUnavailableSender() { + runner.withPropertyValues("app.sms.mock.enabled=false").run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(SmsCodeSender.class); + assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class); + assertThat(context).doesNotHaveBean(MockSmsCodeSender.class); + }); + } + + @Test + void missingPropertyUsesUnavailableSender() { + runner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(SmsCodeSender.class); + assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class); + }); + } + + @Test + void blankPropertyUsesUnavailableSender() { + // F3 核心回归:空值曾导致 0 个 Bean 与模糊 Spring 装配启动失败 + runner.withPropertyValues("app.sms.mock.enabled=").run(context -> { + assertThat(context) + .as("空值必须仍能启动且装配一个可用实现") + .hasNotFailed(); + assertThat(context).hasSingleBean(SmsCodeSender.class); + assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class); + assertThat(context).doesNotHaveBean(MockSmsCodeSender.class); + }); + } + + @Test + void whitespacePropertyUsesUnavailableSender() { + runner.withPropertyValues("app.sms.mock.enabled= ").run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(SmsCodeSender.class); + assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class); + assertThat(context).doesNotHaveBean(MockSmsCodeSender.class); + }); + } + + @Test + void invalidPropertyNeverEnablesMockSender() { + for (String invalid : new String[]{"yes", "1", "on", "enabled", "TRUE1", "mock", "null"}) { + runner.withPropertyValues("app.sms.mock.enabled=" + invalid).run(context -> { + assertThat(context).as("非法值 %s 不应导致启动失败", invalid).hasNotFailed(); + assertThat(context).hasSingleBean(SmsCodeSender.class); + assertThat(context.getBean(SmsCodeSender.class)) + .as("非法值 %s 绝不允许启用 Mock", invalid) + .isInstanceOf(UnavailableSmsCodeSender.class); + assertThat(context).doesNotHaveBean(MockSmsCodeSender.class); + }); + } + } + + @Test + void uppercaseTrueBehaviorIsExplicit() { + // 明确语义:大小写不敏感 + 忽略两侧空白 → TRUE / True / " true " 视为启用 Mock + for (String accepted : new String[]{"TRUE", "True", " true ", "tRuE"}) { + runner.withPropertyValues("app.sms.mock.enabled=" + accepted).run(context -> { + assertThat(context).hasSingleBean(SmsCodeSender.class); + assertThat(context.getBean(SmsCodeSender.class)) + .as("取值 [%s] 应视为显式启用 Mock", accepted) + .isInstanceOf(MockSmsCodeSender.class); + }); + } + } + + @Test + void everySupportedConfigurationCreatesExactlyOneSenderBean() { + String[] values = {"true", "false", "", " ", "TRUE", "yes", "1", "on", "null"}; + for (String value : values) { + runner.withPropertyValues("app.sms.mock.enabled=" + value).run(context -> { + assertThat(context).as("取值 [%s]", value).hasNotFailed(); + assertThat(context.getBeanNamesForType(SmsCodeSender.class)) + .as("取值 [%s] 必须恰好一个 SmsCodeSender Bean", value) + .hasSize(1); + }); + } + // 缺失属性 + runner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeanNamesForType(SmsCodeSender.class)) + .as("缺失属性时必须恰好一个 SmsCodeSender Bean") + .hasSize(1); + }); + } + + // ── 空值场景的完整证据链(消费者注入 + 安全失败 + 无敏感日志)──── + + @Test + void blankPropertyStillAllowsConstructorInjectionIntoConsumer() { + runner.withPropertyValues("app.sms.mock.enabled=").run(context -> { + assertThat(context).hasNotFailed(); + // 复现原始缺陷场景:构造参数注入必须成功(曾因 0 个 Bean 失败) + SenderConsumerProbe probe = context.getBean(SenderConsumerProbe.class); + assertThat(probe.sender).isNotNull().isInstanceOf(UnavailableSmsCodeSender.class); + // 同一实例,证明容器内只有一个 Bean + assertThat(probe.sender).isSameAs(context.getBean(SmsCodeSender.class)); + }); + } + + @Test + void blankPropertySenderFailsSafelyWithoutLeakingOrLogging() { + ListAppender appender = new ListAppender<>(); + appender.start(); + Logger logger = (Logger) LoggerFactory.getLogger("com.mydreamjob.auth.sms"); + logger.addAppender(appender); + logger.setLevel(Level.TRACE); + try { + runner.withPropertyValues("app.sms.mock.enabled=").run(context -> { + SmsCodeSender sender = context.getBean(SmsCodeSender.class); + + assertThatThrownBy(() -> sender.sendVerificationCode(ACC_PHONE, "login", ACC_CODE)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> { + BusinessException be = (BusinessException) e; + assertThat(be.getErrorCodeInt()) + .isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode()); + assertThat(be.getMessage()) + .as("固定安全文本契约") + .isEqualTo("sms sender unavailable") + .doesNotContain(ACC_PHONE) + .doesNotContain(ACC_CODE) + .doesNotContainIgnoringCase("mock") + .doesNotContain("app.sms"); + }); + + String logs = appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .reduce("", (a, b) -> a + '\n' + b); + assertThat(logs) + .doesNotContain(ACC_CODE) + .doesNotContain(ACC_PHONE) + .doesNotContain("app.sms.mock.enabled"); + }); + } finally { + logger.detachAppender(appender); + appender.stop(); + } + } + + @Test + void unavailableSenderDoesNotReturnFalseSuccess() { + runner.withPropertyValues("app.sms.mock.enabled=false").run(context -> { + SmsCodeSender sender = context.getBean(SmsCodeSender.class); + + assertThatThrownBy(() -> sender.sendVerificationCode(ACC_PHONE, "login", ACC_CODE)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> { + BusinessException be = (BusinessException) e; + assertThat(be.getErrorCodeInt()).isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode()); + assertThat(be.getMessage()) + .doesNotContain(ACC_PHONE) + .doesNotContain(ACC_CODE) + .doesNotContainIgnoringCase("mock"); + }); + }); + } + + @Test + void mockSenderLoadsOnlyWhenExplicitlyEnabled() { + runner.withPropertyValues("app.sms.mock.enabled=true").run(context -> { + assertThat(context).hasSingleBean(SmsCodeSender.class); + assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(MockSmsCodeSender.class); + assertThat(context).doesNotHaveBean(UnavailableSmsCodeSender.class); + }); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java new file mode 100644 index 0000000..8f96813 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java @@ -0,0 +1,115 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.audit.AuditLog; +import com.mydreamjob.audit.AuditLogRepository; +import com.mydreamjob.auth.sms.SmsCodeSender; +import com.mydreamjob.auth.sms.UnavailableSmsCodeSender; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationContext; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * S01 定向整改 —— 补充回归:不可用发送实现下的事务回滚。 + * + *

    本测试使用真实 Spring 上下文 + 真实 H2 + Flyway + 真实 + * {@link SmsVerificationService} 与真实 {@code @Transactional} 代理, + * 并在本测试类中把 {@code app.sms.mock.enabled} 覆盖为 {@code false}, + * 因此容器装配的是 {@link UnavailableSmsCodeSender}。

    + * + *

    测试类本身不加 {@code @Transactional},以保证 + * {@code sendCode} 的事务独立提交/回滚,从而真实验证“发送失败不残留可用验证码”。

    + */ +@SpringBootTest(properties = {"app.sms.mock.enabled=false"}) +@ActiveProfiles("integration-test") +class SmsUnavailableSenderRollbackIntegrationTest { + + private static final String PHONE = "19900001234"; + private static final String PURPOSE = "login"; + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private SmsVerificationService smsVerificationService; + + @Autowired + private SmsVerificationCodeRepository codeRepository; + + @Autowired + private AuditLogRepository auditLogRepository; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + } + + @Test + void containerProvidesExactlyOneUnavailableSenderWhenMockDisabled() { + assertThat(applicationContext.getBeanNamesForType(SmsCodeSender.class)) + .as("mock=false 时必须恰好一个 SmsCodeSender") + .hasSize(1); + assertThat(applicationContext.getBean(SmsCodeSender.class)) + .isInstanceOf(UnavailableSmsCodeSender.class); + } + + @Test + void unavailableSenderFailureLeavesNoVerificationCodeRow() { + // 清理本测试手机号的既有数据,保证断言只反映本次调用 + List pre = codeRepository.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE); + if (!pre.isEmpty()) { + codeRepository.deleteAll(pre); + } + long beforeTotal = codeRepository.count(); + LocalDateTime since = LocalDateTime.now().minusMinutes(1); + long auditBefore = auditLogRepository.findByCreatedAtAfter(since).stream() + .filter(a -> "SMS_SEND".equals(a.getAction())) + .count(); + + assertThatThrownBy(() -> smsVerificationService.sendCode(PHONE, PURPOSE, "rollback-test", null, null)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt()) + .isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode())); + + // 事务已回滚:不得残留该手机号的任何验证码行(不只是数量相等) + assertThat(codeRepository.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE)) + .as("发送失败后不得残留可用验证码") + .isEmpty(); + assertThat(codeRepository.count()).isEqualTo(beforeTotal); + + // 失败不得产生“发送成功”审计 + long auditAfter = auditLogRepository.findByCreatedAtAfter(since).stream() + .filter(a -> "SMS_SEND".equals(a.getAction())) + .count(); + assertThat(auditAfter).isEqualTo(auditBefore); + } + + @Test + void unavailableSenderFailureDoesNotReturnFalseSuccess() { + assertThatThrownBy(() -> smsVerificationService.sendCode(PHONE, PURPOSE, null, null, null)) + .isInstanceOf(BusinessException.class) + .isNotInstanceOf(NumberFormatException.class); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java new file mode 100644 index 0000000..bde98a2 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java @@ -0,0 +1,332 @@ +package com.mydreamjob.auth; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.sms.MockSmsCodeSender; +import com.mydreamjob.auth.sms.SmsCodeSender; +import com.mydreamjob.auth.sms.UnavailableSmsCodeSender; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * S01 短信安全:验证码与手机号不得泄露到日志。 + * + *

    本测试不使用 Mockito 校验日志,而是挂载真实 Logback {@link ListAppender} + * 捕获实际日志事件后做字符串断言。

    + * + *

    生产语义不动:长度、有效期、最大尝试次数、锁定时间、一次性消费保持不变。

    + */ +@ExtendWith(MockitoExtension.class) +class SmsVerificationSecurityTest { + + /** 唯一敏感标记:手机号 */ + private static final String SENSITIVE_PHONE = "19900009999"; + private static final String PURPOSE = "login"; + private static final int EXPECTED_CODE_LENGTH = 6; + private static final int EXPECTED_EXPIRE_SECONDS = 300; + private static final int EXPECTED_MAX_ATTEMPTS = 5; + private static final int EXPECTED_LOCKOUT_SECONDS = 300; + + @Mock + private SmsVerificationCodeRepository codeRepository; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private AuditService auditService; + + @Mock + private SmsCodeSender smsCodeSender; + + @InjectMocks + private SmsVerificationService service; + + private Logger rootLogger; + private Logger appLogger; + private ListAppender appender; + private Level originalRootLevel; + private Level originalAppLevel; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "codeLength", EXPECTED_CODE_LENGTH); + ReflectionTestUtils.setField(service, "expireSeconds", EXPECTED_EXPIRE_SECONDS); + ReflectionTestUtils.setField(service, "maxAttempts", EXPECTED_MAX_ATTEMPTS); + ReflectionTestUtils.setField(service, "lockoutSeconds", EXPECTED_LOCKOUT_SECONDS); + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + // ── 挂载真实 Logback 捕获器(root + 应用包,覆盖 INFO/WARN/ERROR/DEBUG)── + rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + appLogger = (Logger) LoggerFactory.getLogger("com.mydreamjob"); + originalRootLevel = rootLogger.getLevel(); + originalAppLevel = appLogger.getLevel(); + rootLogger.setLevel(Level.TRACE); + appLogger.setLevel(Level.TRACE); + appender = new ListAppender<>(); + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void tearDown() { + rootLogger.detachAppender(appender); + appender.stop(); + rootLogger.setLevel(originalRootLevel); + appLogger.setLevel(originalAppLevel); + } + + private void stubSendAllowed() { + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(codeRepository.save(any(SmsVerificationCode.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + private String capturedLogText() { + StringBuilder sb = new StringBuilder(); + for (ILoggingEvent event : appender.list) { + sb.append(event.getLevel()).append(' ') + .append(event.getFormattedMessage()).append('\n'); + if (event.getThrowableProxy() != null) { + sb.append(event.getThrowableProxy().getMessage()).append('\n'); + } + } + return sb.toString(); + } + + // ── 核心:生产语义下不得泄露验证码/手机号 ────────────────────── + + @Test + void productionProfileMustNotLogVerificationCodeOrFullPhone() { + stubSendAllowed(); + ArgumentCaptor captor = ArgumentCaptor.forClass(SmsVerificationCode.class); + + service.sendCode(SENSITIVE_PHONE, PURPOSE, "req-1", "127.0.0.1", "junit"); + + verify(codeRepository).save(captor.capture()); + String generatedCode = captor.getValue().getCode(); + assertThat(generatedCode).hasSize(EXPECTED_CODE_LENGTH); + + String logs = capturedLogText(); + assertThat(logs) + .as("日志不得包含验证码明文") + .doesNotContain(generatedCode); + assertThat(logs) + .as("日志不得包含完整手机号") + .doesNotContain(SENSITIVE_PHONE); + assertThat(logs) + .as("日志不得包含完整验证码的任何形式") + .doesNotContain("code=" + generatedCode); + } + + @Test + void smsLogsNeverContainCodePhoneTokenPasswordOrSecret() { + stubSendAllowed(); + ArgumentCaptor captor = ArgumentCaptor.forClass(SmsVerificationCode.class); + + service.sendCode(SENSITIVE_PHONE, PURPOSE, "req-2", "127.0.0.1", "junit"); + + // 同时触发锁定与限流分支,覆盖 WARN 级日志 + when(redisTemplate.hasKey(eq("sms:lockout:" + SENSITIVE_PHONE))).thenReturn(true); + assertThatThrownBy(() -> service.sendCode(SENSITIVE_PHONE, PURPOSE, "req-3", "127.0.0.1", "junit")) + .isInstanceOf(BusinessException.class); + + verify(codeRepository).save(captor.capture()); + String generatedCode = captor.getValue().getCode(); + + String logs = capturedLogText(); + assertThat(logs) + .doesNotContain(generatedCode) + .doesNotContain(SENSITIVE_PHONE) + .doesNotContainIgnoringCase("password") + .doesNotContainIgnoringCase("secret"); + } + + @Test + void auditDetailsMustNotContainFullPhone() { + stubSendAllowed(); + + service.sendCode(SENSITIVE_PHONE, PURPOSE, "req-4", "127.0.0.1", "junit"); + + ArgumentCaptor details = ArgumentCaptor.forClass(String.class); + verify(auditService).log(any(), eq("SMS_SEND"), eq("PHONE"), any(), details.capture(), + any(), any(), any()); + + assertThat(details.getValue()) + .as("审计明细不得记录完整手机号") + .doesNotContain(SENSITIVE_PHONE); + } + + // ── 生产语义回归:长度/有效期/尝试次数/锁定/一次性消费 ────────── + + @Test + void verificationExpiryRemainsUnchanged() { + stubSendAllowed(); + ArgumentCaptor captor = ArgumentCaptor.forClass(SmsVerificationCode.class); + LocalDateTime before = LocalDateTime.now(); + + service.sendCode(SENSITIVE_PHONE, PURPOSE, null, null, null); + + verify(codeRepository).save(captor.capture()); + SmsVerificationCode saved = captor.getValue(); + assertThat(saved.getCode()).hasSize(EXPECTED_CODE_LENGTH); + assertThat(saved.getExpiredAt()) + .isAfter(before.plusSeconds(EXPECTED_EXPIRE_SECONDS - 5)) + .isBefore(before.plusSeconds(EXPECTED_EXPIRE_SECONDS + 5)); + assertThat(saved.getMaxAttempts()).isEqualTo(EXPECTED_MAX_ATTEMPTS); + assertThat(saved.getUsed()).isFalse(); + assertThat(saved.getAttempts()).isZero(); + } + + @Test + void verificationAttemptLimitRemainsUnchanged() { + SmsVerificationCode stored = code("111111", 0, false); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) + .thenReturn(Stream.of(stored)); + ArgumentCaptor captor = ArgumentCaptor.forClass(SmsVerificationCode.class); + + assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "999999", PURPOSE)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt()) + .isEqualTo(ErrorCode.SMS_CODE_INVALID.getCode())); + + verify(codeRepository).save(captor.capture()); + assertThat(captor.getValue().getAttempts()).isEqualTo(1); + assertThat(stored.getUsed()).isFalse(); + } + + @Test + void verificationLockoutRemainsUnchanged() { + SmsVerificationCode stored = code("111111", EXPECTED_MAX_ATTEMPTS, false); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) + .thenReturn(Stream.of(stored)); + + assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "111111", PURPOSE)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt()) + .isEqualTo(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED.getCode())); + + verify(valueOperations).set("sms:lockout:" + SENSITIVE_PHONE, "1", + (long) EXPECTED_LOCKOUT_SECONDS, TimeUnit.SECONDS); + } + + @Test + void successfulVerificationStillConsumesCodeOnce() { + SmsVerificationCode stored = code("654321", 0, false); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) + .thenReturn(Stream.of(stored)); + + assertThat(service.verifyCode(SENSITIVE_PHONE, "654321", PURPOSE)).isTrue(); + assertThat(stored.getUsed()).isTrue(); + verify(codeRepository).save(stored); + + // 重放:已使用记录不再被查询命中 + when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) + .thenReturn(Stream.empty()); + assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "654321", PURPOSE)) + .isInstanceOf(BusinessException.class); + } + + @Test + void expiredCodeStillRejected() { + SmsVerificationCode expired = SmsVerificationCode.builder() + .phone(SENSITIVE_PHONE).code("222222").purpose(PURPOSE) + .expiredAt(LocalDateTime.now().minus(Duration.ofSeconds(1))) + .attempts(0).maxAttempts(EXPECTED_MAX_ATTEMPTS).used(false) + .createdAt(LocalDateTime.now().minus(Duration.ofMinutes(6))) + .build(); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) + .thenReturn(Stream.of(expired)); + + assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "222222", PURPOSE)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt()) + .isEqualTo(ErrorCode.SMS_CODE_EXPIRED.getCode())); + verify(codeRepository, never()).save(any(SmsVerificationCode.class)); + } + + // ── 发送实现安全边界 ─────────────────────────────────────────── + + @Test + void mockSenderMustNotLogCodeOrPhone() { + ListAppender senderAppender = new ListAppender<>(); + senderAppender.start(); + Logger senderLogger = (Logger) LoggerFactory.getLogger(MockSmsCodeSender.class); + senderLogger.addAppender(senderAppender); + senderLogger.setLevel(Level.TRACE); + try { + new MockSmsCodeSender().sendVerificationCode(SENSITIVE_PHONE, PURPOSE, "739251"); + + String logs = senderAppender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .reduce("", (a, b) -> a + '\n' + b); + assertThat(logs).doesNotContain("739251").doesNotContain(SENSITIVE_PHONE); + } finally { + senderLogger.detachAppender(senderAppender); + senderAppender.stop(); + } + } + + @Test + void unavailableSenderFailsSafelyWithoutLeaking() { + assertThatThrownBy(() -> + new UnavailableSmsCodeSender().sendVerificationCode(SENSITIVE_PHONE, PURPOSE, "739251")) + .isInstanceOf(BusinessException.class) + .satisfies(e -> { + BusinessException be = (BusinessException) e; + assertThat(be.getErrorCodeInt()).isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode()); + assertThat(be.getMessage()) + .doesNotContain(SENSITIVE_PHONE) + .doesNotContain("739251") + .doesNotContainIgnoringCase("mock") + .doesNotContain("app.sms"); + }); + } + + private SmsVerificationCode code(String value, int attempts, boolean used) { + return SmsVerificationCode.builder() + .phone(SENSITIVE_PHONE).code(value).purpose(PURPOSE) + .expiredAt(LocalDateTime.now().plusSeconds(EXPECTED_EXPIRE_SECONDS)) + .attempts(attempts).maxAttempts(EXPECTED_MAX_ATTEMPTS).used(used) + .createdAt(LocalDateTime.now()) + .build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java index d3b689d..2953819 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java @@ -1,6 +1,7 @@ package com.mydreamjob.auth; import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.sms.SmsCodeSender; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import org.junit.jupiter.api.BeforeEach; @@ -35,6 +36,9 @@ class SmsVerificationServiceTest { @Mock private AuditService auditService; + @Mock + private SmsCodeSender smsCodeSender; + @InjectMocks private SmsVerificationService smsVerificationService; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokens.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokens.java new file mode 100644 index 0000000..737791c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokens.java @@ -0,0 +1,189 @@ +package com.mydreamjob.auth.support; + +import com.mydreamjob.auth.AuthTokenHasher; +import com.mydreamjob.auth.IssuedToken; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.config.AppJwtProperties; +import org.springframework.jdbc.core.JdbcTemplate; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 测试专用:签发**可通过 S02 过滤器**的 Access Token。 + * + *

    S02 之后认证过滤器除验签外还要求:{@code type=access}、由 access 密钥签发、 + * 且对应一个真实存在的活动登录会话与可用用户。既有测试大量直接调用 + * {@code jwtTokenProvider.generateAccessToken(userId, phone, claims)}, + * 在新规则下会全部 401;本类让它们只需替换辅助方法体。

    + * + *

    为什么要按数据库登记

    + *

    Spring 会按类缓存测试上下文,而本仓库允许用 {@code @TestPropertySource} 覆盖 + * {@code spring.datasource.url} 隔离各自的 H2 库(例如 {@code CareerProgressControllerTest} + * 使用 {@code integrationdb_careerprogress})。因此夹具必须记住**每一个**出现过的测试库, + * 并在签发时把会话行写入全部已知库 —— 这样无论当前测试读的是哪个库,都能看到会话。 + * 相比「只记第一个」或「记最后一个」,这是唯一与上下文缓存顺序无关的做法。

    + * + *

    夹具用固定有效期构造自己的 {@link JwtTokenProvider},不读取任何注入的 Clock, + * 因此不受 {@code @MockBean Clock} 影响。

    + * + *

    仅存在于 src/test,不会进入生产制品。

    + */ +public final class TestAuthTokens { + + /** 与生产一致的固定业务时区。 */ + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + + /** + * 夹具签发时间:固定为足够早的过去时刻。 + * + *

    S02 的解析契约是 {@code iat <= capturedNow < exp},而部分既有测试会把 + * {@code Clock} 固定在一个较早的时间(例如消息撤回的 HTTP 集成测试)。 + * 若夹具用"真实当前时间"签发,这些测试下就会命中"iat 在未来"而被拒绝。 + * 因此夹具统一使用一个远早于所有测试时钟的 iat,配合超长有效期。

    + */ + private static final Instant FIXTURE_BASE = Instant.parse("2020-01-01T00:00:00Z"); + + /** 夹具有效期:30 年,覆盖 2020–2050 之间的任何测试时钟。 */ + private static final long FIXTURE_TTL_MS = 30L * 365 * 24 * 60 * 60 * 1000; + + /** 已登记的测试库:JDBC URL -> 写入器。 */ + private static final Map LIBRARIES = new ConcurrentHashMap<>(); + + private TestAuthTokens() { + } + + private record Library(JdbcTemplate jdbc, JwtTokenProvider provider, AuthTokenHasher hasher) { + } + + /** + * 登记一个测试上下文使用的数据库。由 + * {@link TestAuthTokensAutoConfiguration} 在每个上下文启动时调用;同一 URL 只登记一次。 + */ + static void register(DataSource dataSource, AppJwtProperties source) { + String url = jdbcUrl(dataSource); + if (url == null) { + return; + } + LIBRARIES.computeIfAbsent(url, key -> { + AppJwtProperties fixture = new AppJwtProperties(); + fixture.setAccessSecret(source.getAccessSecret()); + fixture.setRefreshSecret(source.getRefreshSecret()); + fixture.setTokenHashSecret(source.getTokenHashSecret()); + fixture.setIssuer(source.getIssuer()); + fixture.setAccessAudience(source.getAccessAudience()); + fixture.setRefreshAudience(source.getRefreshAudience()); + fixture.setExpirationMs(FIXTURE_TTL_MS); + fixture.setRefreshExpirationMs(FIXTURE_TTL_MS); + return new Library(new JdbcTemplate(dataSource), + new JwtTokenProvider(fixture), new AuthTokenHasher(fixture)); + }); + } + + /** 返回可直接放入 Authorization 头的完整值。 */ + public static String bearer(Long userId, String role) { + return "Bearer " + accessToken(userId, role); + } + + /** 常用角色重载。 */ + public static String bearer(Long userId, Enum role) { + return bearer(userId, role == null ? "STUDENT" : role.name()); + } + + /** 返回 Access Token 原文。 */ + public static synchronized String accessToken(Long userId, String role) { + return mintPair(userId, role).access(); + } + + /** 返回可通过 S02 过滤器的 Refresh Token 原文(type=refresh、refresh 密钥、真实会话)。 */ + public static synchronized String refreshToken(Long userId, String role) { + return mintPair(userId, role).refresh(); + } + + /** 同时签发 Access/Refresh 并登记会话;返回对。 */ + private static IssuedPair mintPair(Long userId, String role) { + if (LIBRARIES.isEmpty()) { + throw new IllegalStateException( + "TestAuthTokens is not initialized: no test datasource was registered"); + } + String effectiveRole = (role == null || role.isBlank()) ? "STUDENT" : role; + Library mint = LIBRARIES.values().iterator().next(); + + String sessionId = UUID.randomUUID().toString(); + IssuedToken access = mint.provider() + .generateAccessToken(userId, effectiveRole, sessionId, UUID.randomUUID().toString(), FIXTURE_BASE); + IssuedToken refresh = mint.provider() + .generateRefreshToken(userId, sessionId, UUID.randomUUID().toString(), FIXTURE_BASE); + + String refreshHash = mint.hasher().hash(refresh.token()); + LocalDateTime expiresAt = LocalDateTime.ofInstant(refresh.expiresAt(), BUSINESS_ZONE); + LocalDateTime now = LocalDateTime.now(BUSINESS_ZONE); + + // 写入所有已知测试库:当前测试读哪个库都能看到该会话。 + boolean written = false; + for (Map.Entry entry : LIBRARIES.entrySet()) { + try { + writeSession(entry.getValue().jdbc(), userId, effectiveRole, refreshHash, + refresh.tokenId(), expiresAt, sessionId, now); + written = true; + } catch (RuntimeException e) { + // 已失效的上下文(连接池关闭等)自行登记移除,不影响其余库。 + LIBRARIES.remove(entry.getKey()); + } + } + if (!written) { + throw new IllegalStateException( + "TestAuthTokens could not write a session into any registered test datasource"); + } + return new IssuedPair(access.token(), refresh.token()); + } + + private record IssuedPair(String access, String refresh) { + } + + private static void writeSession(JdbcTemplate jdbc, Long userId, String role, String refreshHash, + String refreshJti, LocalDateTime expiresAt, + String sessionId, LocalDateTime now) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, userId); + if (count == null || count == 0) { + // 既有测试常用固定 userId(如 100)但从不建用户行;S02 过滤器会校验用户存在且 active。 + // phone 使用非数字前缀,避免与真实测试号的唯一约束冲突。 + jdbc.update( + "INSERT INTO users (id, phone, nickname, role, is_active, is_verified) VALUES (?, ?, ?, ?, 1, 1)", + userId, "t" + userId, "test-user-" + userId, role); + } + jdbc.update(""" + INSERT INTO auth_tokens + (user_id, token_hash, token_type, expired_at, revoked, + session_id, token_jti, version, created_at) + VALUES (?, ?, 'REFRESH', ?, 0, ?, ?, 0, CURRENT_TIMESTAMP) + """, + userId, refreshHash, Timestamp.valueOf(expiresAt), sessionId, refreshJti); + + Integer visible = jdbc.queryForObject(""" + SELECT COUNT(*) FROM auth_tokens + WHERE user_id = ? AND session_id = ? AND revoked = 0 + AND consumed_at IS NULL AND expired_at > ? + """, + Integer.class, userId, sessionId, Timestamp.valueOf(now)); + if (visible == null || visible == 0) { + throw new IllegalStateException("fixture session not visible"); + } + } + + private static String jdbcUrl(DataSource dataSource) { + try (Connection connection = dataSource.getConnection()) { + return connection.getMetaData().getURL(); + } catch (SQLException e) { + return null; + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokensAutoConfiguration.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokensAutoConfiguration.java new file mode 100644 index 0000000..8549b69 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokensAutoConfiguration.java @@ -0,0 +1,38 @@ +package com.mydreamjob.auth.support; + +import com.mydreamjob.config.AppJwtProperties; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +import javax.sql.DataSource; + +/** + * 测试专用自动配置:把上下文里的 {@link DataSource} 与 JWT 配置登记给 {@link TestAuthTokens}。 + * + *

    使用 {@code @AutoConfiguration}(而非 {@code spring.factories} 的 + * {@code TestExecutionListener}):后者会替换 Spring 的默认执行监听器, + * 导致依赖注入等基础能力失效。

    + * + *

    {@code @WebMvcTest} 切片没有 DataSource 与 JWT 配置,通过 + * {@code ObjectProvider} 安全跳过(不用 {@code @ConditionalOnBean}: + * 它在自动配置阶段顺序敏感,可能先于 DataSourceAutoConfiguration 求值)。

    + */ +@AutoConfiguration +public class TestAuthTokensAutoConfiguration { + + @Bean + public TestAuthTokensInstaller testAuthTokensInstaller( + org.springframework.beans.factory.ObjectProvider dataSource, + org.springframework.beans.factory.ObjectProvider jwtProperties) { + DataSource ds = dataSource.getIfAvailable(); + AppJwtProperties props = jwtProperties.getIfAvailable(); + if (ds != null && props != null) { + TestAuthTokens.register(ds, props); + } + return new TestAuthTokensInstaller(); + } + + /** 仅用于确保自动配置被加载的占位 Bean。 */ + public static final class TestAuthTokensInstaller { + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchControllerTest.java index 8012fa3..a922bfb 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchControllerTest.java @@ -58,11 +58,11 @@ class ApplicationMatchControllerTest { } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT"); } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(COMPANY_ID, "3", Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(COMPANY_ID, "COMPANY"); } private ApplicationMatchRecord record() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java index 0643bbe..20105c3 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java @@ -2,11 +2,14 @@ package com.mydreamjob.bidirectionalmatch; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -24,11 +27,19 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class MatchRuleServiceTest { + @BeforeEach + void wireRoleGuard() { + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + } + @Mock private CompanyMatchRuleRepository companyRuleRepository; @Mock private StudentMatchRuleRepository studentRuleRepository; @Mock private CompanyRepository companyRepository; @Mock private UserRepository userRepository; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private MatchRuleService matchRuleService; private static final Long COMPANY_USER_ID = 10L; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicyTest.java new file mode 100644 index 0000000..7255b19 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicyTest.java @@ -0,0 +1,78 @@ +package com.mydreamjob.candidatescore; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CandidateOverviewStatusPolicyTest { + + @Test + void nullComputedResultsPreserveHistoricalFailFastBehavior() { + assertThrows(NullPointerException.class, + () -> CandidateOverviewStatusPolicy.fromComputedResults(null, 8)); + } + + @Test + void computedResultsPreserveFixedEightDimensionContract() { + assertEquals("PENDING", CandidateOverviewStatusPolicy.fromComputedResults(List.of(), 8)); + assertEquals("OK", CandidateOverviewStatusPolicy.fromComputedResults(results(8, "OK", "80"), 8)); + + List partial = new ArrayList<>(results(1, "OK", "80")); + partial.addAll(results(7, "PENDING", null)); + assertEquals("PARTIAL", CandidateOverviewStatusPolicy.fromComputedResults(partial, 8)); + assertEquals("PENDING", CandidateOverviewStatusPolicy.fromComputedResults(results(8, "PENDING", null), 8)); + assertEquals("INSUFFICIENT_DATA", + CandidateOverviewStatusPolicy.fromComputedResults(results(8, "INSUFFICIENT_DATA", null), 8)); + } + + @Test + void computedResultRequiresScoreEvenWhenStatusLooksComputable() { + assertEquals("INSUFFICIENT_DATA", + CandidateOverviewStatusPolicy.fromComputedResults(results(8, "OK", null), 8)); + } + + @Test + void snapshotsPreserveExistingSnapshotCountAndStatusContract() { + assertEquals("INSUFFICIENT_DATA", CandidateOverviewStatusPolicy.fromSnapshots(null)); + assertEquals("INSUFFICIENT_DATA", CandidateOverviewStatusPolicy.fromSnapshots(List.of())); + assertEquals("OK", CandidateOverviewStatusPolicy.fromSnapshots(snapshots("OK", "ESTIMATED"))); + assertEquals("PARTIAL", CandidateOverviewStatusPolicy.fromSnapshots(snapshots("OK", "PENDING"))); + assertEquals("PENDING", CandidateOverviewStatusPolicy.fromSnapshots(snapshots("PENDING", "PENDING"))); + assertEquals("INSUFFICIENT_DATA", + CandidateOverviewStatusPolicy.fromSnapshots(snapshots("INSUFFICIENT_DATA", "PENDING"))); + } + + @Test + void snapshotsKeepHistoricalStatusOnlyComputabilityEvenWithNullScore() { + CandidateDimensionScore snapshot = snapshot("OK"); + snapshot.setScore(null); + assertEquals("OK", CandidateOverviewStatusPolicy.fromSnapshots(List.of(snapshot))); + } + + private List results(int count, String status, String score) { + List values = new ArrayList<>(); + for (int i = 0; i < count; i++) { + CandidateScoringCalculator.DimensionResult result = + new CandidateScoringCalculator.DimensionResult("D" + i); + result.status = status; + result.score = score == null ? null : new BigDecimal(score); + values.add(result); + } + return values; + } + + private List snapshots(String... statuses) { + List values = new ArrayList<>(); + for (String status : statuses) values.add(snapshot(status)); + return values; + } + + private CandidateDimensionScore snapshot(String status) { + return CandidateDimensionScore.builder().status(status).score(new BigDecimal("80")).build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardControllerTest.java index cfa24c4..b808f09 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardControllerTest.java @@ -56,18 +56,15 @@ class CandidateScoreCardControllerTest { } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "13800138000", - Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT"); } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(200L, "13800138001", - Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(200L, "COMPANY"); } private String adminToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(300L, "13800138002", - Map.of("roles", "ADMIN")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(300L, "ADMIN"); } private CandidateScoreCardResponse buildScoreCard(String status, int sampleCount) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java index 5532a06..ec50c23 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java @@ -46,13 +46,14 @@ class CandidateScoringCalculatorTest { @Mock private AbilityAssessmentScoreSource abilityScoreSource; @Spy private AbilityScoreMapper abilityScoreMapper = new AbilityScoreMapper(); @InjectMocks private CandidateScoringCalculator calculator; + @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringSnapshotWriter snapshotWriter; private static final Long STUDENT_ID = 2L; private Query queryMock; @BeforeEach void setUp() { - ReflectionTestUtils.setField(calculator, "entityManager", entityManager); + // R01-D4-1:快照写入已委托 snapshotWriter,Calculator 不再持有 entityManager。 queryMock = mock(Query.class); when(queryMock.setParameter(anyInt(), any())).thenReturn(queryMock); when(entityManager.createNativeQuery(anyString())).thenReturn(queryMock); @@ -252,23 +253,19 @@ class CandidateScoringCalculatorTest { calculator.computeAbility(STUDENT_ID, NOW); calculator.upsertSnapshots(STUDENT_ID, List.of(r), "2026-08"); - ArgumentCaptor paramCaptor = ArgumentCaptor.forClass(Object.class); - verify(queryMock).setParameter(eq(6), paramCaptor.capture()); - String payload = (String) paramCaptor.getValue(); - assertNotNull(payload); - assertTrue(payload.contains("\"source\":\"V36_ABILITY_ASSESSMENT\"")); - assertTrue(payload.contains("\"sourceResultId\":123")); - assertTrue(payload.contains("\"mappingRulesVersion\":\"ability-assessment-scoring-v0.1\"")); - assertTrue(payload.contains("\"weights\"")); - assertTrue(payload.contains("\"dimensionScores\"")); - for (String banned : List.of("answerKey", "questionCode", "competencyTag", "stem", "selectedOptionId", - "scoringRule", "awardedScore", "explanation", "phone", "email")) { - assertFalse(payload.toLowerCase().contains(banned.toLowerCase()), "must not contain " + banned); - } - // 同一次 ABILITY 写入使用 v0.2 规则版本 - ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(Object.class); - verify(queryMock).setParameter(eq(8), versionCaptor.capture()); - assertEquals("candidate-scoring-v0.2", versionCaptor.getValue()); + // R01-D4-1:写入委托 snapshotWriter;其 ABILITY 溯源 trace 不得含敏感字段。 + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(java.util.List.class); + verify(snapshotWriter).upsertIfChanged(eq(STUDENT_ID), captor.capture(), + eq("2026-08"), any(java.time.LocalDateTime.class), eq("candidate-scoring-v0.2")); + CandidateScoringCalculator.DimensionResult sent = captor.getValue().get(0); + assertNotNull(sent.abilitySnapshotTrace); + assertEquals(123L, sent.abilitySnapshotTrace.resultId()); + // 写入参数不携带任何问题/答案/敏感内容:XML 溯源仅保留 source/sourceResultId/validUntil/版本/权重/维度 + var payload = sent.abilitySnapshotTrace; + assertNotNull(payload.dimensionScores()); + // 同一次 ABILITY 写入使用 v0.2 规则版本(verify 中已断言 rulesVersion 传参) } private void setEnabled(boolean enabled) { @@ -380,22 +377,24 @@ class CandidateScoringCalculatorTest { verify(jdbcTemplate, times(1)).queryForObject(anyString(), eq(Long.class), any(Object[].class)); } - // ── upsert SQL(原生 ON DUPLICATE KEY UPDATE) ───────────── + // ── upsert 委托(R01-D4-1:普通快照写入委托唯一 Writer) ────── - @Test void upsertSnapshots_shouldUseOnDuplicateKeyUpdate() { + @Test void upsertSnapshots_shouldDelegateToWriter() { CandidateScoringCalculator.DimensionResult r = calculator.computeEducation(StudentProfile.builder().userId(STUDENT_ID) .education("本科").school("清华大学").build()); calculator.upsertSnapshots(STUDENT_ID, List.of(r), "2026-08"); - ArgumentCaptor sql = ArgumentCaptor.forClass(String.class); - verify(entityManager).createNativeQuery(sql.capture()); - assertTrue(sql.getValue().contains("ON DUPLICATE KEY UPDATE")); - assertTrue(sql.getValue().contains("version=version+1")); - verify(entityManager.createNativeQuery(anyString())).executeUpdate(); + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(java.util.List.class); + verify(snapshotWriter).upsertIfChanged(eq(STUDENT_ID), captor.capture(), + eq("2026-08"), any(java.time.LocalDateTime.class), anyString()); + assertEquals(1, captor.getValue().size()); + assertEquals("EDUCATION", captor.getValue().get(0).dimension); } - // ── recomputeAndSnapshot ────────────────────────────────── + // ── recomputeAndSnapshot(Governance 强制版本路径委托 Writer) ── @Test void recomputeAndSnapshot_shouldRecomputeAndWritePeriod() { when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(user())); @@ -407,9 +406,9 @@ class CandidateScoringCalculatorTest { calculator.recomputeAndSnapshot(STUDENT_ID, "EDUCATION", "2026-06"); assertEquals("EDUCATION", r.dimension); assertEquals(new BigDecimal("76.75"), r.score); - // period 作为绑定参数写入(写 2026-06 周期快照) - verify(queryMock).setParameter(anyInt(), eq("2026-06")); - verify(queryMock).executeUpdate(); + // 强制版本推进路径:委托 snapshotWriter.upsertForcingVersion + verify(snapshotWriter).upsertForcingVersion(eq(STUDENT_ID), eq(r), + eq("2026-06"), any(java.time.LocalDateTime.class), anyString()); } @Test void recomputeAndSnapshot_invalidDimension_should400() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java index 4968aa1..ab8bf3b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java @@ -49,7 +49,7 @@ class CandidateScoringClockConsistencyTest { private static final LocalDateTime CAPTURED = LocalDateTime.of(2026, 9, 9, 12, 0); @Test - void overviewUsesOneCapturedTimeForComputeAndSnapshot() { + void overviewUsesOneCapturedTimeForComputeAndResponseWithoutSnapshotWrite() { CandidateScoringEngineService service = new CandidateScoringEngineService( calculator, snapshotRepository, companyRepository, applicationRepository, jobService); LocalDateTime captured = LocalDateTime.of(2026, 9, 9, 12, 0); @@ -60,14 +60,13 @@ class CandidateScoringClockConsistencyTest { when(calculator.computeAll(eq(7L), any(LocalDateTime.class))).thenReturn(List.of(result)); when(calculator.currentRulesVersion()).thenReturn(CandidateScoringCalculator.RULES_VERSION); - service.getOverview(7L); + var overview = service.getOverview(7L); ArgumentCaptor computeTime = ArgumentCaptor.forClass(LocalDateTime.class); verify(calculator).computeAll(eq(7L), computeTime.capture()); - ArgumentCaptor snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); - verify(calculator).upsertSnapshots(eq(7L), anyList(), anyString(), snapshotTime.capture()); assertEquals(captured, computeTime.getValue()); - assertEquals(computeTime.getValue(), snapshotTime.getValue()); + assertEquals(captured.atZone(BUSINESS_ZONE).toInstant(), overview.getGeneratedAt()); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); } @Test @@ -170,16 +169,14 @@ class CandidateScoringClockConsistencyTest { ArgumentCaptor computeTime = ArgumentCaptor.forClass(LocalDateTime.class); verify(calculator).computeAll(eq(7L), computeTime.capture()); - ArgumentCaptor snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); - verify(calculator).upsertSnapshots(eq(7L), anyList(), anyString(), snapshotTime.capture()); assertEquals(CAPTURED, computeTime.getValue()); - assertEquals(CAPTURED, snapshotTime.getValue()); assertEquals(CAPTURED.atZone(BUSINESS_ZONE).toInstant(), overview.getGeneratedAt()); assertEquals(1, advancing.instantCalls.get()); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); } @Test - void companyScoring_usesOneCapturedClockAndExactPeriod() { + void companyScoring_usesOneCapturedClockWithoutSnapshotWrite() { CandidateScoringEngineService service = new CandidateScoringEngineService( calculator, snapshotRepository, companyRepository, applicationRepository, jobService); AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE); @@ -199,18 +196,14 @@ class CandidateScoringClockConsistencyTest { ArgumentCaptor computeTime = ArgumentCaptor.forClass(LocalDateTime.class); verify(calculator).computeAll(eq(7L), computeTime.capture()); - ArgumentCaptor snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); - ArgumentCaptor period = ArgumentCaptor.forClass(String.class); - verify(calculator).upsertSnapshots(eq(7L), anyList(), period.capture(), snapshotTime.capture()); assertEquals(CAPTURED, computeTime.getValue()); - assertEquals(CAPTURED, snapshotTime.getValue()); - assertEquals("2026-09", period.getValue()); assertEquals(CAPTURED.atZone(BUSINESS_ZONE).toInstant(), response.getGeneratedAt()); assertEquals(1, advancing.instantCalls.get()); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); } @Test - void generatedAtCalculatedAtPeriodAndValidity_useSameCapturedTime() { + void detailCalculatedAndUpdatedAtUseSameCapturedTimeWithoutSnapshotWrite() { CandidateScoringEngineService service = new CandidateScoringEngineService( calculator, snapshotRepository, companyRepository, applicationRepository, jobService); AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE); @@ -227,15 +220,11 @@ class CandidateScoringClockConsistencyTest { ArgumentCaptor computeTime = ArgumentCaptor.forClass(LocalDateTime.class); verify(calculator).computeAll(eq(7L), computeTime.capture()); - ArgumentCaptor snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); - ArgumentCaptor period = ArgumentCaptor.forClass(String.class); - verify(calculator).upsertSnapshots(eq(7L), anyList(), period.capture(), snapshotTime.capture()); assertEquals(CAPTURED, computeTime.getValue()); - assertEquals(CAPTURED, snapshotTime.getValue()); - assertEquals("2026-09", period.getValue()); assertEquals(CAPTURED, detail.getCalculatedAt()); assertEquals(CAPTURED, detail.getUpdatedAt()); assertEquals(1, advancing.instantCalls.get()); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); } private CandidateScoringCalculator calculatorWithJdbcAggregates(LocalDateTime captured, @@ -282,8 +271,9 @@ class CandidateScoringClockConsistencyTest { when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(0L); CandidateScoringCalculator real = new CandidateScoringCalculator( profileRepository, preferenceRepository, userRepository, jdbcTemplate, - abilityScoreSource, new AbilityScoreMapper()); - ReflectionTestUtils.setField(real, "entityManager", entityManager); + abilityScoreSource, new AbilityScoreMapper(), + org.mockito.Mockito.mock( + com.mydreamjob.candidatescore.refresh.CandidateScoringSnapshotWriter.class)); return real; } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java index eb3255b..09cc9bc 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java @@ -53,15 +53,15 @@ class CandidateScoringControllerTest { } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "13800138000", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT"); } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(200L, "13800138001", Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(200L, "COMPANY"); } private String adminToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(300L, "13800138002", Map.of("roles", "ADMIN")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(300L, "ADMIN"); } private CandidateScoringOverviewResponse buildOverview() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java index 113962b..79b3840 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java @@ -84,13 +84,12 @@ class CandidateScoringEngineServiceTest { @BeforeEach void setUp() { - doNothing().when(calculator).upsertSnapshots(anyLong(), anyList(), anyString()); when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1"); } // ── 综合评分卡 ────────────────────────────────────────────── - @Test void getOverview_shouldAssembleAndUpsert() { + @Test void getOverview_shouldAssembleWithoutWritingSnapshots() { when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).thenReturn(sixComputable()); CandidateScoringOverviewResponse resp = service.getOverview(STUDENT_ID); @@ -101,7 +100,7 @@ class CandidateScoringEngineServiceTest { assertEquals(8, resp.getDimensions().size()); assertEquals(new BigDecimal("12.50"), resp.getWeights().get("EDUCATION")); verify(calculator, times(1)).computeAll(eq(STUDENT_ID), any(LocalDateTime.class)); - verify(calculator, times(1)).upsertSnapshots(eq(STUDENT_ID), anyList(), anyString(), any(LocalDateTime.class)); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); } @Test void getOverview_allPending_shouldBePending() { @@ -221,6 +220,7 @@ class CandidateScoringEngineServiceTest { DimensionScoreDetailResponse detail = service.getDimensionDetail(STUDENT_ID, "EDUCATION"); assertEquals("EDUCATION", detail.getDimension()); assertEquals("candidate-scoring-v0.1", detail.getRulesVersion()); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); } // ── 趋势 ───────────────────────────────────────────────── @@ -293,6 +293,7 @@ class CandidateScoringEngineServiceTest { when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).thenReturn(sixComputable()); CompanyCandidateScoringResponse resp = service.getCompanyScoring(COMPANY_USER_ID, APP_ID); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); assertEquals(APP_ID, resp.getApplicationId()); assertEquals("candidate-scoring-v0.1", resp.getRulesVersion()); assertEquals(8, resp.getDimensions().size()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java index 2d7da1a..ae43fca 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java @@ -4,8 +4,10 @@ import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.candidatescore.dto.CandidateScoringOverviewResponse; import com.mydreamjob.candidatescore.dto.CandidateScoringTrendResponse; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -14,6 +16,7 @@ import org.springframework.data.redis.core.ValueOperations; import org.springframework.test.context.ActiveProfiles; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -28,23 +31,69 @@ import static org.mockito.Mockito.when; class CandidateScoringPersistenceIntegrationTest { @Autowired private CandidateScoringEngineService service; + @Autowired private CandidateScoringCalculator calculator; @Autowired private UserRepository userRepository; @Autowired private CandidateDimensionScoreRepository snapshotRepository; + @Autowired private JdbcTemplate jdbcTemplate; @MockBean private RedisTemplate redisTemplate; + /** + * 本测试**独占**的手机号段。用于: + *
      + *
    1. 前置断言"独占键不存在",避免上一轮残留被静默覆盖而掩盖污染;
    2. + *
    3. 收尾按精确条件清理本测试创建的数据。
    4. + *
    + */ + private static final List OWN_PHONES = + List.of("13900009901", "13900009902", "13900009903"); + + /** 本测试创建的用户 id(每个用例自建,收尾按精确 id 清理)。 */ + private final List ownUserIds = new java.util.ArrayList<>(); + @BeforeEach @SuppressWarnings("unchecked") void setUp() { ValueOperations ops = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(any())).thenReturn(false); + + // 前置断言:独占手机号不得预先存在。若上一轮清理失效,这里立即失败, + // 而不是让残留数据静默参与断言(也会污染同期其它测试的期间统计)。 + for (String phone : OWN_PHONES) { + Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, phone); + assertTrue(existing == null || existing == 0, + "独占夹具手机号 " + phone + " 已存在,说明上轮清理失效(不得静默覆盖)"); + } + ownUserIds.clear(); + } + + /** + * 收尾:按**精确 id** 删除本测试创建的数据,遵循外键顺序(快照 → 角色 → 用户)。 + * + *

    背景:本类会以 {@code currentPeriod()} 物化 8 条维度快照。这些行若残留, + * 会进入同一 H2 库中其它测试对"当前期间"的总体统计 + * (例如 {@code FairnessMonitoringHttpIntegrationTest} 的 {@code populationSize}), + * 造成顺序相关的误失败。此处只删除自有 id,**不使用 {@code deleteAll}**, + * 不触碰任何其它测试的数据。

    + */ + @AfterEach + void cleanUpOwnFixture() { + for (Long userId : ownUserIds) { + jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id = ?", userId); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", userId); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", userId); + } + ownUserIds.clear(); } private User createStudent(String phone) { User u = User.builder().phone(phone).nickname("scoring-test") .role(User.Role.STUDENT).active(true).verified(true).build(); u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); - return userRepository.save(u); + User saved = userRepository.save(u); + ownUserIds.add(saved.getId()); + return saved; } private String currentPeriod() { @@ -52,30 +101,41 @@ class CandidateScoringPersistenceIntegrationTest { return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); } + private CandidateScoringOverviewResponse computeAndMaterialize(Long studentId) { + LocalDateTime capturedNow = LocalDateTime.now(); + CandidateScoringOverviewResponse response = service.getOverview(studentId); + calculator.upsertSnapshots(studentId, calculator.computeAll(studentId, capturedNow), + currentPeriod(), capturedNow); + return response; + } + @Test - void upsertSnapshots_shouldBeIdempotentAndIncrementVersion() { + void upsertSnapshots_shouldBeIdempotentInValuesAndPersistEightRows() { + // R01-D4-1 契约:评分输入未变化时,普通物化必须幂等(值不变 → version 不变)。 User u = createStudent("13900009901"); String period = currentPeriod(); - CandidateScoringOverviewResponse first = service.getOverview(u.getId()); + CandidateScoringOverviewResponse first = computeAndMaterialize(u.getId()); List rows1 = snapshotRepository.findByStudentUserIdAndPeriod(u.getId(), period); assertEquals(8, rows1.size()); // 新用户无数据:全部 INSUFFICIENT_DATA / PENDING,overallScore=null assertEquals("INSUFFICIENT_DATA", first.getStatus()); assertNull(first.getOverallScore()); - Long version1 = rows1.get(0).getVersion(); - // 再次调用:同周期覆盖,version 递增,行数不变 - service.getOverview(u.getId()); + // 再次调用:值未变化 → version 不变(幂等物化),行数不变 + computeAndMaterialize(u.getId()); List rows2 = snapshotRepository.findByStudentUserIdAndPeriod(u.getId(), period); assertEquals(8, rows2.size()); - assertEquals(version1 + 1, rows2.get(0).getVersion()); + for (int i = 0; i < rows1.size(); i++) { + assertEquals(rows1.get(i).getVersion(), rows2.get(i).getVersion(), + "重复相同值 upsert 不得推进 version: " + rows1.get(i).getDimension()); + } } @Test void upsert_rulesVersionPersisted() { User u = createStudent("13900009902"); - service.getOverview(u.getId()); + computeAndMaterialize(u.getId()); List rows = snapshotRepository.findByStudentUserIdAndPeriod(u.getId(), currentPeriod()); assertTrue(rows.stream().allMatch(r -> "candidate-scoring-v0.1".equals(r.getRulesVersion()))); assertTrue(rows.stream().allMatch(r -> r.getWeight().doubleValue() == 12.50)); @@ -84,7 +144,7 @@ class CandidateScoringPersistenceIntegrationTest { @Test void trend_shouldReadSnapshotsOfCurrentPeriod() { User u = createStudent("13900009903"); - service.getOverview(u.getId()); + computeAndMaterialize(u.getId()); CandidateScoringTrendResponse trend = service.getTrend(u.getId(), 6); assertEquals(1, trend.getItems().size()); assertEquals(currentPeriod(), trend.getItems().get(0).getPeriod()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/R01D4GetWritesSnapshotEvidenceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/R01D4GetWritesSnapshotEvidenceTest.java new file mode 100644 index 0000000..0fdf9a1 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/R01D4GetWritesSnapshotEvidenceTest.java @@ -0,0 +1,113 @@ +package com.mydreamjob.candidatescore; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.dto.CandidateScoringOverviewResponse; +import com.mydreamjob.candidatescore.dto.CandidateScoringTrendResponse; +import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * R01-D4-2 契约证据:评分 GET 只实时计算响应,不写 candidate_dimension_scores。 + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class R01D4GetWritesSnapshotEvidenceTest { + + @Autowired private CandidateScoringEngineService service; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + + private Long studentId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + User u = User.builder().phone("133" + String.format("%08d", System.nanoTime() % 100_000_000L)) + .nickname("r01d4-evidence").role(User.Role.STUDENT) + .active(true).verified(true).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + studentId = userRepository.save(u).getId(); + } + + private List> snapshotRows() { + return jdbcTemplate.queryForList(""" + SELECT id, student_user_id, dimension, score, status, weight, period, + rules_version, version, created_at, updated_at + FROM candidate_dimension_scores + ORDER BY id + """); + } + + private long countRows(String table) { + Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); + return c == null ? -1 : c; + } + + @Test + void overviewGet_returnsRealtimeResultWithoutWritingSnapshots() { + List> before = snapshotRows(); + CandidateScoringOverviewResponse first = service.getOverview(studentId); + CandidateScoringOverviewResponse second = service.getOverview(studentId); + assertEquals(before, snapshotRows(), "overview GET 必须保持快照表逐字段不变"); + assertEquals(first.getOverallScore(), second.getOverallScore()); + assertEquals(first.getStatus(), second.getStatus()); + } + + @Test + void dimensionDetailGet_returnsRealtimeResultWithoutWritingSnapshots() { + List> before = snapshotRows(); + DimensionScoreDetailResponse detail = service.getDimensionDetail(studentId, "EDUCATION"); + assertNotNull(detail); + assertEquals(before, snapshotRows(), "单维度 GET 必须保持快照表逐字段不变"); + } + + @Test + void trendGet_isReadOnly() { + List> before = snapshotRows(); + CandidateScoringTrendResponse t1 = service.getTrend(studentId, 6); + CandidateScoringTrendResponse t2 = service.getTrend(studentId, 6); + assertEquals(before, snapshotRows(), "trend GET 必须保持快照表逐字段不变"); + assertEquals(t1.getItems().size(), t2.getItems().size()); + } + + @Test + void invalidDimensionGet_doesNotWrite() { + List> before = snapshotRows(); + assertThrows(com.mydreamjob.common.exception.BusinessException.class, + () -> service.getDimensionDetail(studentId, "BOGUS")); + assertEquals(before, snapshotRows(), "非法 dimension 不得写库"); + } + + @Test + void overviewGet_doesNotWriteAuditOrNotifications() { + service.getOverview(studentId); + long audit = countRows("audit_logs"); + long notif = countRows("notifications"); + service.getOverview(studentId); + service.getOverview(studentId); + assertEquals(audit, countRows("audit_logs"), "GET 评分不得写 audit_logs"); + assertEquals(notif, countRows("notifications"), "GET 评分不得写 notifications"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java index e68c278..c792cd0 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java @@ -39,7 +39,7 @@ import static org.mockito.Mockito.when; * V38 → V32 排名回归(真实快照写路径 + 真实 CompetitionRankingCalculator)。 * *

    不伪造 ABILITY 快照:开关开启且存在最新有效 V36 结果时,先经 - * {@link CandidateScoringEngineService#getOverview} 的真实候选评分写快照路径, + * {@link CandidateScoringCalculator} 的真实候选评分物化路径, * 使 ABILITY 以既定权重公式计入综合分,再交排名组件计算 overallScore/rank/percentile。 * 开关关闭时 ABILITY 保持 PENDING,综合分与既有口径一致(无可计算维度 → 本人无分)。

    * @@ -62,7 +62,6 @@ class V38AbilityRankingIntegrationTest { @Autowired private AbilityDefinitionRepository definitionRepository; @Autowired private AbilityAttemptRepository attemptRepository; @Autowired private AbilityResultRepository resultRepository; - @Autowired private CandidateScoringEngineService engineService; @Autowired private CandidateScoringCalculator calculator; @Autowired private CompetitionRankingCalculator rankingCalculator; @@ -129,6 +128,12 @@ class V38AbilityRankingIntegrationTest { ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", b); } + private void materialize(Long studentId) { + LocalDateTime capturedNow = LocalDateTime.now(); + calculator.upsertSnapshots(studentId, calculator.computeAll(studentId, capturedNow), + currentMonth(), capturedNow); + } + /** * me 为无资料/无活跃数据学生;peer 由内存快照给出 EDUCATION=70(仅作对照组)。 * 开关关闭:me 无可计算维度 → overall=null、rank=null、percentile=null(ABILITY=PENDING)。 @@ -148,7 +153,7 @@ class V38AbilityRankingIntegrationTest { // 关闭:ABILITY=PENDING,me 无可计算维度 setEnabled(false); - engineService.getOverview(me); + materialize(me); Map> offSnaps = new HashMap<>(rankingCalculator.loadLatestSnapshots(List.of(me), month)); offSnaps.putAll(peerOnly); @@ -160,10 +165,10 @@ class V38AbilityRankingIntegrationTest { assertEquals("PENDING", off.meSnaps.stream() .filter(s -> "ABILITY".equals(s.getDimension())).findFirst().get().getStatus()); - // 开启:真实评分写快照 → ABILITY=OK 74.25 计入综合分 + // 开启:显式走真实物化写入口 → ABILITY=OK 74.25 计入综合分 setEnabled(true); giveValidAbilityResult(me); - engineService.getOverview(me); + materialize(me); Map> onSnaps = new HashMap<>(rankingCalculator.loadLatestSnapshots(List.of(me), month)); onSnaps.putAll(peerOnly); @@ -180,4 +185,4 @@ class V38AbilityRankingIntegrationTest { assertNotEquals(on.rank, off.rank); assertNotEquals(on.percentile, off.percentile); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculatorTest.java new file mode 100644 index 0000000..3f2bbb1 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CandidatePercentileCalculatorTest.java @@ -0,0 +1,87 @@ +package com.mydreamjob.candidatescore.dashboard; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CandidatePercentileCalculatorTest { + + @Test + void emptySingleAndNullOnlySamplesRemainUndisclosed() { + assertEquals(List.of(), CandidatePercentileCalculator.calculate(List.of())); + assertEquals(list((BigDecimal) null), CandidatePercentileCalculator.calculate(list(decimal("80")))); + assertEquals(list(null, null), CandidatePercentileCalculator.calculate(list(null, null))); + } + + @Test + void preservesInputOrderAndNullPositions() { + assertEquals(list(decimal("100.00"), null, decimal("0.00"), decimal("50.00")), + CandidatePercentileCalculator.calculate( + list(decimal("90"), null, decimal("70"), decimal("80")))); + } + + @Test + void tiedScoresUseStrictlyLowerCount() { + assertEquals(list(decimal("0.00"), decimal("0.00"), decimal("100.00")), + CandidatePercentileCalculator.calculate( + list(decimal("80.0"), decimal("80.00"), decimal("90")))); + } + + @Test + void matchesHistoricalQuadraticFormulaAcrossGeneratedMatrix() { + List domain = list(null, decimal("0"), decimal("1.0"), decimal("1.00"), decimal("2.5")); + for (int size = 0; size <= 6; size++) { + compareEveryCombination(domain, new ArrayList<>(), size); + } + } + + @Test + void nullInputPreservesHistoricalFailFastBehavior() { + assertThrows(NullPointerException.class, () -> CandidatePercentileCalculator.calculate(null)); + } + + private void compareEveryCombination(List domain, List current, int targetSize) { + if (current.size() == targetSize) { + assertEquals(legacy(current), CandidatePercentileCalculator.calculate(current), current::toString); + return; + } + for (BigDecimal value : domain) { + current.add(value); + compareEveryCombination(domain, current, targetSize); + current.remove(current.size() - 1); + } + } + + private List legacy(List scores) { + List scored = scores.stream().filter(java.util.Objects::nonNull).toList(); + if (scored.size() < 2) return scores.stream().map(ignored -> (BigDecimal) null).toList(); + List result = new ArrayList<>(); + for (BigDecimal score : scores) { + if (score == null) { + result.add(null); + continue; + } + long lower = scored.stream().filter(candidate -> candidate.compareTo(score) < 0).count(); + result.add(BigDecimal.valueOf(lower) + .divide(BigDecimal.valueOf(scored.size() - 1L), 10, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .setScale(2, RoundingMode.HALF_UP)); + } + return java.util.Collections.unmodifiableList(result); + } + + private BigDecimal decimal(String value) { + return new BigDecimal(value); + } + + @SafeVarargs + private final List list(T... values) { + return java.util.Arrays.asList(values); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardControllerTest.java index f589e34..d3b2481 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardControllerTest.java @@ -58,7 +58,7 @@ class CompanyScoringDashboardControllerTest { } private String token(long uid, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(uid, "1", Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(uid, role); } @Test void overview_company_should200_andMasked() throws Exception { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceControllerTest.java index 6b1d008..032d725 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceControllerTest.java @@ -50,7 +50,7 @@ class CandidateScoringGovernanceControllerTest { } private String token(long uid, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(uid, "1", Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(uid, role); } private AppealResponse buildAppeal(Long id, String status) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java index f363edf..4b4c8b9 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java @@ -4,6 +4,8 @@ import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateScoringCalculator; @@ -55,6 +57,9 @@ class CandidateScoringGovernanceServiceTest { @Mock private UserRepository userRepository; @Mock private AuditService auditService; @Mock private EntityManager entityManager; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private CandidateScoringGovernanceService service; private static final Long STUDENT_ID = 2L; @@ -64,6 +69,9 @@ class CandidateScoringGovernanceServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + ReflectionTestUtils.setField(service, "entityManager", entityManager); Query nativeQ = mock(Query.class); when(nativeQ.setParameter(anyInt(), any())).thenReturn(nativeQ); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshBatchIsolationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshBatchIsolationTest.java new file mode 100644 index 0000000..11da9df --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshBatchIsolationTest.java @@ -0,0 +1,156 @@ +package com.mydreamjob.candidatescore.refresh; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * R01-D4-2 次生缺陷:单个任务失败不得阻止同批后续任务。 + * + *

    缺陷背景:{@code recordFailure} 在更新 0 行时抛 IllegalStateException,异常点位于 + * Worker 的 catch 块内,而 {@code processDue} 的 for 循环没有保护 —— 单个任务的失败会 + * 让同批剩余任务本轮全部不被处理。

    + * + *

    本测试通过 mock 计算器精确制造"指定学生计算失败",断言真实数据库中的批次终态。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class CandidateScoringRefreshBatchIsolationTest { + + @Autowired private CandidateScoringRefreshRequestService requestService; + @Autowired private CandidateScoringRefreshWorker worker; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + @MockBean private CandidateScoringCalculator calculator; + + private static final AtomicLong SEQ = new AtomicLong(11000); + + private Long okA; + private Long okB; + private Long failing; + + private String statusOf(Long studentId) { + return jdbcTemplate.queryForObject( + "SELECT status FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", + String.class, studentId); + } + + private String errorCodeOf(Long studentId) { + return jdbcTemplate.queryForObject( + "SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", + String.class, studentId); + } + + private Long newStudent(String tag) { + User u = User.builder() + .phone("191" + String.format("%08d", SEQ.incrementAndGet())) + .nickname(tag).role(User.Role.STUDENT).active(true).verified(true).build(); + u.setRoles(java.util.Set.of(User.Role.STUDENT)); + return userRepository.save(u).getId(); + } + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + + okA = newStudent("隔离A"); + okB = newStudent("隔离B"); + failing = newStudent("失败C"); + + when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1"); + when(calculator.computeAll(eq(okA), any(LocalDateTime.class))).thenReturn(List.of()); + when(calculator.computeAll(eq(okB), any(LocalDateTime.class))).thenReturn(List.of()); + // 计算失败:异常 message 故意夹带敏感形态内容,用于验证错误分类不落异常原文 + when(calculator.computeAll(eq(failing), any(LocalDateTime.class))) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST, + "compute failed for phone=13800001111 token=eyJhbGciOiJIUzI1NiJ9.secret.sig")); + } + + @org.junit.jupiter.api.AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + } + + @Test + void firstTaskFails_secondTaskStillProcessed() { + requestService.requestRefresh(failing, LocalDateTime.now()); + requestService.requestRefresh(okA, LocalDateTime.now()); + + int processed = worker.processDue(10); + + assertEquals("RETRY", statusOf(failing), "失败任务应进入 RETRY"); + assertEquals("COMPLETED", statusOf(okA), + "同批后续任务必须仍然被处理(单任务失败不得阻断批次)"); + assertEquals(1, processed, "成功计数只统计被处理的任务"); + } + + @Test + void failingTaskInMiddle_doesNotBlockRemainingTasks() { + requestService.requestRefresh(okA, LocalDateTime.now()); + requestService.requestRefresh(failing, LocalDateTime.now()); + requestService.requestRefresh(okB, LocalDateTime.now()); + + worker.processDue(10); + + assertEquals("COMPLETED", statusOf(okA), "失败项之前的任务应完成"); + assertEquals("COMPLETED", statusOf(okB), "失败项之后的任务也必须完成"); + assertEquals("RETRY", statusOf(failing)); + } + + @Test + void lastErrorCode_neverLeaksExceptionMessage() { + requestService.requestRefresh(failing, LocalDateTime.now()); + + worker.processDue(10); + + String code = errorCodeOf(failing); + assertNotNull(code); + assertEquals("COMPUTE_FAILED", code, "只允许固定错误分类"); + assertFalse(code.contains("13800001111"), "错误分类不得包含手机号"); + assertFalse(code.contains("eyJ"), "错误分类不得包含 Token"); + assertFalse(code.contains("compute failed"), "错误分类不得包含异常原文"); + assertTrue(code.length() <= 40, "错误分类长度受列定义约束"); + } + + @Test + void batchTerminalState_matchesRealOutcome() { + requestService.requestRefresh(okA, LocalDateTime.now()); + requestService.requestRefresh(failing, LocalDateTime.now()); + requestService.requestRefresh(okB, LocalDateTime.now()); + + worker.processDue(10); + + List statuses = jdbcTemplate.queryForList( + "SELECT status FROM candidate_scoring_refresh_tasks ORDER BY student_user_id", String.class); + assertFalse(statuses.contains("PROCESSING"), "批次结束后不得残留 PROCESSING"); + assertEquals(2, statuses.stream().filter("COMPLETED"::equals).count(), "两个成功项"); + assertEquals(1, statuses.stream().filter("RETRY"::equals).count(), "一个重试项"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java new file mode 100644 index 0000000..d92342b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java @@ -0,0 +1,232 @@ +package com.mydreamjob.candidatescore.refresh; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * R01-D4-2 次生缺陷:Worker 领取所有权(claim fence)与 stale claim 处置。 + * + *

    缺陷背景:{@code materialize} 的「回到 PENDING」回退分支只校验 + * {@code status='PROCESSING'} 而不校验领取所有权;当原 Worker 因租约超时已被重新领取时, + * 慢返回的旧 Worker 会把新 Worker 正在处理的任务改回 PENDING。同时 + * {@code recordFailure} 在更新 0 行时抛 IllegalStateException,异常点位于 Worker 的 + * catch 块内、{@code processDue} 的 for 循环无保护 → 同批剩余任务本轮全部不处理。

    + * + *

    本测试断言真实数据库状态,不使用 sleep,不使用 JVM 锁。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class CandidateScoringRefreshClaimFenceTest { + + @Autowired private CandidateScoringRefreshRequestService requestService; + @Autowired private CandidateScoringRefreshTaskExecutor taskExecutor; + @Autowired private CandidateScoringRefreshWorker worker; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong SEQ = new AtomicLong(9000); + + private Long sid; + private String period; + + private String status(Long taskId) { + return jdbcTemplate.queryForObject( + "SELECT status FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId); + } + + private LocalDateTime processingStartedAt(Long taskId) { + return jdbcTemplate.queryForObject( + "SELECT processing_started_at FROM candidate_scoring_refresh_tasks WHERE id=?", + LocalDateTime.class, taskId); + } + + private Long newTask() { + requestService.requestRefresh(sid, LocalDateTime.now()); + return jdbcTemplate.queryForObject( + "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); + } + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + User u = User.builder() + .phone("193" + String.format("%08d", SEQ.incrementAndGet())) + .nickname("Fence学生").role(User.Role.STUDENT).active(true).verified(true).build(); + u.setRoles(java.util.Set.of(User.Role.STUDENT)); + sid = userRepository.save(u).getId(); + + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + // 与 CandidateScoringRefreshRequestService 保持一致:period = capturedNow 所在 yyyy-MM + LocalDateTime n = LocalDateTime.now(); + period = String.format("%04d-%02d", n.getYear(), n.getMonthValue()); + } + + /** 清理本类产生的行,避免污染同一 JVM 内共享的 H2 内存库。 */ + @org.junit.jupiter.api.AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id=?", sid); + } + + // ── claim fence ──────────────────────────────────────────── + + @Test + void claim_exposesProcessingStartedAtAsOwnershipFence() { + Long taskId = newTask(); + LocalDateTime t1 = LocalDateTime.now().withNano(0); + CandidateScoringRefreshTaskExecutor.ClaimedTask claimed = taskExecutor.claim(taskId, t1); + + assertNotNull(claimed, "首次 claim 应成功"); + assertEquals("PROCESSING", status(taskId)); + assertEquals(t1, processingStartedAt(taskId), "claim 必须记录所有权标识"); + assertNotNull(claimed, "ClaimedTask 必须携带领取信息"); + } + + @Test + void staleWorkerCompletion_cannotOverwriteNewWorkerClaim() { + Long taskId = newTask(); + LocalDateTime t1 = LocalDateTime.now().withNano(0).minusMinutes(20); + CandidateScoringRefreshTaskExecutor.ClaimedTask oldClaim = taskExecutor.claim(taskId, t1); + assertNotNull(oldClaim); + + // 模拟租约超时回收 + 另一个 Worker 重新领取 + taskExecutor.recoverStale(LocalDateTime.now(), LocalDateTime.now(), 3); + LocalDateTime t2 = LocalDateTime.now().withNano(0); + CandidateScoringRefreshTaskExecutor.ClaimedTask newClaim = taskExecutor.claim(taskId, t2); + assertNotNull(newClaim, "回收后应可被重新领取"); + assertEquals("PROCESSING", status(taskId)); + assertEquals(t2, processingStartedAt(taskId)); + + // 旧 Worker 迟到完成:必须更新 0 行并安静退出,不得改动新 Worker 的 PROCESSING + assertDoesNotThrow(() -> taskExecutor.materialize(oldClaim, LocalDateTime.now()), + "stale claim 的完成路径必须安静退出而不是抛异常"); + + assertEquals("PROCESSING", status(taskId), "旧 Worker 不得覆盖新 Worker 的领取状态"); + assertEquals(t2, processingStartedAt(taskId), "旧 Worker 不得改动所有权标识"); + + // 新 Worker 正常完成 + taskExecutor.materialize(newClaim, LocalDateTime.now()); + assertEquals("COMPLETED", status(taskId), "持有者应能正常完成"); + } + + @Test + void staleWorkerFailure_cannotOverwriteNewWorkerClaim() { + Long taskId = newTask(); + LocalDateTime t1 = LocalDateTime.now().withNano(0).minusMinutes(20); + CandidateScoringRefreshTaskExecutor.ClaimedTask oldClaim = taskExecutor.claim(taskId, t1); + assertNotNull(oldClaim); + + taskExecutor.recoverStale(LocalDateTime.now(), LocalDateTime.now(), 3); + LocalDateTime t2 = LocalDateTime.now().withNano(0); + taskExecutor.claim(taskId, t2); + + // 旧 Worker 迟到失败登记(携带旧的所有权令牌):必须安静退出 + assertDoesNotThrow(() -> taskExecutor.recordFailure(taskId, "WRITE_FAILED", + LocalDateTime.now(), 3, 1000L, oldClaim.claimToken()), + "stale claim 的失败登记必须安静退出而不是抛异常"); + assertEquals("PROCESSING", status(taskId), "旧 Worker 的失败登记不得覆盖新 Worker"); + } + + @Test + void requestDuringSameClaim_completionFallsBackToPending() { + Long taskId = newTask(); + LocalDateTime t1 = LocalDateTime.now().withNano(0); + CandidateScoringRefreshTaskExecutor.ClaimedTask claimed = taskExecutor.claim(taskId, t1); + assertNotNull(claimed); + + // 同一次领取期间到达新请求 → version 推进,但所有权标识不变 + requestService.requestRefresh(sid, t1.plusNanos(1)); + assertEquals("PROCESSING", status(taskId), "处理期间新请求不得改变 PROCESSING"); + assertEquals(t1, processingStartedAt(taskId), "处理期间新请求不得改变所有权标识"); + + taskExecutor.materialize(claimed, LocalDateTime.now()); + + assertEquals("PENDING", status(taskId), + "同一 claim 期间收到新请求时,完成路径必须回到 PENDING 而不是 COMPLETED"); + } + + // ── 单任务失败隔离 ───────────────────────────────────────── + + @Test + void processDue_skipsTasksAlreadyClaimedByAnotherWorker_andProcessesTheRest() { + Long taskA = newTask(); + Long taskB = newTaskFor(newStudent("隔离B")); + Long taskC = newTaskFor(newStudent("隔离C")); + + // 模拟 taskA 已被另一个 Worker 持有 + assertNotNull(taskExecutor.claim(taskA, LocalDateTime.now().withNano(0))); + + int processed = worker.processDue(10); + + assertEquals(2, processed, "被并发持有的任务应被跳过,其余任务仍被处理"); + assertEquals("COMPLETED", status(taskB), "同批第二个任务应被处理"); + assertEquals("COMPLETED", status(taskC), "同批第三个任务应被处理"); + } + + @Test + void processDue_batchEndsConsistently_evenWithStaleClaimsPresent() { + Long a = newTask(); + + int processed = worker.processDue(10); + + assertTrue(processed >= 0); + List all = jdbcTemplate.queryForList( + "SELECT status FROM candidate_scoring_refresh_tasks", String.class); + assertFalse(all.contains("PROCESSING"), "批次结束后不得残留 PROCESSING"); + assertEquals("COMPLETED", status(a)); + } + + private Long newStudent(String tag) { + User u = User.builder() + .phone("192" + String.format("%08d", SEQ.incrementAndGet())) + .nickname(tag).role(User.Role.STUDENT).active(true).verified(true).build(); + u.setRoles(java.util.Set.of(User.Role.STUDENT)); + return userRepository.save(u).getId(); + } + + private Long newTaskFor(Long studentId) { + requestService.requestRefresh(studentId, LocalDateTime.now()); + return jdbcTemplate.queryForObject( + "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, studentId); + } + + /** R01-C 阶段二:本类会以**当前 period** 物化快照。残留行会进入同库中其它测试 + * 对"当前期间"的总体统计(如 Fairness 的 populationSize),造成顺序相关误失败。 + * 此处按本类**独占手机号前缀**解析出精确 id,再按外键顺序删除;不使用 deleteAll。 */ + @AfterEach + void cleanupOwnFixture() { + for (String prefix : java.util.List.of("193", "192")) { + for (Long id : jdbcTemplate.queryForList( + "SELECT id FROM users WHERE phone LIKE ?", Long.class, prefix + "%")) { + jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id = ?", id); + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks WHERE student_user_id = ?", id); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); + } + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java new file mode 100644 index 0000000..391460a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java @@ -0,0 +1,428 @@ +package com.mydreamjob.candidatescore.refresh; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * R01-D4-2 claim fence 定向整改:不可复用 claim token。 + * + *

    缺陷背景:旧实现以 {@code processing_started_at} 作为所有权标识,而该列是秒级 + * {@code DATETIME}。当租约回收与重新领取落在同一秒时,新旧 claim 的 fence 完全相同, + * 旧 Worker 的 PENDING 回退会误改新持有者(已由 {@code iv-fence2.sql} 在真实 MySQL 复现)。

    + * + *

    本测试使用固定时间值(两次领取传入完全相同的 {@code LocalDateTime}), + * 不依赖跨秒等待。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class CandidateScoringRefreshClaimTokenTest { + + @Autowired private CandidateScoringRefreshRequestService requestService; + @Autowired private CandidateScoringRefreshTaskExecutor taskExecutor; + @Autowired private CandidateScoringRefreshWorker worker; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong SEQ = new AtomicLong(13000); + + /** 两次领取刻意使用同一个时间值。 */ + private static final LocalDateTime SAME_INSTANT = LocalDateTime.of(2026, 9, 16, 12, 0, 0); + + private Long sid; + private String period; + + private String status(Long taskId) { + return jdbcTemplate.queryForObject( + "SELECT status FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId); + } + + private String claimToken(Long taskId) { + return jdbcTemplate.queryForObject( + "SELECT claim_token FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId); + } + + private int attempts(Long taskId) { + Integer v = jdbcTemplate.queryForObject( + "SELECT attempts FROM candidate_scoring_refresh_tasks WHERE id=?", Integer.class, taskId); + return v == null ? -1 : v; + } + + private LocalDateTime processingStartedAt(Long taskId) { + return jdbcTemplate.queryForObject( + "SELECT processing_started_at FROM candidate_scoring_refresh_tasks WHERE id=?", + LocalDateTime.class, taskId); + } + + private Long newTask() { + requestService.requestRefresh(sid, LocalDateTime.now()); + return jdbcTemplate.queryForObject( + "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); + } + + /** 模拟租约回收(把 PROCESSING 置为 RETRY,processing_started_at 清空)。 */ + private void recover(Long taskId) { + jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='RETRY', " + + "processing_started_at=NULL, attempts=attempts+1, version=version+1 WHERE id=?", taskId); + } + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + User u = User.builder() + .phone("190" + String.format("%08d", SEQ.incrementAndGet())) + .nickname("Token学生").role(User.Role.STUDENT).active(true).verified(true).build(); + u.setRoles(java.util.Set.of(User.Role.STUDENT)); + sid = userRepository.save(u).getId(); + + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + LocalDateTime n = LocalDateTime.now(); + period = String.format("%04d-%02d", n.getYear(), n.getMonthValue()); + } + + @AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id=?", sid); + } + + // ── 缺陷复现:同一时间值下两次领取,旧 Worker 不得影响新持有者 ── + + @Test + void sameTimestamp_staleWorkerPendingRevert_mustNotTouchNewClaim() { + Long taskId = newTask(); + + // Worker A 领取 + CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(a, "A 应领取成功"); + assertEquals(SAME_INSTANT, processingStartedAt(taskId)); + + // 租约回收 → Worker B 在**完全相同的时间值**下重新领取 + recover(taskId); + CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(b, "B 应领取成功"); + assertEquals(SAME_INSTANT, processingStartedAt(taskId), + "两次领取的 processing_started_at 完全相同(当前实现的固有精度)"); + assertEquals("PROCESSING", status(taskId)); + + // A 迟到:完成尝试(version 已变 → 应 0 行) + taskExecutor.materialize(a, SAME_INSTANT); + assertEquals("PROCESSING", status(taskId), + "A 的完成路径不得把 B 的 PROCESSING 改成其它状态"); + + // 再补一次 B 的重领取,确保状态干净(A 的 PENDING 回退是本次的核心缺陷点) + if (!"PROCESSING".equals(status(taskId))) { + recover(taskId); + b = taskExecutor.claim(taskId, SAME_INSTANT); + } + assertNotNull(b); + assertEquals("PROCESSING", status(taskId)); + + // B 正常完成 + taskExecutor.materialize(b, SAME_INSTANT); + assertEquals("COMPLETED", status(taskId), "B 应能正常完成"); + } + + @Test + void sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim() { + Long taskId = newTask(); + CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(a); + recover(taskId); + CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(b); + assertEquals("PROCESSING", status(taskId)); + + // 记录失败登记前的基线(recover() 辅助方法自身会递增 attempts,故用差值判断) + int attemptsBefore = attempts(taskId); + String tokenBefore = claimToken(taskId); + + taskExecutor.recordFailure(taskId, "WRITE_FAILED", SAME_INSTANT, 3, 1000L, a.claimToken()); + + assertEquals("PROCESSING", status(taskId), "A 的失败登记不得覆盖 B 的 PROCESSING"); + assertEquals(attemptsBefore, attempts(taskId), "A 的失败登记不得改动 B 的 attempts"); + assertNull(jdbcTemplate.queryForObject( + "SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId), + "A 的失败登记不得写入 last_error_code"); + assertEquals(tokenBefore, claimToken(taskId), "A 的失败登记不得清空 B 的 claim token"); + } + + // ── claim token 语义 ────────────────────────────────────── + + @Test + void twoClaimsAtSameInstant_getDifferentTokens() { + Long taskId = newTask(); + CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(a); + String ta = claimToken(taskId); + assertNotNull(ta, "领取后必须写入 claim_token"); + + recover(taskId); + CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(b); + String tb = claimToken(taskId); + + assertEquals(SAME_INSTANT, processingStartedAt(taskId), "时间值相同"); + assertNotEquals(ta, tb, "同一时间值下两次领取必须得到不同 claim token"); + } + + @Test + void claim_repeatedOnDifferentTasks_producesDistinctTokens() { + Long t1 = newTask(); + CandidateScoringRefreshTaskExecutor.ClaimedTask c1 = taskExecutor.claim(t1, SAME_INSTANT); + assertNotNull(c1); + String token1 = claimToken(t1); + + Long sid2 = newStudent(); + requestService.requestRefresh(sid2, LocalDateTime.now()); + Long t2 = jdbcTemplate.queryForObject( + "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid2); + CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT); + assertNotNull(c2); + String token2 = claimToken(t2); + + assertNotEquals(token1, token2, "不同任务的 token 必须不同"); + } + + @Test + void currentHolder_canCompleteNormally() { + Long taskId = newTask(); + CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(c); + taskExecutor.materialize(c, SAME_INSTANT); + assertEquals("COMPLETED", status(taskId)); + } + + @Test + void requestDuringSameClaim_versionAdvances_completionFallsBackToPending() { + Long taskId = newTask(); + CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(c); + + requestService.requestRefresh(sid, SAME_INSTANT.plusNanos(1)); + assertEquals("PROCESSING", status(taskId)); + + taskExecutor.materialize(c, SAME_INSTANT); + assertEquals("PENDING", status(taskId), + "同一 claim 期间新请求推进 version → 必须回到 PENDING 而不是 COMPLETED"); + } + + @Test + void staleRecovery_generatesNewTokenOnNextClaim() { + Long taskId = newTask(); + CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(a); + String ta = claimToken(taskId); + + taskExecutor.recoverStale(LocalDateTime.now(), LocalDateTime.now(), 3); + assertEquals("RETRY", status(taskId)); + assertNull(claimToken(taskId), "回收后应清空 claim_token"); + + CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(b); + assertNotEquals(ta, claimToken(taskId), "stale recovery 后的新领取必须生成新 token"); + } + + @Test + void terminalAndPendingStates_clearClaimToken() { + // COMPLETED + Long t1 = newTask(); + CandidateScoringRefreshTaskExecutor.ClaimedTask c1 = taskExecutor.claim(t1, SAME_INSTANT); + assertNotNull(c1); + taskExecutor.materialize(c1, SAME_INSTANT); + assertEquals("COMPLETED", status(t1)); + assertNull(claimToken(t1), "COMPLETED 必须清空 claim_token"); + + // PENDING 回退 + Long sid2 = newStudent(); + requestService.requestRefresh(sid2, LocalDateTime.now()); + Long t2 = jdbcTemplate.queryForObject( + "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid2); + CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT); + assertNotNull(c2); + requestService.requestRefresh(sid2, SAME_INSTANT.plusNanos(1)); + taskExecutor.materialize(c2, SAME_INSTANT); + assertEquals("PENDING", status(t2)); + assertNull(claimToken(t2), "PENDING 回退必须清空 claim_token"); + + // RETRY + Long sid3 = newStudent(); + requestService.requestRefresh(sid3, LocalDateTime.now()); + Long t3 = jdbcTemplate.queryForObject( + "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid3); + CandidateScoringRefreshTaskExecutor.ClaimedTask c3 = taskExecutor.claim(t3, SAME_INSTANT); + assertNotNull(c3); + taskExecutor.recordFailure(t3, "WRITE_FAILED", SAME_INSTANT, 3, 1000L, c3.claimToken()); + assertEquals("RETRY", status(t3)); + assertNull(claimToken(t3), "RETRY 必须清空 claim_token"); + } + + @Test + void failedTerminalState_clearsClaimToken() { + Long taskId = newTask(); + jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET attempts=2 WHERE id=?", taskId); + CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(c); + // maxAttempts=3,attempts=2 → attempts+1 >= 3 → FAILED + taskExecutor.recordFailure(taskId, "COMPUTE_FAILED", SAME_INSTANT, 3, 1000L, c.claimToken()); + assertEquals("FAILED", status(taskId)); + assertNull(claimToken(taskId), "FAILED 必须清空 claim_token"); + } + + @Test + void tokenIsUuidShaped_andNotDerivedFromIds() { + Long taskId = newTask(); + CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(c); + String token = claimToken(taskId); + assertNotNull(token); + assertEquals(36, token.length(), "token 应为固定长度 UUID 字符串"); + assertTrue(token.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + + "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"), + "token 必须为 UUID 形态,实际: " + token); + // token 必须是随机 UUID:不得等于/以任务或学生 ID 起头,也不得其字符串形式 + assertNotEquals(String.valueOf(taskId), token); + assertNotEquals(String.valueOf(sid), token); + assertFalse(token.startsWith(String.valueOf(taskId)), "token 不得由任务 ID 派生"); + assertTrue(token.matches(".*[0-9a-f]{8}-.*"), "token 必须是随机 UUID 形态而非 ID 拼接"); + // 再领一次必须得到不同 token(不可复用)—— 用独立学生,避免 (student,period) 合并 + Long otherStudent = newStudent(); + requestService.requestRefresh(otherStudent, LocalDateTime.now()); + Long t2 = jdbcTemplate.queryForObject( + "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", + Long.class, otherStudent); + CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT); + assertNotNull(c2, "独立任务应可领取"); + assertNotEquals(token, claimToken(t2), "token 必须不可复用"); + } + + // ── 双 Worker 长时间循环 ────────────────────────────────── + + @Test + void doubleWorkerLoop_100Rounds_noOverwrite_noStuckProcessing_noLostRequest() { + final int rounds = 100; + for (int i = 0; i < rounds; i++) { + Long taskId = newTask(); + + // A 领取 + CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(a, "round " + i + ": A 领取失败"); + + // 租约回收 → B 在**同一时间值**下重新领取(fence 时间完全相同) + recover(taskId); + CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(b, "round " + i + ": B 领取失败"); + assertEquals(SAME_INSTANT, processingStartedAt(taskId), "round " + i); + + // A 的三种迟到写:完成 / 失败 / (完成路径内含的 PENDING 回退) + taskExecutor.materialize(a, SAME_INSTANT); + taskExecutor.recordFailure(taskId, "WRITE_FAILED", SAME_INSTANT, 3, 1000L, a.claimToken()); + taskExecutor.materialize(a, SAME_INSTANT); + + assertEquals("PROCESSING", status(taskId), + "round " + i + ": 旧 Worker A 覆盖了新持有者 B"); + assertEquals(b.claimToken(), claimToken(taskId), + "round " + i + ": A 改动了 B 的 claim token"); + + // B 正常完成 + taskExecutor.materialize(b, SAME_INSTANT); + assertEquals("COMPLETED", status(taskId), "round " + i + ": B 未能完成"); + assertNull(claimToken(taskId), "round " + i + ": COMPLETED 未清空 token"); + + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks WHERE id=?", taskId); + } + long stuck = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE status='PROCESSING'", + Long.class); + assertEquals(0L, stuck, "循环结束后不得残留 PROCESSING"); + assertEquals(rounds, 100); + } + + // ── token 不泄露 ───────────────────────────────────────── + + @Test + void claimToken_doesNotLeakIntoAuditOrNotificationTables() { + Long taskId = newTask(); + CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(c); + String token = c.claimToken(); + assertNotNull(token); + + for (String table : List.of("audit_logs", "notifications", "messages")) { + Long hits = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM " + table + " WHERE CAST(" + + ("messages".equals(table) ? "content" : "audit_logs".equals(table) ? "details" : "content") + + " AS VARCHAR) LIKE ?", Long.class, "%" + token + "%"); + assertEquals(0L, hits, table + " 不得出现 claim token"); + } + // 错误分类为固定枚举,不含 token + taskExecutor.recordFailure(taskId, "WRITE_FAILED", SAME_INSTANT, 3, 1000L, "stale-token-value"); + String code = jdbcTemplate.queryForObject( + "SELECT COALESCE(last_error_code,'') FROM candidate_scoring_refresh_tasks WHERE id=?", + String.class, taskId); + assertFalse(code.contains("stale-token-value"), "错误分类不得包含 token"); + assertTrue(List.of("COMPUTE_FAILED", "WRITE_FAILED", "UNKNOWN_REFRESH_FAILURE", "WORKER_TIMEOUT", "") + .contains(code), + "错误分类必须为固定枚举,实际: " + code); + } + + @Test + void claimToken_rowNeverExposedInTaskStatusQueriesUsedByObservability() { + // 任务表本身持有 token 属预期;此处确认 token 不会被写入其它可观测载体 + Long taskId = newTask(); + CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT); + assertNotNull(c); + String token = c.claimToken(); + Long inScores = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM candidate_dimension_scores WHERE CAST(sub_scores_json AS VARCHAR) LIKE ?", + Long.class, "%" + token + "%"); + assertEquals(0L, inScores, "快照 JSON 不得出现 claim token"); + } + + private Long newStudent() { + User u = User.builder() + .phone("189" + String.format("%08d", SEQ.incrementAndGet())) + .nickname("Token学生x").role(User.Role.STUDENT).active(true).verified(true).build(); + u.setRoles(java.util.Set.of(User.Role.STUDENT)); + return userRepository.save(u).getId(); + } + + /** R01-C 阶段二:本类会以**当前 period** 物化快照。残留行会进入同库中其它测试 + * 对"当前期间"的总体统计(如 Fairness 的 populationSize),造成顺序相关误失败。 + * 此处按本类**独占手机号前缀**解析出精确 id,再按外键顺序删除;不使用 deleteAll。 */ + @AfterEach + void cleanupOwnFixture() { + for (String prefix : java.util.List.of("190", "189")) { + for (Long id : jdbcTemplate.queryForList( + "SELECT id FROM users WHERE phone LIKE ?", Long.class, prefix + "%")) { + jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id = ?", id); + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks WHERE student_user_id = ?", id); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); + } + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java new file mode 100644 index 0000000..4a2020f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java @@ -0,0 +1,144 @@ +package com.mydreamjob.candidatescore.refresh; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("integration-test") +class CandidateScoringRefreshFailureTransactionTest { + + @Autowired private CandidateScoringRefreshRequestService requestService; + @Autowired private CandidateScoringRefreshWorker worker; + @Autowired private UserRepository userRepository; + @Autowired private CandidateDimensionScoreRepository snapshotRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private PlatformTransactionManager transactionManager; + @MockBean private CandidateScoringCalculator calculator; + @MockBean private RedisTemplate redisTemplate; + + private Long failingStudent; + private Long successfulStudent; + + @BeforeEach + void setUp() { + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + failingStudent = student("failure-tx-1"); + successfulStudent = student("failure-tx-2"); + when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1"); + when(calculator.computeAll(any(), any())).thenAnswer(invocation -> { + Long studentId = invocation.getArgument(0); + return results(studentId.equals(failingStudent)); + }); + } + + @Test + void databaseWriteFailureRollsBackAllDimensionsAndPersistsRetry() { + requestService.requestRefresh(failingStudent, LocalDateTime.now()); + + assertEquals(0, worker.processDue(10)); + + assertTrue(snapshotRepository.findByStudentUserId(failingStudent).isEmpty(), + "第4维写失败时前三维也必须回滚"); + assertEquals("RETRY", status(failingStudent)); + assertEquals("WRITE_FAILED", error(failingStudent)); + } + + @Test + void oneFailedTaskDoesNotRollbackSuccessfulSibling() { + requestService.requestRefresh(failingStudent, LocalDateTime.now()); + requestService.requestRefresh(successfulStudent, LocalDateTime.now()); + + assertEquals(1, worker.processDue(10)); + + assertEquals("RETRY", status(failingStudent)); + assertEquals("COMPLETED", status(successfulStudent)); + assertEquals(8, snapshotRepository.findByStudentUserId(successfulStudent).size()); + } + + @Test + void outerBusinessRollbackRemovesRefreshRegistration() { + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + transaction.executeWithoutResult(status -> { + requestService.requestRefresh(successfulStudent, LocalDateTime.now()); + status.setRollbackOnly(); + }); + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", + Long.class, successfulStudent); + assertEquals(0L, count); + } + + private Long student(String nickname) { + String phone = "130" + String.format("%08d", Math.abs(System.nanoTime() % 100_000_000L)); + User user = User.builder().phone(phone).nickname(nickname).role(User.Role.STUDENT) + .active(true).verified(true).build(); + user.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + return userRepository.saveAndFlush(user).getId(); + } + + private List results(boolean failAtFourth) { + String[] dimensions = {"EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"}; + List results = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + CandidateScoringCalculator.DimensionResult result = + new CandidateScoringCalculator.DimensionResult(failAtFourth && i == 3 ? null : dimensions[i]); + result.score = BigDecimal.valueOf(50 + i); + result.status = "OK"; + results.add(result); + } + return results; + } + + private String status(Long studentId) { + return jdbcTemplate.queryForObject( + "SELECT status FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", + String.class, studentId); + } + + private String error(Long studentId) { + return jdbcTemplate.queryForObject( + "SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", + String.class, studentId); + } + + /** R01-C 阶段二:本类会以**当前 period** 物化快照。残留行会进入同库中其它测试 + * 对"当前期间"的总体统计(如 Fairness 的 populationSize),造成顺序相关误失败。 + * 此处按本类**独占手机号前缀**解析出精确 id,再按外键顺序删除;不使用 deleteAll。 */ + @AfterEach + void cleanupOwnFixture() { + for (String prefix : java.util.List.of("130")) { + for (Long id : jdbcTemplate.queryForList( + "SELECT id FROM users WHERE phone LIKE ?", Long.class, prefix + "%")) { + jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id = ?", id); + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks WHERE student_user_id = ?", id); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); + } + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java new file mode 100644 index 0000000..4c073ac --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java @@ -0,0 +1,405 @@ +package com.mydreamjob.candidatescore.refresh; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * R01-D4-1 基础设施与写侧接入综合测试(H2 集成)。 + * 覆盖任务登记合并、PROCESSING 防丢失、并发领取、Worker 原子完成、 + * Writer 幂等 version 语义、写侧登记范围与不接入项、GET 零副作用保持。 + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class CandidateScoringRefreshInfrastructureTest { + + @Autowired private CandidateScoringRefreshRequestService requestService; + @Autowired private CandidateScoringRefreshWorker worker; + @Autowired private CandidateScoringRefreshTaskExecutor taskExecutor; + @Autowired private CandidateScoringSnapshotWriter writer; + @Autowired private CandidateScoringRefreshCompensationScanner compensationScanner; + @Autowired private CandidateScoringCalculator calculator; + @Autowired private UserRepository userRepository; + @Autowired private CandidateDimensionScoreRepository snapshotRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + + private Long sid; + private String period; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + User u = User.builder().phone("132" + String.format("%08d", System.nanoTime() % 100_000_000L)) + .nickname("r01d41").role(User.Role.STUDENT) + .active(true).verified(true).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + sid = userRepository.save(u).getId(); + LocalDate d = LocalDate.now(); + period = String.format("%04d-%02d", d.getYear(), d.getMonthValue()); + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_scan_checkpoints"); + } + + private long taskCount() { + Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM candidate_scoring_refresh_tasks", Long.class); + return c == null ? -1 : c; + } + + private String taskStatus() { + return jdbcTemplate.queryForObject( + "SELECT status FROM candidate_scoring_refresh_tasks WHERE student_user_id=? AND target_period=?", + String.class, sid, period); + } + + private LocalDateTime now() { + return LocalDateTime.now(); + } + + // 1. 重复登记只保留一条任务 + @Test + void duplicateRefreshRequestCreatesOneTask() { + requestService.requestRefresh(sid, now()); + LocalDateTime t1 = jdbcTemplate.queryForObject( + "SELECT requested_at FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", LocalDateTime.class, sid); + requestService.requestRefresh(sid, now()); + assertEquals(1, taskCount(), "同学生同周期重复登记只保留一条任务"); + LocalDateTime t2 = jdbcTemplate.queryForObject( + "SELECT requested_at FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", LocalDateTime.class, sid); + assertFalse(t2.isBefore(t1), "重复登记应更新 requested_at 而非新增"); + } + + // 2. COMPLETED 任务再次登记 → 回到 PENDING 且 attempts 归零 + @Test + void completedTaskCanBeRequestedAgain() { + requestService.requestRefresh(sid, now()); + jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='COMPLETED', attempts=5 WHERE student_user_id=?", + sid); + requestService.requestRefresh(sid, now()); + assertEquals("PENDING", taskStatus()); + Integer attempts = jdbcTemplate.queryForObject( + "SELECT attempts FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Integer.class, sid); + assertEquals(0, attempts, "重新登记后 attempts 应归零"); + } + + // 3. PROCESSING 期间新请求不丢失:状态保持 PROCESSING、requested_at 持续推进;完成后可处理 + @Test + void requestDuringProcessingIsNotLost() { + requestService.requestRefresh(sid, now()); + LocalDateTime pst = now().minusSeconds(5); + jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='PROCESSING', processing_started_at=?, " + + "requested_at=? WHERE student_user_id=?", pst, pst, sid); + // 处理期间产生新请求(requested_at 更新,status 保持不变,任务仍唯一) + requestService.requestRefresh(sid, now()); + assertEquals("PROCESSING", taskStatus(), "处理期间新请求不得覆盖 PROCESSING 状态"); + assertEquals(1, taskCount(), "处理期间新请求不得新增任务行"); + // 完成语义:若 requested_at 晚于 processing_started_at,Worker 完成时回退 PENDING(数据可验证) + LocalDateTime req2 = jdbcTemplate.queryForObject( + "SELECT requested_at FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", LocalDateTime.class, sid); + assertTrue(req2.isAfter(pst), "处理期间新请求必须推进 requested_at(不丢失)"); + // 使用真实完成路径:领取版本早于处理期间的新请求,完成时必须回到 PENDING。 + jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='PENDING', processing_started_at=NULL " + + "WHERE student_user_id=?", sid); + Long taskId = jdbcTemplate.queryForObject( + "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); + LocalDateTime claimedAt = now(); + CandidateScoringRefreshTaskExecutor.ClaimedTask claimed = taskExecutor.claim(taskId, claimedAt); + assertNotNull(claimed); + requestService.requestRefresh(sid, claimedAt.plusNanos(1)); + taskExecutor.materialize(claimed, claimedAt); + assertEquals("PENDING", taskStatus(), "处理期间的新请求必须让真实完成路径回到 PENDING"); + } + + // 4. 两个并发 Worker 只能有效处理一次(数据库条件领取) + @Test + void twoWorkersCannotProcessSameTaskTwice() throws Exception { + requestService.requestRefresh(sid, now()); + List ids = jdbcTemplate.queryForList( + "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); + Long taskId = ids.get(0); + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + List> futures = java.util.stream.IntStream.range(0, 2).>mapToObj(i -> + pool.submit(() -> { + ready.countDown(); + start.await(5, TimeUnit.SECONDS); + return worker.processOne(taskId, now()); + })).toList(); + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + boolean r1 = futures.get(0).get(30, TimeUnit.SECONDS); + boolean r2 = futures.get(1).get(30, TimeUnit.SECONDS); + pool.shutdownNow(); + assertEquals(1, (r1 ? 1 : 0) + (r2 ? 1 : 0), "同一任务并发只能有一个 Worker 有效处理"); + List snaps = snapshotRepository.findByStudentUserIdAndPeriod(sid, period); + assertTrue(snaps.size() == 8, "并发处理恰一次物化(8 维)"); + } + + // 6/9. 相同计算值重复 Writer 写入不增 version;Worker 重放同结果不增 + @Test + void sameCalculatedValuesDoNotIncrementSnapshotVersion() { + List results = + calculator.computeAll(sid, now()); + writer.upsertIfChanged(sid, results, period, now(), calculator.currentRulesVersion()); + List r1 = snapshotRepository.findByStudentUserIdAndPeriod(sid, period); + assertEquals(8, r1.size()); + writer.upsertIfChanged(sid, results, period, now(), calculator.currentRulesVersion()); + List r2 = snapshotRepository.findByStudentUserIdAndPeriod(sid, period); + for (int i = 0; i < r1.size(); i++) { + assertEquals(r1.get(i).getVersion(), r2.get(i).getVersion(), "值不变不得推进 version: " + r1.get(i).getDimension()); + } + } + + // 8. rulesVersion 变化强制递增 + @Test + void rulesVersionChangeIncrementsVersion() { + List results = calculator.computeAll(sid, now()); + writer.upsertIfChanged(sid, results, period, now(), "candidate-scoring-v0.1"); + writer.upsertIfChanged(sid, results, period, now(), "candidate-scoring-v0.2"); + List rows = snapshotRepository.findByStudentUserIdAndPeriod(sid, period); + assertTrue(rows.stream().allMatch(s -> s.getVersion() == 1L), "rulesVersion 变化应强制 version+1"); + } + + // 21. 任务错误分类为固定值且不含异常原文/SQL/手机号/堆栈 + @Test + void taskErrorCategoryIsFixedAndSafe() { + requestService.requestRefresh(sid, now()); + Long taskId = jdbcTemplate.queryForObject( + "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); + CandidateScoringRefreshTaskExecutor.ClaimedTask claimed = taskExecutor.claim(taskId, now()); + assertNotNull(claimed); + taskExecutor.recordFailure(taskId, "UNKNOWN_REFRESH_FAILURE", now(), 3, 1000L, claimed.claimToken()); + String code = jdbcTemplate.queryForObject( + "SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId); + assertTrue(Set.of("COMPUTE_FAILED", "WRITE_FAILED", "UNKNOWN_REFRESH_FAILURE", "WORKER_TIMEOUT").contains(code), + "错误分类必须为固定值,实际: " + code); + assertFalse(code.contains("Exception") || code.contains(" at com.mydreamjob"), + "错误分类不得含异常原文/堆栈"); + } + + // 22. Worker 不发布 RabbitMQ/Notification(快照仅有 8 维行,无 notifications 行) + @Test + void workerDoesNotPublishRabbitMqOrNotification() { + requestService.requestRefresh(sid, now()); + long notifBefore = count("notifications"); + worker.processDue(10); + assertEquals(notifBefore, count("notifications"), "Worker 物化不得写 notifications"); + } + + // 20. 普通刷新路径不写 snapshot_history + @Test + void noRefreshPathWritesSnapshotHistory() { + requestService.requestRefresh(sid, now()); + long histBefore = count("candidate_scoring_snapshot_history"); + worker.processDue(10); + assertEquals(histBefore, count("candidate_scoring_snapshot_history"), + "普通物化不得写 candidate_scoring_snapshot_history(仅申诉决定/回滚写入)"); + } + + // 5. 到期 RETRY 重新领取 + 未到期不领取 + @Test + void retryTaskClaimedOnlyWhenDue() { + requestService.requestRefresh(sid, now()); + Long taskId = jdbcTemplate.queryForObject( + "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); + jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='RETRY', next_attempt_at=? WHERE id=?", + now().plusHours(1), taskId); + assertFalse(worker.processOne(taskId, now()), "未到 next_attempt_at 不得领取"); + jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET next_attempt_at=NULL WHERE id=?", taskId); + assertTrue(worker.processOne(taskId, now()), "到期任务应可领取"); + } + + @Test + void staleProcessingLeaseIsRecoveredAndCanBeClaimedAgain() { + requestService.requestRefresh(sid, now()); + Long taskId = jdbcTemplate.queryForObject( + "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); + LocalDateTime claimedAt = now().minusMinutes(20); + assertNotNull(taskExecutor.claim(taskId, claimedAt)); + + assertEquals(1, taskExecutor.recoverStale(now().minusMinutes(10), now(), 3)); + assertEquals("RETRY", taskStatus()); + assertEquals("WORKER_TIMEOUT", jdbcTemplate.queryForObject( + "SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId)); + assertNotNull(taskExecutor.claim(taskId, now()), "恢复后的租约必须可被其他实例重新领取"); + } + + // 无外层事务时登记独立提交成功;外层回滚由专门事务测试覆盖。 + @Test + void requestRegistrationCommitsIndependently() { + requestService.requestRefresh(sid, now()); + assertEquals(1, taskCount()); + } + + @Test + void compensationScanTraversesMoreThanThreePagesWithoutStarvation() { + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + List expected = new java.util.ArrayList<>(); + for (int i = 0; i < 7; i++) { + User u = User.builder().phone("131" + String.format("%08d", i + 7000)) + .nickname("scan-" + i).role(User.Role.STUDENT) + .active(true).verified(true).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + Long id = userRepository.saveAndFlush(u).getId(); + expected.add(id); + jdbcTemplate.update("INSERT INTO audit_logs(user_id,action,created_at) VALUES (?,'LOGIN',?)", + id, now()); + } + jdbcTemplate.update(""" + INSERT INTO candidate_scoring_refresh_scan_checkpoints + (scan_key,last_user_id,version,updated_at) + VALUES ('ACTIVE_STUDENTS',?,0,CURRENT_TIMESTAMP) + """, expected.get(0) - 1L); + + int first = compensationScanner.scanDueStudents(2); + long cursorAfterFirst = jdbcTemplate.queryForObject( + "SELECT last_user_id FROM candidate_scoring_refresh_scan_checkpoints WHERE scan_key='ACTIVE_STUDENTS'", + Long.class); + int second = compensationScanner.scanDueStudents(2); + int third = compensationScanner.scanDueStudents(2); + int fourth = compensationScanner.scanDueStudents(2); + List actual = jdbcTemplate.queryForList( + "SELECT student_user_id FROM candidate_scoring_refresh_tasks WHERE student_user_id IN (" + + String.join(",", java.util.Collections.nCopies(expected.size(), "?")) + ")", + Long.class, expected.toArray()); + + assertEquals(2, first, "单轮必须严格受预算限制"); + assertTrue(cursorAfterFirst > 0L, "游标必须持久化以供下一轮或重启后续扫"); + assertTrue(second <= 2 && third <= 2 && fourth <= 2, "每轮不得突破固定预算"); + assertEquals(Set.copyOf(expected), Set.copyOf(actual), + "持久 keyset 游标必须跨四轮覆盖每个合格学生"); + } + + // ── F4:ABILITY 快照 payload 敏感字段防泄漏(真实 Writer → 真实 DB → 递归断言) ── + + /** 禁止作为 JSON key 出现的字段名(小写精确匹配,避免 questionSetVersion 之类白名单字段误报)。 */ + private static final java.util.List BANNED_KEYS = java.util.List.of( + "answer", "answers", "rawanswer", "rawanswers", + "question", "questions", "questionid", "questioncode", "questionstem", + "option", "options", "optionid", "selectedoption", "selectedoptionid", + "answerkey", "competencytag", "stem", + "scoringrule", "awardedscore", "explanation", + "phone", "email", "mobile", "token", "password", "accesstoken"); + + private void assertNoBannedKeyDeep(com.fasterxml.jackson.databind.JsonNode node, + String path, java.util.List hits) { + if (node == null) return; + if (node.isObject()) { + java.util.Iterator it = node.fieldNames(); + java.util.List names = new java.util.ArrayList<>(); + it.forEachRemaining(names::add); + for (String name : names) { + String lower = name.toLowerCase(); + if (BANNED_KEYS.contains(lower)) hits.add(path + "." + name); + assertNoBannedKeyDeep(node.get(name), path + "." + name, hits); + } + } else if (node.isArray()) { + for (int i = 0; i < node.size(); i++) { + assertNoBannedKeyDeep(node.get(i), path + "[" + i + "]", hits); + } + } + } + + @Test + void abilitySnapshotPayload_hasNoSensitiveFields_andKeepsWhitelistedKeys() throws Exception { + String p = period; + CandidateScoringCalculator.DimensionResult r = + new CandidateScoringCalculator.DimensionResult("ABILITY"); + r.score = new java.math.BigDecimal("74.25"); + r.status = "OK"; + r.abilitySnapshotTrace = new CandidateScoringCalculator.AbilitySnapshotTrace( + 123L, now(), now().plusDays(30), "qs-v1", "ar-v1", + Map.of("KNOWLEDGE", 30, "SKILL", 70), + Map.of("KNOWLEDGE", new java.math.BigDecimal("70.00"), + "SKILL", new java.math.BigDecimal("78.50"))); + + writer.upsertIfChanged(sid, List.of(r), p, now(), "candidate-scoring-v0.2"); + + String json = jdbcTemplate.queryForObject( + "SELECT sub_scores_json FROM candidate_dimension_scores " + + "WHERE student_user_id=? AND dimension='ABILITY' AND period=?", + String.class, sid, p); + assertNotNull(json, "ABILITY 快照 JSON 必须经真实 Writer 写入数据库"); + + com.fasterxml.jackson.databind.JsonNode node = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(json); + + // 正向:白名单字段必须存在且取值正确 + for (String allowed : List.of("source", "sourceResultId", "validUntil", "questionSetVersion", + "assessmentRulesVersion", "mappingRulesVersion", "weights", "dimensionScores")) { + assertTrue(node.has(allowed), "允许字段必须存在: " + allowed); + } + assertEquals("V36_ABILITY_ASSESSMENT", node.get("source").asText()); + assertEquals(123L, node.get("sourceResultId").asLong()); + assertTrue(node.get("weights").has("KNOWLEDGE"), "weights 应保留白名单内的维度键"); + assertTrue(node.get("dimensionScores").has("SKILL"), "dimensionScores 应保留白名单内的维度键"); + + // 负向(递归,含嵌套对象与数组,大小写不敏感) + java.util.List hits = new java.util.ArrayList<>(); + assertNoBannedKeyDeep(node, "$", hits); + assertTrue(hits.isEmpty(), "ABILITY payload 递归不得含敏感字段,命中: " + hits); + + // 负向(原始 JSON 文本层,禁止出现这些 key 字面量) + String lower = json.toLowerCase(); + for (String banned : List.of("answer", "answers", "rawanswer", "questionid", "questioncode", + "optionid", "selectedoption", "answerkey", "competencytag", "stem", + "scoringrule", "awardedscore", "explanation", "phone", "email", "token")) { + assertFalse(lower.contains("\"" + banned + "\""), + "原始 JSON 不得出现敏感 key 字面量: " + banned); + } + } + + private long count(String table) { + Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); + return c == null ? -1 : c; + } + + /** R01-C 阶段二:本类会以**当前 period** 物化快照。残留行会进入同库中其它测试 + * 对"当前期间"的总体统计(如 Fairness 的 populationSize),造成顺序相关误失败。 + * 此处按本类**独占手机号前缀**解析出精确 id,再按外键顺序删除;不使用 deleteAll。 */ + @AfterEach + void cleanupOwnFixture() { + for (String prefix : java.util.List.of("132", "13107")) { + for (Long id : jdbcTemplate.queryForList( + "SELECT id FROM users WHERE phone LIKE ?", Long.class, prefix + "%")) { + jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id = ?", id); + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks WHERE student_user_id = ?", id); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); + } + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshMySqlIntegrationTest.java new file mode 100644 index 0000000..2b0941f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshMySqlIntegrationTest.java @@ -0,0 +1,274 @@ +package com.mydreamjob.candidatescore.refresh; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** R01-D4-1 真实 MySQL 8.4 网关:正式 Flyway、validate、锁与事务语义。 */ +@Testcontainers(disabledWithoutDocker = true) +@SpringBootTest +@ActiveProfiles("mysql-it") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class CandidateScoringRefreshMySqlIntegrationTest { + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("mydreamjob_r01_d4") + .withUsername("r01d4") + .withPassword("r01-d4-test-only"); + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + + @Autowired private CandidateScoringRefreshRequestService requestService; + @Autowired private CandidateScoringRefreshTaskExecutor executor; + @Autowired private CandidateScoringRefreshWorker worker; + @Autowired private CandidateScoringRefreshCompensationScanner compensationScanner; + @Autowired private CandidateScoringSnapshotWriter writer; + @Autowired private CandidateDimensionScoreRepository snapshotRepository; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private CandidateScoringCalculator calculator; + @MockBean private RedisTemplate redisTemplate; + + private Long failingStudent; + private Long successfulStudent; + private final LocalDateTime now = LocalDateTime.of(2026, 9, 16, 12, 0); + + @BeforeEach + void setUp() { + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_scan_checkpoints"); + snapshotRepository.deleteAll(); + failingStudent = student("mysql-failing"); + successfulStudent = student("mysql-success"); + when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1"); + when(calculator.computeAll(any(), any())).thenAnswer(call -> + results(call.getArgument(0).equals(failingStudent))); + } + + @Test + void v44MigrationAndSchemaValidationAreApplied() { + Integer success = jdbcTemplate.queryForObject(""" + SELECT success FROM flyway_schema_history + WHERE version='44' AND description='candidate scoring refresh tasks' + """, Integer.class); + assertEquals(1, success); + Long columns = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='candidate_scoring_refresh_tasks' + """, Long.class); + // V44 建表 13 列;R01-D4-2 claim fence 整改的 V45 追加 claim_token → 14 列 + assertEquals(14L, columns); + Long claimTokenColumns = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='candidate_scoring_refresh_tasks' + AND column_name='claim_token' + """, Long.class); + assertEquals(1L, claimTokenColumns, "V45 必须新增 claim_token 列"); + + // V45 自身必须成功且唯一 + Integer v45 = jdbcTemplate.queryForObject(""" + SELECT success FROM flyway_schema_history WHERE version='45' + """, Integer.class); + assertEquals(1, v45, "V45 必须成功"); + Long v45Rows = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM flyway_schema_history WHERE version='45' + """, Long.class); + assertEquals(1L, v45Rows, "V45 必须唯一"); + + Long checkpointColumns = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='candidate_scoring_refresh_scan_checkpoints' + """, Long.class); + assertEquals(4L, checkpointColumns); + } + + @Test + void mysqlWriterIsIdempotentAndForcePathStillAdvancesVersion() { + List values = results(false); + writer.upsertIfChanged(successfulStudent, values, "2026-09", now, "candidate-scoring-v0.1"); + var first = snapshotRepository.findByStudentUserIdAndPeriod(successfulStudent, "2026-09"); + assertEquals(8, first.size()); + assertTrue(first.stream().allMatch(row -> row.getVersion() == 0L)); + + writer.upsertIfChanged(successfulStudent, values, "2026-09", now.plusMinutes(1), + "candidate-scoring-v0.1"); + var unchanged = snapshotRepository.findByStudentUserIdAndPeriod(successfulStudent, "2026-09"); + assertTrue(unchanged.stream().allMatch(row -> row.getVersion() == 0L)); + + values.get(0).score = new BigDecimal("88.00"); + writer.upsertIfChanged(successfulStudent, values, "2026-09", now.plusMinutes(2), + "candidate-scoring-v0.1"); + var changed = snapshotRepository.findByStudentUserIdAndPeriod(successfulStudent, "2026-09"); + assertEquals(1L, changed.stream().filter(row -> "EDUCATION".equals(row.getDimension())) + .findFirst().orElseThrow().getVersion()); + assertTrue(changed.stream().filter(row -> !"EDUCATION".equals(row.getDimension())) + .allMatch(row -> row.getVersion() == 0L)); + + writer.upsertForcingVersion(successfulStudent, values.get(0), "2026-09", now.plusMinutes(3), + "candidate-scoring-v0.1"); + assertEquals(2L, snapshotRepository.findByStudentUserIdAndPeriod(successfulStudent, "2026-09") + .stream().filter(row -> "EDUCATION".equals(row.getDimension())) + .findFirst().orElseThrow().getVersion()); + } + + @Test + void mysqlConcurrentClaimHasExactlyOneWinner() throws Exception { + requestService.requestRefresh(successfulStudent, now); + Long taskId = taskId(successfulStudent); + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + futures.add(pool.submit(() -> { + ready.countDown(); + start.await(10, TimeUnit.SECONDS); + return executor.claim(taskId, now); + })); + } + assertTrue(ready.await(10, TimeUnit.SECONDS)); + start.countDown(); + long winners = 0; + for (Future future : futures) { + if (future.get(30, TimeUnit.SECONDS) != null) winners++; + } + pool.shutdownNow(); + assertEquals(1L, winners); + } + + @Test + void mysqlFailureRollsBackPartialSnapshotsAndDoesNotRollbackSibling() { + requestService.requestRefresh(failingStudent, now); + requestService.requestRefresh(successfulStudent, now); + + assertEquals(1, worker.processDue(10)); + assertTrue(snapshotRepository.findByStudentUserId(failingStudent).isEmpty()); + assertEquals(8, snapshotRepository.findByStudentUserId(successfulStudent).size()); + assertEquals("RETRY", status(failingStudent)); + assertEquals("WRITE_FAILED", error(failingStudent)); + assertEquals("COMPLETED", status(successfulStudent)); + } + + @Test + void mysqlProcessingReregistrationAndStaleRecoveryDoNotLoseWork() { + requestService.requestRefresh(successfulStudent, now); + Long taskId = taskId(successfulStudent); + var claimed = executor.claim(taskId, now); + assertNotNull(claimed); + requestService.requestRefresh(successfulStudent, now.plusSeconds(1)); + executor.materialize(claimed, now); + assertEquals("PENDING", status(successfulStudent)); + + var staleClaim = executor.claim(taskId, now.minusMinutes(20)); + assertNotNull(staleClaim); + assertEquals(1, executor.recoverStale(now.minusMinutes(10), now, 3)); + assertEquals("RETRY", status(successfulStudent)); + assertNotNull(executor.claim(taskId, now)); + } + + @Test + void mysqlCompensationCursorPersistsAndHonorsPerRunBudget() { + List students = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Long id = student("mysql-scan-" + i); + students.add(id); + jdbcTemplate.update("INSERT INTO audit_logs(user_id,action,created_at) VALUES (?,'LOGIN',?)", id, now); + } + jdbcTemplate.update(""" + INSERT INTO candidate_scoring_refresh_scan_checkpoints + (scan_key,last_user_id,version,updated_at) + VALUES ('ACTIVE_STUDENTS',?,0,CURRENT_TIMESTAMP) + """, students.get(0) - 1L); + + assertEquals(2, compensationScanner.scanDueStudents(2)); + long cursor = jdbcTemplate.queryForObject(""" + SELECT last_user_id FROM candidate_scoring_refresh_scan_checkpoints + WHERE scan_key='ACTIVE_STUDENTS' + """, Long.class); + assertEquals(students.get(1).longValue(), cursor); + assertEquals(2, compensationScanner.scanDueStudents(2)); + assertEquals(1, compensationScanner.scanDueStudents(2)); + Long registered = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM candidate_scoring_refresh_tasks + WHERE student_user_id IN (?,?,?,?,?) + """, Long.class, students.toArray()); + assertEquals(5L, registered); + } + + private Long student(String nickname) { + User user = User.builder() + .phone("131" + String.format("%08d", Math.abs(System.nanoTime() % 100_000_000L))) + .nickname(nickname).role(User.Role.STUDENT).active(true).verified(true).build(); + user.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + return userRepository.saveAndFlush(user).getId(); + } + + private Long taskId(Long studentId) { + return jdbcTemplate.queryForObject( + "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", + Long.class, studentId); + } + + private String status(Long studentId) { + return jdbcTemplate.queryForObject( + "SELECT status FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", + String.class, studentId); + } + + private String error(Long studentId) { + return jdbcTemplate.queryForObject( + "SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", + String.class, studentId); + } + + private List results(boolean failAtFourth) { + String[] dimensions = {"EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"}; + List values = new ArrayList<>(); + for (int i = 0; i < dimensions.length; i++) { + var value = new CandidateScoringCalculator.DimensionResult(failAtFourth && i == 3 ? null : dimensions[i]); + value.score = BigDecimal.valueOf(60L + i); + value.status = "OK"; + values.add(value); + } + return values; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorkerLoopIsolationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorkerLoopIsolationTest.java new file mode 100644 index 0000000..9cc72c0 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorkerLoopIsolationTest.java @@ -0,0 +1,110 @@ +package com.mydreamjob.candidatescore.refresh; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * R01-D4-2 次生缺陷:{@code processDue} 的批次循环必须逐任务隔离。 + * + *

    缺陷背景:{@code recordFailure} 更新 0 行时抛 IllegalStateException,该异常在 + * Worker 的 catch 块内抛出,而 {@code processDue} 的 for 循环没有保护 —— 会导致同批 + * 剩余任务本轮全部不被处理。

    + * + *

    纯单元测试:精确控制"第一项失败且失败登记也抛异常"的时序,不依赖 sleep 或 JVM 锁。

    + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CandidateScoringRefreshWorkerLoopIsolationTest { + + @Mock private JdbcTemplate jdbcTemplate; + @Mock private CandidateScoringRefreshTaskExecutor taskExecutor; + + @InjectMocks private CandidateScoringRefreshWorker worker; + + /** 固定时间,保证 record 的 equals 在参数匹配时可稳定比较。 */ + private static final LocalDateTime CLAIMED_AT = LocalDateTime.of(2026, 9, 16, 12, 0); + + private CandidateScoringRefreshTaskExecutor.ClaimedTask claim(long id) { + return new CandidateScoringRefreshTaskExecutor.ClaimedTask(id, id * 10, "2026-09", + CLAIMED_AT, "token-" + id, 0L); + } + + @Test + void recordFailureThrowing_doesNotAbortRemainingTasksInBatch() { + when(jdbcTemplate.queryForList(anyString(), eq(Long.class), any(), anyInt())) + .thenReturn(List.of(1L, 2L, 3L)); + when(taskExecutor.claim(anyLong(), any(LocalDateTime.class))) + .thenAnswer(inv -> claim(inv.getArgument(0))); + when(taskExecutor.recoverStale(any(), any(), anyInt())).thenReturn(0); + + // 第一个任务计算失败 + doThrow(new RuntimeException("compute blew up")) + .when(taskExecutor).materialize(eq(claim(1L)), any(LocalDateTime.class)); + // 且其失败登记也抛异常(旧实现会抛出 IllegalStateException) + doThrow(new IllegalStateException("refresh task failure state conflict")) + .when(taskExecutor).recordFailure(eq(1L), anyString(), any(LocalDateTime.class), + anyInt(), anyLong(), anyString()); + + worker.processDue(10); + + // 关键:后续两个任务必须仍被处理 + verify(taskExecutor, times(1)).materialize(eq(claim(2L)), any(LocalDateTime.class)); + verify(taskExecutor, times(1)).materialize(eq(claim(3L)), any(LocalDateTime.class)); + } + + @Test + void recordFailureThrowingInMiddle_doesNotAbortRemainingTasks() { + when(jdbcTemplate.queryForList(anyString(), eq(Long.class), any(), anyInt())) + .thenReturn(List.of(11L, 12L, 13L)); + when(taskExecutor.claim(anyLong(), any(LocalDateTime.class))) + .thenAnswer(inv -> claim(inv.getArgument(0))); + when(taskExecutor.recoverStale(any(), any(), anyInt())).thenReturn(0); + + doThrow(new RuntimeException("boom")).when(taskExecutor) + .materialize(eq(claim(12L)), any(LocalDateTime.class)); + doThrow(new IllegalStateException("state conflict")).when(taskExecutor) + .recordFailure(eq(12L), anyString(), any(LocalDateTime.class), anyInt(), anyLong(), + anyString()); + + worker.processDue(10); + + verify(taskExecutor, times(1)).materialize(eq(claim(11L)), any(LocalDateTime.class)); + verify(taskExecutor, times(1)).materialize(eq(claim(13L)), any(LocalDateTime.class)); + } + + @Test + void claimLosing_skipsTaskWithoutRecordingFailure() { + when(jdbcTemplate.queryForList(anyString(), eq(Long.class), any(), anyInt())) + .thenReturn(List.of(21L, 22L)); + when(taskExecutor.recoverStale(any(), any(), anyInt())).thenReturn(0); + // 21 被并发 Worker 抢走(claim 返回 null) + when(taskExecutor.claim(eq(21L), any(LocalDateTime.class))).thenReturn(null); + when(taskExecutor.claim(eq(22L), any(LocalDateTime.class))).thenReturn(claim(22L)); + + worker.processDue(10); + + verify(taskExecutor, never()).recordFailure(eq(21L), anyString(), any(LocalDateTime.class), + anyInt(), anyLong(), anyString()); + verify(taskExecutor, times(1)).materialize(eq(claim(22L)), any(LocalDateTime.class)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java index e9d2ff7..c750f5e 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java @@ -182,8 +182,7 @@ class CareerProgressControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } // ── 权限矩阵 ────────────────────────────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java index 767447f..e4becbb 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java @@ -82,7 +82,10 @@ class CareerProgressServiceTest { @BeforeEach void setUp() { calculator = spy(new CompetitionRankingCalculator(dimensionScoreRepository, scoringCalculator)); - service = new CareerProgressService(applicationRepository, interviewRepository, + // R01-B/B1:角色授权已收束到 UserRoleGuard。此处装配**真实** guard 并复用本测试 + // 已 stub 的 userRepository,使断言目标(角色不符 → FORBIDDEN)与收束前完全一致。 + service = new CareerProgressService(new com.mydreamjob.security.UserRoleGuard(userRepository), + applicationRepository, interviewRepository, interviewScoreRepository, offerRepository, notificationRepository, jobRepository, companyRepository, userRepository, calculator, scoringCalculator); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java index 5770dc1..abeacd1 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java @@ -19,7 +19,7 @@ class GlobalExceptionHandlerAccountDeletionTest { @BeforeEach void setUp() { - handler = new GlobalExceptionHandler(); + handler = new GlobalExceptionHandler(new com.mydreamjob.common.logging.RequestIdAccessor(), new ErrorHttpStatusRegistry()); request = new MockHttpServletRequest(); request.setRequestURI("/api/v1/profile/me/deletion"); } @@ -42,4 +42,4 @@ class GlobalExceptionHandlerAccountDeletionTest { assertEquals(code.getCode(), resp.getBody().getCode()); } } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java index e21eadf..88f613e 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java @@ -16,7 +16,7 @@ class GlobalExceptionHandlerHttpStatusTest { @BeforeEach void setUp() { - handler = new GlobalExceptionHandler(); + handler = new GlobalExceptionHandler(new com.mydreamjob.common.logging.RequestIdAccessor(), new ErrorHttpStatusRegistry()); request = new MockHttpServletRequest(); request.setRequestURI("/api/v1/test"); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java index b37edcb..deb6f8a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java @@ -22,7 +22,7 @@ class GlobalExceptionHandlerReportBlockTest { @BeforeEach void setUp() { - handler = new GlobalExceptionHandler(); + handler = new GlobalExceptionHandler(new com.mydreamjob.common.logging.RequestIdAccessor(), new ErrorHttpStatusRegistry()); request = new MockHttpServletRequest(); request.setRequestURI("/api/v1/reports"); } @@ -87,4 +87,4 @@ class GlobalExceptionHandlerReportBlockTest { void blockedSend_externallyUsesForbidden_shouldReturnHttp403() { assertEquals(HttpStatus.FORBIDDEN.value(), statusOf(ErrorCode.FORBIDDEN)); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java new file mode 100644 index 0000000..27112ca --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java @@ -0,0 +1,106 @@ +package com.mydreamjob.common.exception; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.logging.RequestIdAccessor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** Locks the pre-R01-B public HTTP contract for every existing business error code. */ +class GlobalExceptionHandlerStatusContractTest { + + private static final Set FORBIDDEN = Set.of( + "FORBIDDEN", "INTERVIEW_ACCESS_DENIED", "APPLICATION_OWNER_MISMATCH", + "MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER"); + + private static final Set NOT_FOUND = Set.of( + "COMPANY_NOT_FOUND", "PROFILE_NOT_FOUND", "JOB_NOT_FOUND", "APPLICATION_NOT_FOUND", + "OFFER_NOT_FOUND", "OFFER_COUNTER_OFFER_NOT_FOUND", "RESUME_VERSION_NOT_FOUND", + "RESUME_EXPORT_TASK_NOT_FOUND", "USER_NOT_FOUND", "REVIEW_NOT_FOUND", + "FEEDBACK_NOT_FOUND", "CONVERSATION_NOT_FOUND", "MESSAGE_NOT_FOUND", + "APPEAL_NOT_FOUND", "DATA_EXPORT_TASK_NOT_FOUND", "REVIEW_REPLY_NOT_FOUND", + "TALENT_INVITATION_NOT_FOUND", "VERIFICATION_NOT_FOUND", "INTERVIEW_SCORE_NOT_FOUND", + "REPORT_NOT_FOUND", "ASSESSMENT_TYPE_NOT_FOUND", "ASSESSMENT_ATTEMPT_NOT_FOUND", + "ABILITY_TYPE_NOT_FOUND", "ABILITY_ATTEMPT_NOT_FOUND", "ABILITY_RESULT_NOT_FOUND", + "RISK_RULE_NOT_FOUND", "RISK_EVENT_NOT_FOUND"); + + private static final Set CONFLICT = Set.of( + "COMPANY_ALREADY_EXISTS", "APPLICATION_DUPLICATE", "INVALID_APPLICATION_STATUS_TRANSITION", + "INVALID_JOB_STATUS_TRANSITION", "DUPLICATE_REVIEW", "DUPLICATE_FEEDBACK", + "DUPLICATE_OFFER", "INVALID_OFFER_STATUS_TRANSITION", "OFFER_EXPIRED", + "OFFER_NOT_EDITABLE", "OFFER_REMINDER_DUPLICATE", "OFFER_COUNTER_OFFER_EXISTS", + "OFFER_COUNTER_OFFER_NOT_PENDING", "OFFER_COUNTER_OFFER_EXPIRED", + "DEFAULT_RESUME_DELETE_NOT_ALLOWED", "RESUME_EXPORT_IN_PROGRESS", + "DUPLICATE_DEFAULT_RESUME", "RESUME_EXPORT_NOT_READY", "MATCH_CLOSED", + "MATCH_SCORES_LOCKED", "APPLICATION_NOT_MATCHED", "REVIEW_ALREADY_DECIDED", + "INVALID_INTERVIEW_STATUS_TRANSITION", "INTERVIEW_COMPLETE_NOT_READY", + "INTERVIEW_ALREADY_RESPONDED", "REVIEW_REPLY_ALREADY_EXISTS", + "REVIEW_REPLY_INVALID_STATE", "REVIEW_REPLY_CONCURRENT_UPDATE", + "REVIEW_REPLY_IDEMPOTENCY_CONFLICT", "MESSAGE_RECALL_WINDOW_EXPIRED", + "MESSAGE_RECALL_CONCURRENT_UPDATE", "TALENT_INVITATION_CANDIDATE_UNAVAILABLE", + "TALENT_INVITATION_ALREADY_ACTIVE", "TALENT_INVITATION_INVALID_STATE", + "TALENT_INVITATION_EXPIRED", "TALENT_INVITATION_CONCURRENT_UPDATE", + "TALENT_INVITATION_IDEMPOTENCY_CONFLICT", "VERIFICATION_ALREADY_PENDING", + "VERIFICATION_ALREADY_APPROVED", "INVALID_VERIFICATION_STATUS_TRANSITION", + "REVIEW_WEIGHT_CONCURRENT_UPDATE", "PRIVACY_SETTINGS_CONCURRENT_UPDATE", + "INVALID_DELETION_STATE", "ACCOUNT_DELETION_PENDING", "ACCOUNT_DELETED", + "ACCOUNT_DELETION_CONCURRENT_UPDATE", "ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED", + "LAST_ADMIN_CANNOT_DELETE", "APPEAL_DUPLICATE", "APPEAL_ALREADY_DECIDED", + "APPEAL_ROLLBACK_LIMIT_EXCEEDED", "APPEAL_INVALID_TRANSITION", + "APPEAL_IDEMPOTENCY_CONFLICT", "DATA_EXPORT_DUPLICATE", "DATA_EXPORT_NOT_READY", + "DATA_EXPORT_EXPIRED", "DATA_EXPORT_FAILED", "DATA_EXPORT_FILE_NOT_FOUND", + "INTERVIEW_SCORE_CONCURRENT_UPDATE", "INTERVIEW_NOT_COMPLETED", + "INTERVIEW_SCORE_SUBMITTED", "REPORT_DUPLICATE", "REPORT_ALREADY_DECIDED", + "REPORT_INVALID_TRANSITION", "REPORT_IDEMPOTENCY_CONFLICT", + "ASSESSMENT_ATTEMPT_INVALID_STATE", "ASSESSMENT_CONCURRENT_UPDATE", + "ASSESSMENT_DUPLICATE_ACTIVE", "ASSESSMENT_IDEMPOTENCY_CONFLICT", + "ABILITY_ATTEMPT_INVALID_STATE", "ABILITY_CONCURRENT_UPDATE", "ABILITY_DUPLICATE_ACTIVE", + "ABILITY_IDEMPOTENCY_CONFLICT", "ABILITY_RETAKE_COOLDOWN", + "RISK_RULE_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", "RISK_INVALID_ACTION"); + + private static final Set TOO_MANY_REQUESTS = Set.of( + "REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED"); + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), new ErrorHttpStatusRegistry()); + + @ParameterizedTest(name = "{0}") + @EnumSource(ErrorCode.class) + void everyExistingErrorCodeKeepsItsHttpStatusAndResponseFields(ErrorCode errorCode) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/v1/contract"); + request.setAttribute("requestId", "r01b-contract"); + + ResponseEntity> response = + handler.handleBusinessException(new BusinessException(errorCode), request); + + assertEquals(expectedStatus(errorCode), response.getStatusCode(), errorCode.name()); + assertNotNull(response.getBody()); + assertEquals(errorCode.getCode(), response.getBody().getCode()); + assertEquals(errorCode.getMessage(), response.getBody().getMessage()); + assertEquals("r01b-contract", response.getBody().getRequestId()); + } + + @Test + void unknownBusinessCodeKeepsBadRequestFallback() { + // R01-B/B2:解析入口由 GlobalExceptionHandler 的私有方法收束到 ErrorHttpStatusRegistry, + // 断言目标(未知码仍回退 400)保持不变,不再依赖反射。 + assertEquals(HttpStatus.BAD_REQUEST, new ErrorHttpStatusRegistry().resolve(123456789)); + } + + private HttpStatus expectedStatus(ErrorCode errorCode) { + String name = errorCode.name(); + if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN; + if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND; + if (CONFLICT.contains(name)) return HttpStatus.CONFLICT; + if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS; + return HttpStatus.BAD_REQUEST; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java new file mode 100644 index 0000000..918a393 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java @@ -0,0 +1,386 @@ +package com.mydreamjob.common.logging; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.auth.support.TestAuthTokens; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +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.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.authentication.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.MvcResult; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +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-A:请求上下文收束的**端到端**证据。 + * + *

    使用真实的 {@code SecurityFilterChain}、{@link RequestIdFilter} 与 MVC 派发, + * 而不是 Mock 掉新组件后断言 mock 值。覆盖:requestId 在响应头 / 响应体 / MDC / + * {@code AuditContext} 中同源、并发不串号、过滤器结束后清理 MDC、八类非法 principal + * 得到 401 而非 500、错误响应不回显 principal、拒绝请求不产生业务写入。

    + * + *

    被迁移的 8 个 Controller 全部被真实调用一次。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class RequestContextMigrationIntegrationTest { + + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + private static final String BAD_PRINCIPAL = "INTERNAL_BAD_PRINCIPAL_SECRET"; + /** + * 测试专属手机号段(986_000_000 起)。 + * + *

    隔离修复:此前使用 930_000_000 与 {@code AccountDeletionControllerTest} 撞段, + * 且本类不清理所创建的 users,导致共享 H2 库(integrationdb)中残留该号码, + * 使后运行的同类首次 createUser 触发唯一键冲突。

    + */ + private static final AtomicLong PHONE = new AtomicLong(986_000_000L); + + /** 8 个已迁移 Controller 各自的真实入口 + 预期状态码(STUDENT 身份)。 */ + private static final List ENDPOINTS = List.of( + new MigratedEndpoint("/profile/me", 200, "ProfileController"), + new MigratedEndpoint("/profile/me/preferences", 200, "PreferenceController"), + new MigratedEndpoint("/profile/me/privacy", 200, "PrivacySettingsController"), + new MigratedEndpoint("/profile/me/resume-versions", 200, "ResumeVersionController"), + new MigratedEndpoint("/profile/me/data-export-tasks", 200, "PersonalDataExportController"), + new MigratedEndpoint("/assessments/me", 200, "AssessmentController"), + new MigratedEndpoint("/abilities/me", 200, "AbilityController"), + new MigratedEndpoint("/jobs/99999999/competition-ranking", 404, "CompetitionRankingController")); + + private record MigratedEndpoint(String path, int expectedStatus, String controller) { + } + + /** 在真实 MVC 派发过程中抓取 MDC 与 AuditContext,避免只看过滤器自身。 */ + static final class RequestContextProbe { + static final ThreadLocal MDC_VALUE = new ThreadLocal<>(); + static final ThreadLocal AUDIT_VALUE = new ThreadLocal<>(); + + static void clear() { + MDC_VALUE.remove(); + AUDIT_VALUE.remove(); + } + } + + @TestConfiguration + static class RequestContextProbeConfig implements WebMvcConfigurer { + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new HandlerInterceptor() { + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + RequestContextProbe.MDC_VALUE.set(MDC.get(RequestIdFilter.REQUEST_ID_ATTRIBUTE)); + RequestContextProbe.AUDIT_VALUE.set(AuditContext.from(request).getRequestId()); + return true; + } + }); + } + } + + @Autowired private MockMvc mockMvc; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + @MockBean private RedisTemplate redisTemplate; + + private final ObjectMapper mapper = new ObjectMapper(); + private long studentId; + + @BeforeEach + void setUp() { + studentId = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .nickname("r01a-context-student") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true).verified(true).build()).getId(); + } + + @AfterEach + void tearDown() { + RequestContextProbe.clear(); + MDC.clear(); + // 隔离修复:精确清理本测试创建的 user(仅本测试记录,不整表删除), + // 避免残留污染共享 H2 库(integrationdb)中后运行的其他集成测试类。 + if (studentId > 0) { + userRepository.findById(studentId).ifPresent(userRepository::delete); + studentId = 0L; + } + } + + // ── 3. 响应头、响应体、MDC、AuditContext 同源 ──────────────── + + @Test + void requestIdIsIdenticalAcrossHeaderBodyMdcAndAuditContext() throws Exception { + String requestId = "r01a-consistent-001"; + + for (MigratedEndpoint endpoint : ENDPOINTS) { + RequestContextProbe.clear(); + MvcResult result = mockMvc.perform(get(endpoint.path()) + .header("Authorization", studentToken()) + .header(REQUEST_ID_HEADER, requestId)) + .andExpect(status().is(endpoint.expectedStatus())) + .andReturn(); + + String where = endpoint.controller() + " " + endpoint.path(); + assertThat(result.getResponse().getHeader(REQUEST_ID_HEADER)) + .as("%s:响应头", where).isEqualTo(requestId); + assertThat(bodyRequestId(result)) + .as("%s:响应体 requestId", where).isEqualTo(requestId); + assertThat(RequestContextProbe.MDC_VALUE.get()) + .as("%s:MDC requestId", where).isEqualTo(requestId); + assertThat(RequestContextProbe.AUDIT_VALUE.get()) + .as("%s:AuditContext requestId", where).isEqualTo(requestId); + } + } + + @Test + void overlongRequestIdIsBoundedAndStillConsistentEverywhere() throws Exception { + String overlong = "L".repeat(120); + + RequestContextProbe.clear(); + MvcResult result = mockMvc.perform(get("/profile/me/privacy") + .header("Authorization", studentToken()) + .header(REQUEST_ID_HEADER, overlong)) + .andExpect(status().isOk()) + .andReturn(); + + String expected = overlong.substring(0, 32); + assertThat(result.getResponse().getHeader(REQUEST_ID_HEADER)).isEqualTo(expected); + assertThat(bodyRequestId(result)).isEqualTo(expected); + assertThat(RequestContextProbe.MDC_VALUE.get()).isEqualTo(expected); + assertThat(RequestContextProbe.AUDIT_VALUE.get()).isEqualTo(expected); + assertThat(bodyRequestId(result)).isNotEqualTo(overlong); + } + + // ── 4. 并发不串号 ──────────────────────────────────────────── + + @Test + void concurrentRequestsDoNotLeakRequestIdAcrossThreads() throws Exception { + int concurrency = 8; + ExecutorService pool = Executors.newFixedThreadPool(concurrency); + CountDownLatch start = new CountDownLatch(1); + ConcurrentLinkedQueue failures = new ConcurrentLinkedQueue<>(); + + try { + List> futures = new ArrayList<>(); + for (int i = 0; i < concurrency; i++) { + String expected = "r01a-concurrent-" + i; + futures.add(pool.submit(() -> { + try { + start.await(); + MvcResult result = mockMvc.perform(get("/profile/me/preferences") + .header("Authorization", studentToken()) + .header(REQUEST_ID_HEADER, expected)) + .andExpect(status().isOk()) + .andReturn(); + String header = result.getResponse().getHeader(REQUEST_ID_HEADER); + String body = bodyRequestId(result); + if (!expected.equals(header) || !expected.equals(body)) { + failures.add(expected + " -> header=" + header + ", body=" + body); + } + } catch (Exception e) { + failures.add(expected + " -> " + e); + } + return null; + })); + } + start.countDown(); + for (var future : futures) { + future.get(60, TimeUnit.SECONDS); + } + } finally { + pool.shutdownNow(); + } + + assertThat(failures).as("并发请求不得互相串用 requestId").isEmpty(); + } + + // ── 5. 过滤器结束后清理 MDC ───────────────────────────────── + + @Test + void mdcIsClearedAfterRequestCompletes() throws Exception { + mockMvc.perform(get("/profile/me/privacy") + .header("Authorization", studentToken()) + .header(REQUEST_ID_HEADER, "r01a-mdc-cleanup")) + .andExpect(status().isOk()); + + assertThat(MDC.get(RequestIdFilter.REQUEST_ID_ATTRIBUTE)) + .as("请求结束后不得残留 requestId").isNull(); + } + + // ── 6./7. 非法 principal → 401,不回显 principal ────────────── + + @Test + void illegalPrincipalsReturn401InsteadOf500AndNeverEchoThePrincipal() throws Exception { + List cases = List.of( + new PrincipalCase("匿名令牌", new AnonymousAuthenticationToken( + "r01a-key", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))), + new PrincipalCase("已认证但名称为 anonymousUser", + UsernamePasswordAuthenticationToken.authenticated("anonymousUser", null, List.of())), + new PrincipalCase("空白主体", + UsernamePasswordAuthenticationToken.authenticated(" ", null, List.of())), + new PrincipalCase("非数字主体", + UsernamePasswordAuthenticationToken.authenticated(BAD_PRINCIPAL, null, List.of())), + new PrincipalCase("Unicode 数字主体", + UsernamePasswordAuthenticationToken.authenticated("٤٢", null, List.of())), + new PrincipalCase("带正号主体", + UsernamePasswordAuthenticationToken.authenticated("+42", null, List.of())), + new PrincipalCase("带负号主体", + UsernamePasswordAuthenticationToken.authenticated("-7", null, List.of())), + new PrincipalCase("零主体", + UsernamePasswordAuthenticationToken.authenticated("0", null, List.of())), + new PrincipalCase("负数主体", + UsernamePasswordAuthenticationToken.authenticated("-1", null, List.of())), + new PrincipalCase("Long 溢出主体", + UsernamePasswordAuthenticationToken.authenticated("99999999999999999999", null, List.of()))); + + for (PrincipalCase principalCase : cases) { + MvcResult result = mockMvc.perform(get("/profile/me/privacy") + .with(authentication(principalCase.authentication())) + .header(REQUEST_ID_HEADER, "r01a-bad-principal")) + .andReturn(); + + String as = principalCase.label(); + assertThat(result.getResponse().getStatus()) + .as("%s:必须是 401 而不是 5xx(迁移前会因 NumberFormatException 逃逸为 500)", as) + .isEqualTo(401); + assertThat(result.getResponse().getContentAsString()) + .as("%s:错误响应不得回显 principal", as) + .doesNotContain(BAD_PRINCIPAL) + .doesNotContain("anonymousUser") + .doesNotContain("NumberFormatException") + .doesNotContain("at com.mydreamjob"); + } + } + + @Test + void noCredentialsAtAllReturns401WithoutServerError() throws Exception { + MvcResult result = mockMvc.perform(get("/profile/me/privacy") + .header(REQUEST_ID_HEADER, "r01a-no-credentials")) + .andReturn(); + + assertThat(result.getResponse().getStatus()).isEqualTo(401); + assertThat(result.getResponse().getContentAsString()).doesNotContain("at com.mydreamjob"); + } + + /** + * 反例边界:一个「非匿名但 isAuthenticated()==false」的合成令牌会在**安全过滤器链** + * 就被判定为拒绝(403),根本到不了 Controller 与 {@code CurrentUserAccessor}。 + * + *

    这是迁移前既有的链行为,R01-A 明确不把原 403 改成 401。此处只锁定 + * 「不是 5xx、不回显 principal」,避免为了凑一个 401 而改动安全语义。

    + */ + @Test + void syntheticUnauthenticatedTokenIsRejectedByChainWithoutServerError() throws Exception { + Authentication synthetic = new UsernamePasswordAuthenticationToken(BAD_PRINCIPAL, null); + + MvcResult result = mockMvc.perform(get("/profile/me/privacy") + .with(authentication(synthetic)) + .header(REQUEST_ID_HEADER, "r01a-synthetic")) + .andReturn(); + + int status = result.getResponse().getStatus(); + assertThat(status) + .as("必须是 4xx 客户端拒绝,不得是 5xx") + .isBetween(400, 499) + .isNotEqualTo(200); + assertThat(result.getResponse().getContentAsString()) + .as("错误响应不得回显 principal") + .doesNotContain(BAD_PRINCIPAL) + .doesNotContain("at com.mydreamjob"); + } + + private record PrincipalCase(String label, Authentication authentication) { + } + + // ── 9. 拒绝请求无业务写入 ──────────────────────────────────── + + @Test + void rejectedRequestsPerformNoBusinessWrite() throws Exception { + List tables = List.of( + new BusinessTable("student_privacy_settings", "student_user_id"), + new BusinessTable("student_preferences", "student_user_id"), + new BusinessTable("resume_versions", "user_id"), + new BusinessTable("personal_data_export_tasks", "user_id"), + new BusinessTable("assessment_attempts", "student_user_id"), + new BusinessTable("ability_attempts", "student_user_id")); + var before = rowCounts(tables, studentId); + + mockMvc.perform(get("/profile/me/privacy") + .with(authentication(UsernamePasswordAuthenticationToken.authenticated( + BAD_PRINCIPAL, null, List.of())))) + .andExpect(status().isUnauthorized()); + mockMvc.perform(get("/profile/me/privacy")) + .andExpect(status().isUnauthorized()); + mockMvc.perform(get("/profile/me/preferences") + .with(authentication(new AnonymousAuthenticationToken( + "r01a-key", "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))))) + .andExpect(status().isUnauthorized()); + + assertThat(rowCounts(tables, studentId)) + .as("被拒绝的请求不得写入任何业务表") + .isEqualTo(before); + } + + private record BusinessTable(String name, String ownerColumn) { + } + + private List rowCounts(List tables, long userId) { + List counts = new ArrayList<>(); + for (BusinessTable table : tables) { + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM " + table.name() + " WHERE " + table.ownerColumn() + " = ?", + Long.class, userId); + counts.add(table.name() + "=" + count); + } + return counts; + } + + // ── 辅助 ───────────────────────────────────────────────────── + + private String studentToken() { + return TestAuthTokens.bearer(studentId, "STUDENT"); + } + + private String bodyRequestId(MvcResult result) throws Exception { + JsonNode root = mapper.readTree(result.getResponse().getContentAsString()); + JsonNode requestId = root.get("requestId"); + return requestId == null ? null : requestId.asText(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java new file mode 100644 index 0000000..85e6874 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java @@ -0,0 +1,150 @@ +package com.mydreamjob.common.logging; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * R01-A requestId 读取器边界。 + * + *

    核心约束:accessor 只能复用 {@link RequestIdFilter} 产出的规范化值, + * 不得自建第二套生成规则,也不得把超长/非法客户端头原样返回。

    + */ +class RequestIdAccessorTest { + + private final RequestIdFilter filter = new RequestIdFilter(); + private final RequestIdAccessor accessor = new RequestIdAccessor(); + + // ── 1. 合法客户端 requestId 原样使用 ──────────────────────── + + @Test + void legalClientRequestIdIsUsedVerbatimAcrossAllOutputs() throws Exception { + String clientId = "client-abc_123"; + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, clientId); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference seenByController = new AtomicReference<>(); + AtomicReference seenInMdc = new AtomicReference<>(); + + filter.doFilter(request, response, (wrapped, ignored) -> { + HttpServletRequest req = (HttpServletRequest) wrapped; + seenByController.set(accessor.get(req)); + seenInMdc.set(org.slf4j.MDC.get(RequestIdFilter.REQUEST_ID_ATTRIBUTE)); + }); + + assertEquals(clientId, seenByController.get(), "合法客户端值必须原样使用"); + assertEquals(seenByController.get(), response.getHeader(RequestIdFilter.REQUEST_ID_HEADER), + "响应头必须与 Controller 读到的一致"); + assertEquals(seenByController.get(), seenInMdc.get(), "MDC 必须与 Controller 读到的一致"); + } + + // ── 2. 缺失 / 空白 / 超长时使用过滤器规范化值 ──────────────── + + @Test + void missingBlankAndOverlongIdsUseFilterNormalizedValue() throws Exception { + record Case(String header, int expectedLength, String label) { + } + Case[] cases = { + new Case(null, 16, "缺失"), + new Case(" ", 16, "纯空白"), + new Case("\t", 16, "制表符"), + new Case("x".repeat(33), 32, "刚好超一位"), + new Case("y".repeat(200), 32, "远超声明的 32 字符上限"), + }; + + for (Case testCase : cases) { + MockHttpServletRequest request = new MockHttpServletRequest(); + if (testCase.header() != null) { + request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, testCase.header()); + } + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference seenByController = new AtomicReference<>(); + + filter.doFilter(request, response, (wrapped, ignored) -> + seenByController.set(accessor.get((HttpServletRequest) wrapped))); + + String value = seenByController.get(); + assertNotNull(value, testCase.label() + ":必须有值"); + assertEquals(testCase.expectedLength(), value.length(), testCase.label() + ":长度必须被收敛"); + assertEquals(response.getHeader(RequestIdFilter.REQUEST_ID_HEADER), value, + testCase.label() + ":与响应头同源"); + if (testCase.header() == null) { + continue; + } + assertNotEquals(testCase.header(), value, testCase.label() + ":不得原样回显客户端头"); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "bad id", "bad\tid", "bad\nid", "bad\rid", "中文", "", "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); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/RequestContextMigrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/RequestContextMigrationIntegrationTest.java new file mode 100644 index 0000000..ac20570 --- /dev/null +++ b/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 收束的特征测试。 + * + *

    覆盖 8 个 Controller 的代表端点:身份只来自 + * {@code CurrentUserAccessor#requireUserId()},requestId 只来自 + * {@code RequestIdAccessor#get(HttpServletRequest)}。

    + * + *

    身份边界要求:任何非法主体统一 HTTP 401(不是 500,也不是 400), + * 响应不回显 principal,失败请求不产生业务写入。

    + * + *

    requestId 边界要求:规范化后的值与响应头、响应体同源;超长值被既有规则收敛; + * 空白值重新生成;恶意换行/异常字符不得进入响应。

    + */ +@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 redisTemplate; + + /** 8 个 Controller 的一个代表端点。 */ + private record Endpoint(String name, String path) {} + + private static final List 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 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 malicious = List.of( + "", + "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"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3AccessorWiringProbeTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3AccessorWiringProbeTest.java new file mode 100644 index 0000000..9ab4625 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3AccessorWiringProbeTest.java @@ -0,0 +1,128 @@ +package com.mydreamjob.common.r01a3; + +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.deletion.AccountDeletionService; +import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.recommendation.JobRecommendationService; +import com.mydreamjob.risk.RiskGovernanceService; +import com.mydreamjob.safety.BlockService; +import com.mydreamjob.safety.ReportService; +import com.mydreamjob.security.CurrentUserAccessor; +import com.mydreamjob.verification.CompanyVerificationService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * R01-A3 独立复验:**接线变异检测**。 + * + *

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

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

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

    + * + *

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

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

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

    + * + *

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

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

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

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

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

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

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

    + * + *

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

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

    把两个 Accessor 与相关 Service 全部替换为 mock,证明 5 个 Controller 确实通过 + * 既有 Accessor 取得 requestId(以及 FairnessMonitoring 的身份)。 + * 删除任一 Accessor 调用都会使本测试失败。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class A6AccessorWiringIndependentTest { + + @Autowired private MockMvc mockMvc; + + @MockBean private CurrentUserAccessor currentUserAccessor; + @MockBean private RequestIdAccessor requestIdAccessor; + + @MockBean private AuthService authService; + @MockBean private SmsVerificationService smsVerificationService; + @MockBean private JobService jobService; + @MockBean private CompanyService companyService; + @MockBean private FairnessMonitoringService fairnessMonitoringService; + @MockBean private RedisTemplate redisTemplate; + + private static final UsernamePasswordAuthenticationToken LEGAL = + new UsernamePasswordAuthenticationToken("42", "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + + private static final String API_INDEX = "/"; + private static final String HEALTH = "/health"; + private static final String AUTH_ME = "/auth/me"; + private static final String JOBS = "/jobs"; + private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift"; + + private static final String[] ALL = {API_INDEX, HEALTH, AUTH_ME, JOBS, FAIRNESS_DRIFT}; + + @Test + @DisplayName("独立复验:5 个 Controller 的响应体 requestId 均来自 RequestIdAccessor") + void allFiveControllers_useRequestIdAccessor() throws Exception { + when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep-request-id"); + + for (String path : ALL) { + String body = mockMvc.perform(get(path).with(authentication(LEGAL))) + .andReturn().getResponse().getContentAsString(); + assertThat(body) + .as("path=%s 响应体 requestId 必须来自 Accessor mock", path) + .contains("\"requestId\":\"a6-indep-request-id\""); + } + + verify(requestIdAccessor, Mockito.atLeast(ALL.length)).get(any(HttpServletRequest.class)); + } + + @Test + @DisplayName("独立复验:Accessor 返回 4242 时 FairnessMonitoring Service 收到 4242") + void fairnessServiceReceivesAccessorUserId() throws Exception { + when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep"); + when(currentUserAccessor.requireUserId()).thenReturn(4242L); + + mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn(); + + verify(fairnessMonitoringService).drift(eq(4242L)); + verify(currentUserAccessor, Mockito.atLeastOnce()).requireUserId(); + } + + @Test + @DisplayName("独立复验:Accessor 抛未登录时 FairnessMonitoring 返回 401 且 Service 零调用") + void accessorThrowsUnauthenticated_returns401_noServiceCall() throws Exception { + when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep"); + when(currentUserAccessor.requireUserId()) + .thenThrow(new InsufficientAuthenticationException("authentication required")); + + var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn(); + assertThat(r.getResponse().getStatus()).isEqualTo(401); + verify(fairnessMonitoringService, never()).drift(any()); + } + + @Test + @DisplayName("独立复验:合法身份下 FairnessMonitoring 其余端点同样经 Accessor 取 userId") + void fairnessOtherEndpoints_useAccessor() throws Exception { + when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep"); + when(currentUserAccessor.requireUserId()).thenReturn(77L); + + mockMvc.perform(get("/admin/scoring/fairness/overview") + .param("period", "2026-09").with(authentication(LEGAL))).andReturn(); + verify(fairnessMonitoringService).overview(eq(77L), eq("2026-09")); + + mockMvc.perform(get("/admin/scoring/fairness/ranking-stability") + .with(authentication(LEGAL))).andReturn(); + verify(fairnessMonitoringService).rankingStability(eq(77L)); + + mockMvc.perform(get("/admin/scoring/fairness/dimensions") + .param("period", "2026-09").with(authentication(LEGAL))).andReturn(); + verify(fairnessMonitoringService).dimensions(eq(77L), eq("2026-09"), any()); + } + + @Test + @DisplayName("独立复验:需认证端点无 Token 时由 Security 链返回 401(公开端点不在此列)") + void noToken_is401OnProtectedEndpoints() throws Exception { + // `/`、`/health`、`/info`、`GET /jobs` 为公开端点,不参与此断言 + for (String path : new String[]{AUTH_ME, FAIRNESS_DRIFT}) { + assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus()) + .as(path).isEqualTo(401); + } + } + + @Test + @DisplayName("独立复验:JobController 企业侧端点身份来自 Accessor(未被重复实现)") + void jobController_identityFromAccessor() throws Exception { + when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep"); + when(currentUserAccessor.requireUserId()).thenReturn(999L); + + // 企业侧创建岗位端点:其 resolveCompanyId() 必须经 CurrentUserAccessor 取身份 + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .post("/jobs") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"title\":\"t\",\"description\":\"d\",\"city\":\"上海\",\"employmentType\":\"FULL_TIME\"}") + .with(authentication(LEGAL))) + .andReturn(); + + verify(currentUserAccessor, Mockito.atLeastOnce()).requireUserId(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java new file mode 100644 index 0000000..ccd94ce --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java @@ -0,0 +1,111 @@ +package com.mydreamjob.common.r01a6; + +import com.mydreamjob.auth.AuthService; +import com.mydreamjob.auth.SmsVerificationService; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.job.JobService; +import com.mydreamjob.company.CompanyService; +import com.mydreamjob.scoringmonitoring.FairnessMonitoringService; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +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-A6 接线变异检测:证明 5 个 Controller 的 requestId(及 FairnessMonitoring 的身份) + * 确实经过既有 Accessor。 + * + *

    修复前,这 5 个 Controller 各自持有 {@code getRequestId}/{@code requestId} 直读 + * {@code X-Request-Id},FairnessMonitoring 还直接读 {@code SecurityContextHolder}; + * 因此 mock Accessor 的返回值不会出现在响应中 —— 本测试在修复前必然失败。

    + * + *

    说明:FairnessMonitoring 的既有身份解析带有 {@code try/catch NumberFormatException}, + * 其行为本身是安全的(401 而非 500),故本测试以"未调用 Accessor"作为失败依据, + * **不编造 500**。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class A6AccessorWiringProbeTest { + + @Autowired private MockMvc mockMvc; + + @MockBean private CurrentUserAccessor currentUserAccessor; + @MockBean private RequestIdAccessor requestIdAccessor; + + @MockBean private AuthService authService; + @MockBean private SmsVerificationService smsVerificationService; + @MockBean private JobService jobService; + @MockBean private CompanyService companyService; + @MockBean private FairnessMonitoringService fairnessMonitoringService; + @MockBean private RedisTemplate redisTemplate; + + private static final UsernamePasswordAuthenticationToken LEGAL = + new UsernamePasswordAuthenticationToken("42", "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + + /** 5 个 Controller 的真实代表端点(均为 GET,且不需额外必需查询参数)。 */ + private static final String API_INDEX = "/"; // ApiController + private static final String HEALTH = "/health"; // SystemController + private static final String AUTH_ME = "/auth/me"; // AuthController + private static final String JOBS = "/jobs"; // JobController + private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift"; // FairnessMonitoringController + + @Test + @DisplayName("A6 接线检测:5 个 Controller 的响应体 requestId 均来自 RequestIdAccessor") + void requestIdComesFromAccessorAcrossFiveControllers() throws Exception { + when(requestIdAccessor.get(any())).thenReturn("a6-probe-request-id"); + + for (String path : new String[]{API_INDEX, HEALTH, AUTH_ME, JOBS, FAIRNESS_DRIFT}) { + String body = mockMvc.perform(get(path).with(authentication(LEGAL))) + .andReturn().getResponse().getContentAsString(); + assertThat(body) + .as("path=%s 响应体 requestId 应来自 Accessor(修复前为直读 header 的 null)", path) + .contains("\"requestId\":\"a6-probe-request-id\""); + } + + verify(requestIdAccessor, atLeastOnce()).get(any()); + } + + @Test + @DisplayName("A6 接线检测:FairnessMonitoring 的身份来自 CurrentUserAccessor") + void fairnessMonitoringUsesCurrentUserAccessor() throws Exception { + when(requestIdAccessor.get(any())).thenReturn("a6-probe"); + when(currentUserAccessor.requireUserId()).thenReturn(5151L); + + mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn(); + + verify(fairnessMonitoringService).drift(5151L); + verify(currentUserAccessor, atLeastOnce()).requireUserId(); + } + + @Test + @DisplayName("A6 接线检测:Accessor 抛未登录时 FairnessMonitoring 返回 401(非 500)") + void accessorThrowsUnauthenticated_fairnessReturns401() throws Exception { + when(requestIdAccessor.get(any())).thenReturn("a6-probe"); + when(currentUserAccessor.requireUserId()) + .thenThrow(new InsufficientAuthenticationException("authentication required")); + + var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn(); + assertThat(r.getResponse().getStatus()).isEqualTo(401); + verify(fairnessMonitoringService, Mockito.never()).drift(any()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6IndependentVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6IndependentVerificationTest.java new file mode 100644 index 0000000..a990ce3 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6IndependentVerificationTest.java @@ -0,0 +1,197 @@ +package com.mydreamjob.common.r01a6; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.AuthService; +import com.mydreamjob.auth.SmsVerificationService; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.company.CompanyService; +import com.mydreamjob.job.JobService; +import com.mydreamjob.scoringmonitoring.FairnessMonitoringService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.authentication.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.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * R01-A6 独立复验:身份边界、requestId 语义、副作用与夹具反证。 + * + *

    使用**真实** {@code CurrentUserAccessor} 与真实 SecurityFilterChain(不 mock Accessor)。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class A6IndependentVerificationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private RequestIdAccessor requestIdAccessor; + @Autowired private JdbcTemplate jdbcTemplate; + + @MockBean private AuthService authService; + @MockBean private SmsVerificationService smsVerificationService; + @MockBean private JobService jobService; + @MockBean private CompanyService companyService; + @MockBean private FairnessMonitoringService fairnessMonitoringService; + @MockBean private RedisTemplate redisTemplate; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift"; + private static final List REQUEST_ID_PATHS = + List.of("/", "/health", "/auth/me", "/jobs", FAIRNESS_DRIFT); + + private static final List ILLEGAL = + List.of("anonymousUser", "not-a-number", "99999999999999999999", "0", "-1"); + + private static Authentication principal(String name) { + return new UsernamePasswordAuthenticationToken(name, "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + } + + // ── 1. FairnessMonitoring 身份边界(真实 Accessor)──────── + + @Test + @DisplayName("独立复验:非法主体访问 FairnessMonitoring 一律 401(不得 400/500)") + void fairness_illegalPrincipals_are401_not400or500() throws Exception { + for (String p : ILLEGAL) { + var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn(); + int st = r.getResponse().getStatus(); + assertThat(st).as("principal='%s' 必须 401(实际 %d)", p, st).isEqualTo(401); + + JsonNode json = MAPPER.readTree(r.getResponse().getContentAsString()); + assertThat(json.path("code").asInt()).isEqualTo(401); + String msg = json.path("message").asText(); + assertThat(msg).isIn("authentication required", "not authenticated"); + assertThat(msg) + .doesNotContain("Exception").doesNotContain("java.") + .doesNotContain("principal").doesNotContain("anonymousUser") + .doesNotContain("com.mydreamjob").doesNotContain("NumberFormat"); + assertThat(json.has("stackTrace")).isFalse(); + assertThat(json.has("trace")).isFalse(); + // 只检查稳定字段 message,不对整段 JSON 做数字搜索 + if (p.length() >= 3 && !p.chars().allMatch(Character::isDigit)) { + assertThat(msg).doesNotContain(p); + } + } + } + + @Test + @DisplayName("独立复验:非法主体与匿名访问 FairnessMonitoring 时 Service 零调用") + void fairness_illegalPrincipals_produceNoServiceCall() throws Exception { + Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + for (String p : ILLEGAL) { + mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn(); + } + mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(anon))).andReturn(); + mockMvc.perform(get(FAIRNESS_DRIFT)).andReturn(); + verify(fairnessMonitoringService, never()).drift(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("独立复验:合法数字主体可进入业务层(不被身份层拒绝)") + void fairness_legalPrincipal_reachesService() throws Exception { + int st = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal("7")))) + .andReturn().getResponse().getStatus(); + assertThat(st).as("合法主体不得被判未登录").isNotEqualTo(401); + } + + // ── 2. requestId 语义(5 个 Controller)────────────────── + + @Test + @DisplayName("独立复验:5 个 Controller 的 requestId 规范化与头体同源") + void requestId_semanticsAcrossFiveControllers() throws Exception { + for (String sent : List.of("a6v-legal-1", " ", "q".repeat(90), "x", "a b")) { + for (String path : REQUEST_ID_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("独立复验:请求属性优先于原始 Header") + void requestAttributeWins() { + var req = new org.springframework.mock.web.MockHttpServletRequest(); + req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "a6v-attr"); + req.addHeader("X-Request-Id", "a6v-header"); + assertThat(requestIdAccessor.get(req)).isEqualTo("a6v-attr"); + } + + @Test + @DisplayName("独立复验:CR/LF 不进入规范化结果(直接经 Accessor,不依赖 MockMvc 接受非法 header)") + void crlfNeverEntersValue() { + for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "k\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"); + } + } + + // ── 3. 副作用快照(行级、显式列、稳定排序)────────────── + + private static final List TABLES = List.of( + new String[]{"SCORING_RISK_EVENTS", "ID, SUBJECT_USER_ID, SOURCE_ACTION, SEVERITY, STATUS, VERSION, CREATED_AT"}, + new String[]{"SCORING_RISK_ACTIONS", "ID, RISK_EVENT_ID, OPERATOR_USER_ID, ACTION, REASON_CODE, CREATED_AT"}, + new String[]{"JOBS", "ID, COMPANY_ID, TITLE, STATUS, CREATED_AT, UPDATED_AT"}, + new String[]{"USERS", "ID, PHONE, NICKNAME, ROLE, IS_ACTIVE"}, + new String[]{"USER_ROLES", "USER_ID, ROLE"}, + new String[]{"AUDIT_LOGS", "ID, USER_ID, ACTION, ENTITY_TYPE, ENTITY_ID, CREATED_AT"}, + new String[]{"NOTIFICATIONS", "ID, USER_ID, TYPE, TITLE, IS_READ, CREATED_AT"}, + new String[]{"MESSAGES", "ID, CONVERSATION_ID, SENDER_USER_ID, CONTENT, CREATED_AT"}, + new String[]{"CANDIDATE_DIMENSION_SCORES", "ID, STUDENT_USER_ID, DIMENSION, PERIOD, SCORE, STATUS, VERSION"}); + + private Map>> snapshot() { + Map>> out = new LinkedHashMap<>(); + for (String[] t : TABLES) { + out.put(t[0], jdbcTemplate.queryForList( + "SELECT " + t[1] + " FROM " + t[0] + " ORDER BY " + t[1])); + } + return out; + } + + @Test + @DisplayName("独立复验:非法身份请求前后九张核心表逐字段零变化") + void illegalIdentity_producesNoBusinessWrite() throws Exception { + Map>> before = snapshot(); + + for (String p : ILLEGAL) { + mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn(); + mockMvc.perform(get(FAIRNESS_DRIFT).header("Authorization", "Bearer " + p)).andReturn(); + } + mockMvc.perform(get(FAIRNESS_DRIFT)).andReturn(); + + Map>> after = snapshot(); + for (String[] t : TABLES) { + assertThat(after.get(t[0])).as("%s 必须逐字段零变化", t[0]).isEqualTo(before.get(t[0])); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java new file mode 100644 index 0000000..6cfbf59 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java @@ -0,0 +1,167 @@ +package com.mydreamjob.common.r01a6; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.AuthService; +import com.mydreamjob.auth.SmsVerificationService; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.company.CompanyService; +import com.mydreamjob.job.JobService; +import com.mydreamjob.scoringmonitoring.FairnessMonitoringService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * R01-A6 特征测试:FairnessMonitoringController 的身份边界 + 5 个 Controller 的 requestId 规范化。 + * + *

    身份断言只针对 {@code /admin/scoring/fairness/*}(其余 4 个 Controller 的代表端点为 + * 公开端点,无身份解析,仅覆盖 requestId)。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class R01A6RequestContextMigrationIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private RequestIdAccessor requestIdAccessor; + + @MockBean private AuthService authService; + @MockBean private SmsVerificationService smsVerificationService; + @MockBean private JobService jobService; + @MockBean private CompanyService companyService; + @MockBean private FairnessMonitoringService fairnessMonitoringService; + @MockBean private RedisTemplate redisTemplate; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String API_INDEX = "/"; + private static final String HEALTH = "/health"; + private static final String AUTH_ME = "/auth/me"; + private static final String JOBS = "/jobs"; + private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift"; + + private static final List REQUEST_ID_PATHS = + List.of(API_INDEX, HEALTH, AUTH_ME, JOBS, FAIRNESS_DRIFT); + + private static final List ILLEGAL = List.of( + "anonymousUser", "not-a-number", "99999999999999999999", "0", "-1"); + + private static Authentication principal(String name) { + return new UsernamePasswordAuthenticationToken(name, "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + } + + // ── 身份边界(FairnessMonitoringController)─────────────── + + @Test + @DisplayName("A6:非法主体访问 FairnessMonitoring 一律 401、无 500、不回显主体") + void fairnessMonitoring_illegalPrincipals_are401() throws Exception { + for (String p : ILLEGAL) { + var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn(); + int st = r.getResponse().getStatus(); + assertThat(st).as("principal='%s' 必须 401", p).isEqualTo(401); + + JsonNode json = MAPPER.readTree(r.getResponse().getContentAsString()); + assertThat(json.path("code").asInt()).isEqualTo(401); + String msg = json.path("message").asText(); + assertThat(msg).isIn("authentication required", "not authenticated"); + assertThat(msg) + .doesNotContain("Exception").doesNotContain("java.") + .doesNotContain("principal").doesNotContain("anonymousUser") + .doesNotContain("com.mydreamjob").doesNotContain("NumberFormat"); + 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("A6:非法主体访问 FairnessMonitoring 时业务 Service 零调用") + void fairnessMonitoring_illegalPrincipals_produceNoServiceCall() throws Exception { + for (String p : ILLEGAL) { + mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn(); + } + verify(fairnessMonitoringService, never()).drift(any()); + } + + @Test + @DisplayName("A6:匿名 token 与无 Token 均 401") + void fairnessMonitoring_anonymousAndNoToken_are401() throws Exception { + Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + assertThat(mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(anon))) + .andReturn().getResponse().getStatus()).isEqualTo(401); + assertThat(mockMvc.perform(get(FAIRNESS_DRIFT)).andReturn() + .getResponse().getStatus()).isEqualTo(401); + } + + @Test + @DisplayName("A6:合法数字主体仍可进入业务层(不被身份层拒绝)") + void fairnessMonitoring_legalPrincipal_reachesService() throws Exception { + int st = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal("7")))) + .andReturn().getResponse().getStatus(); + assertThat(st).as("合法主体不得被判未登录").isNotEqualTo(401); + } + + // ── requestId 规范化(5 个 Controller)─────────────────── + + @Test + @DisplayName("A6:5 个 Controller 的 requestId 合法/空白/超长/非法字符 均与响应头同源") + void requestId_consistentAcrossFiveControllers() throws Exception { + for (String sent : List.of("a6-legal-0001", " ", "v".repeat(80), "", "a b")) { + for (String path : REQUEST_ID_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("A6:request attribute 优先于原始 header") + void requestAttributeWins() { + var req = new org.springframework.mock.web.MockHttpServletRequest(); + req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "a6-attr-wins"); + req.addHeader("X-Request-Id", "a6-header-loses"); + assertThat(requestIdAccessor.get(req)).isEqualTo("a6-attr-wins"); + } + + @Test + @DisplayName("A6:CR/LF 不进入规范化结果(直接经 Accessor)") + void crlfNeverEntersValue() { + for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "y\r\nInjected: 1")) { + var req = new org.springframework.mock.web.MockHttpServletRequest(); + req.addHeader("X-Request-Id", bad); + String v = requestIdAccessor.get(req); + assertThat(v).as("输入 %s", bad).isNotBlank().doesNotContain("\r").doesNotContain("\n"); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java new file mode 100644 index 0000000..7409157 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java @@ -0,0 +1,156 @@ +package com.mydreamjob.common.r01b; + +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.exception.ErrorHttpStatusRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpStatus; + +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * R01-B/B2:集中式错误码 → HTTP 状态映射的契约测试。 + * + *

    覆盖任务书要求:单一映射、显式映射集中、不可变、重复键初始化即失败、 + * 未映射回退 400、只保留既有语义。

    + */ +class ErrorHttpStatusRegistryTest { + + private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); + + /** 允许出现在显式映射里的状态集合:重构前后都必须只有这四个。 */ + private static final Set ALLOWED_EXPLICIT_STATUSES = Set.of( + HttpStatus.FORBIDDEN, HttpStatus.NOT_FOUND, HttpStatus.CONFLICT, HttpStatus.TOO_MANY_REQUESTS); + + @Test + @DisplayName("B2:显式映射数量与重构前快照一致(112 个 ErrorCode 常量)") + void explicitMappingCountMatchesBaseline() { + assertThat(registry.explicitMappingCount()).isEqualTo(112); + // 112 个常量只覆盖 111 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与 + // DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。 + assertThat(registry.explicitNumericCodeCount()).isEqualTo(111); + } + + @Test + @DisplayName("B2:映射键是 ErrorCode 常量本身,不是数字码") + void mappingKeysAreErrorCodeConstants() { + assertThat(ErrorHttpStatusRegistry.explicitMappingsView().keySet()) + .isSubsetOf(EnumSet.allOf(ErrorCode.class)); + + // 两个常量共用数字码 9103 时必须同时显式登记,否则说明键被错误地按数字去重 + assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.REVIEW_ALREADY_DECIDED)).isTrue(); + assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.DUPLICATE_FEEDBACK)).isTrue(); + assertThat(ErrorCode.REVIEW_ALREADY_DECIDED.getCode()) + .as("9103 为两个既有常量共用(潜在缺陷,本轮不修改 ErrorCode)") + .isEqualTo(ErrorCode.DUPLICATE_FEEDBACK.getCode()); + // 二者状态一致,数字入口因此无歧义 + assertThat(registry.resolve(ErrorCode.REVIEW_ALREADY_DECIDED)) + .isEqualTo(registry.resolve(ErrorCode.DUPLICATE_FEEDBACK)); + } + + @Test + @DisplayName("B2:显式映射只使用四个既有状态,未引入新状态") + void explicitMappingsUseOnlyPreExistingStatuses() { + assertThat(new java.util.LinkedHashSet<>(ErrorHttpStatusRegistry.explicitMappingsView().values())) + .as("显式映射出现的状态集合") + .isSubsetOf(ALLOWED_EXPLICIT_STATUSES); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(ErrorCode.class) + @DisplayName("B2:显式映射逐项自洽;未映射码一律回退 400") + void resolveMatchesMappingTableForEveryErrorCode(ErrorCode errorCode) { + HttpStatus expected = ErrorHttpStatusRegistry.explicitMappingsView() + .getOrDefault(errorCode, HttpStatus.BAD_REQUEST); + + assertThat(registry.resolve(errorCode)).isEqualTo(expected); + assertThat(registry.resolve(errorCode.getCode())).as("数字入口与枚举入口一致").isEqualTo(expected); + assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(errorCode)) + .isEqualTo(ErrorHttpStatusRegistry.explicitMappingsView().containsKey(errorCode)); + } + + @Test + @DisplayName("B2:未映射错误码保持既有 400 回退(含 UNAUTHORIZED 与 SERVICE_UNAVAILABLE)") + void unmappedErrorCodesKeepBadRequestFallback() { + // 既有语义:这两个码在重构前就没有显式映射,因此是 400,不是 401/503。 + assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.resolve(ErrorCode.BAD_REQUEST)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.resolve(ErrorCode.INTERNAL_ERROR)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.resolve(ErrorCode.TOKEN_EXPIRED)).isEqualTo(HttpStatus.BAD_REQUEST); + // 完全未知的数字码 + assertThat(registry.resolve(0)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.resolve(-1)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.resolve(123456789)).isEqualTo(HttpStatus.BAD_REQUEST); + // null 不抛 NPE + assertThat(registry.resolve((ErrorCode) null)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.defaultStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("B2:重复注册同一错误码(即使状态相同)必须在注册时失败") + void duplicateKeysAreRejectedAtRegistration() { + assertThatThrownBy(() -> new ErrorHttpStatusRegistry.Builder() + .put(HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN) + .put(HttpStatus.NOT_FOUND, ErrorCode.FORBIDDEN)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("duplicate HTTP status mapping") + .hasMessageContaining(String.valueOf(ErrorCode.FORBIDDEN.getCode())); + + // 同一状态下的重复登记同样是配置错误,不得静默容忍 + assertThatThrownBy(() -> new ErrorHttpStatusRegistry.Builder() + .put(HttpStatus.NOT_FOUND, ErrorCode.JOB_NOT_FOUND) + .put(HttpStatus.NOT_FOUND, ErrorCode.JOB_NOT_FOUND, ErrorCode.USER_NOT_FOUND)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("duplicate HTTP status mapping"); + } + + @Test + @DisplayName("B2:构建结果不可变,运行期无法增删改") + void builtMappingIsImmutable() { + Map view = ErrorHttpStatusRegistry.explicitMappingsView(); + assertThatThrownBy(() -> view.put(ErrorCode.FORBIDDEN, HttpStatus.OK)) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN)) + .isInstanceOf(UnsupportedOperationException.class); + assertThat(view).hasSize(112); + + Map numeric = ErrorHttpStatusRegistry.numericMappingsView(); + assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) + .isInstanceOf(UnsupportedOperationException.class); + assertThat(numeric).hasSize(111); + } + + @Test + @DisplayName("B2:不同 ErrorCode 常量允许各自登记(即使数字码相同且状态相同)") + void distinctConstantsWithSharedNumericCodeAreAllowed() { + Map built = new ErrorHttpStatusRegistry.Builder() + .put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED) + .put(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_FEEDBACK) + .build(); + + assertThat(built).hasSize(2); + } + + @Test + @DisplayName("B2:注册顺序不影响解析结果") + void registrationOrderDoesNotChangeResolution() { + Map forward = new ErrorHttpStatusRegistry.Builder() + .put(HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN) + .put(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND) + .build(); + Map reverse = new ErrorHttpStatusRegistry.Builder() + .put(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND) + .put(HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN) + .build(); + + assertThat(forward).isEqualTo(reverse); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java new file mode 100644 index 0000000..a92f1b9 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java @@ -0,0 +1,188 @@ +package com.mydreamjob.common.r01b; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.exception.ErrorHttpStatusRegistry; +import com.mydreamjob.common.exception.GlobalExceptionHandler; +import com.mydreamjob.common.logging.RequestIdAccessor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * R01-B/B2:重构前后 HTTP 映射**逐项一致**证明。 + * + *

    这里的四个状态桶是**重构前**从 {@code GlobalExceptionHandler.buildBusinessHttpStatuses()} + * 逐条抄录的独立快照(亦保存于 {@code docs/acceptance/r01-b/b2-mapping-baseline.json}), + * 不是从重构后的实现反推。任何一个错误码的状态发生漂移,本测试都会失败。

    + */ +class GlobalExceptionHandlerMappingParityTest { + + private static final Set FORBIDDEN = Set.of( + "APPLICATION_OWNER_MISMATCH", "FORBIDDEN", "INTERVIEW_ACCESS_DENIED", + "MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER"); + + private static final Set NOT_FOUND = Set.of( + "ABILITY_ATTEMPT_NOT_FOUND", "ABILITY_RESULT_NOT_FOUND", "ABILITY_TYPE_NOT_FOUND", + "APPEAL_NOT_FOUND", "APPLICATION_NOT_FOUND", "ASSESSMENT_ATTEMPT_NOT_FOUND", + "ASSESSMENT_TYPE_NOT_FOUND", "COMPANY_NOT_FOUND", "CONVERSATION_NOT_FOUND", + "DATA_EXPORT_TASK_NOT_FOUND", "FEEDBACK_NOT_FOUND", "INTERVIEW_SCORE_NOT_FOUND", + "JOB_NOT_FOUND", "MESSAGE_NOT_FOUND", "OFFER_COUNTER_OFFER_NOT_FOUND", + "OFFER_NOT_FOUND", "PROFILE_NOT_FOUND", "REPORT_NOT_FOUND", + "RESUME_EXPORT_TASK_NOT_FOUND", "RESUME_VERSION_NOT_FOUND", "REVIEW_NOT_FOUND", + "REVIEW_REPLY_NOT_FOUND", "RISK_EVENT_NOT_FOUND", "RISK_RULE_NOT_FOUND", + "TALENT_INVITATION_NOT_FOUND", "USER_NOT_FOUND", "VERIFICATION_NOT_FOUND"); + + private static final Set CONFLICT = Set.of( + "ABILITY_ATTEMPT_INVALID_STATE", "ABILITY_CONCURRENT_UPDATE", + "ABILITY_DUPLICATE_ACTIVE", "ABILITY_IDEMPOTENCY_CONFLICT", + "ABILITY_RETAKE_COOLDOWN", "ACCOUNT_DELETED", + "ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED", "ACCOUNT_DELETION_CONCURRENT_UPDATE", + "ACCOUNT_DELETION_PENDING", "APPEAL_ALREADY_DECIDED", "APPEAL_DUPLICATE", + "APPEAL_IDEMPOTENCY_CONFLICT", "APPEAL_INVALID_TRANSITION", + "APPEAL_ROLLBACK_LIMIT_EXCEEDED", "APPLICATION_DUPLICATE", + "APPLICATION_NOT_MATCHED", "ASSESSMENT_ATTEMPT_INVALID_STATE", + "ASSESSMENT_CONCURRENT_UPDATE", "ASSESSMENT_DUPLICATE_ACTIVE", + "ASSESSMENT_IDEMPOTENCY_CONFLICT", "COMPANY_ALREADY_EXISTS", + "DATA_EXPORT_DUPLICATE", "DATA_EXPORT_EXPIRED", "DATA_EXPORT_FAILED", + "DATA_EXPORT_FILE_NOT_FOUND", "DATA_EXPORT_NOT_READY", + "DEFAULT_RESUME_DELETE_NOT_ALLOWED", "DUPLICATE_DEFAULT_RESUME", + "DUPLICATE_FEEDBACK", "DUPLICATE_OFFER", "DUPLICATE_REVIEW", + "INTERVIEW_ALREADY_RESPONDED", "INTERVIEW_COMPLETE_NOT_READY", + "INTERVIEW_NOT_COMPLETED", "INTERVIEW_SCORE_CONCURRENT_UPDATE", + "INTERVIEW_SCORE_SUBMITTED", "INVALID_APPLICATION_STATUS_TRANSITION", + "INVALID_DELETION_STATE", "INVALID_INTERVIEW_STATUS_TRANSITION", + "INVALID_JOB_STATUS_TRANSITION", "INVALID_OFFER_STATUS_TRANSITION", + "INVALID_VERIFICATION_STATUS_TRANSITION", "LAST_ADMIN_CANNOT_DELETE", + "MATCH_CLOSED", "MATCH_SCORES_LOCKED", "MESSAGE_RECALL_CONCURRENT_UPDATE", + "MESSAGE_RECALL_WINDOW_EXPIRED", "OFFER_COUNTER_OFFER_EXISTS", + "OFFER_COUNTER_OFFER_EXPIRED", "OFFER_COUNTER_OFFER_NOT_PENDING", "OFFER_EXPIRED", + "OFFER_NOT_EDITABLE", "OFFER_REMINDER_DUPLICATE", + "PRIVACY_SETTINGS_CONCURRENT_UPDATE", "REPORT_ALREADY_DECIDED", "REPORT_DUPLICATE", + "REPORT_IDEMPOTENCY_CONFLICT", "REPORT_INVALID_TRANSITION", + "RESUME_EXPORT_IN_PROGRESS", "RESUME_EXPORT_NOT_READY", "REVIEW_ALREADY_DECIDED", + "REVIEW_REPLY_ALREADY_EXISTS", "REVIEW_REPLY_CONCURRENT_UPDATE", + "REVIEW_REPLY_IDEMPOTENCY_CONFLICT", "REVIEW_REPLY_INVALID_STATE", + "REVIEW_WEIGHT_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", + "RISK_INVALID_ACTION", "RISK_RULE_CONCURRENT_UPDATE", + "TALENT_INVITATION_ALREADY_ACTIVE", "TALENT_INVITATION_CANDIDATE_UNAVAILABLE", + "TALENT_INVITATION_CONCURRENT_UPDATE", "TALENT_INVITATION_EXPIRED", + "TALENT_INVITATION_IDEMPOTENCY_CONFLICT", "TALENT_INVITATION_INVALID_STATE", + "VERIFICATION_ALREADY_APPROVED", "VERIFICATION_ALREADY_PENDING"); + + private static final Set TOO_MANY_REQUESTS = Set.of( + "ABILITY_RETAKE_LIMIT", "REPORT_RATE_LIMITED", "TALENT_INVITATION_RATE_LIMITED"); + + private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); + private final GlobalExceptionHandler handler = + new GlobalExceptionHandler(new RequestIdAccessor(), registry); + + private static HttpStatus baselineStatus(ErrorCode errorCode) { + String name = errorCode.name(); + if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN; + if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND; + if (CONFLICT.contains(name)) return HttpStatus.CONFLICT; + if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS; + return HttpStatus.BAD_REQUEST; + } + + @Test + @DisplayName("B2:显式映射集合与重构前快照逐项相等") + void explicitMappingSetEqualsPreRefactorSnapshot() { + assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(112); + assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(111); + + for (ErrorCode errorCode : ErrorCode.values()) { + HttpStatus expected = baselineStatus(errorCode); + if (expected == HttpStatus.BAD_REQUEST) { + assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(errorCode)) + .as("%s 在快照中未显式映射,重构后也不得显式映射", errorCode.name()) + .isFalse(); + } else { + assertThat(registry.resolve(errorCode)) + .as("%s 的显式映射状态", errorCode.name()) + .isEqualTo(expected); + } + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(ErrorCode.class) + @DisplayName("B2:处理器实际返回的状态与其 registry 解析结果、以及快照三者一致") + void handlerStatusMatchesRegistryAndSnapshotForEveryErrorCode(ErrorCode errorCode) { + ResponseEntity> response = + handler.handleBusinessException(new BusinessException(errorCode), request()); + + assertThat(response.getStatusCode()).isEqualTo(baselineStatus(errorCode)); + assertThat(response.getStatusCode()).isEqualTo(registry.resolve(errorCode)); + } + + @Test + @DisplayName("B2:FORBIDDEN 保持业务码 2002 与 HTTP 403") + void forbiddenKeepsBusinessCodeAndStatus() { + ResponseEntity> response = + handler.handleBusinessException(new BusinessException(ErrorCode.FORBIDDEN), request()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getCode()).isEqualTo(2002); + } + + @Test + @DisplayName("B2:UNAUTHORIZED(2001) 保持既有语义(回退 400,不擅自改为 401)") + void unauthorizedKeepsLegacySemantics() { + ResponseEntity> response = + handler.handleBusinessException(new BusinessException(ErrorCode.UNAUTHORIZED), request()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getCode()).isEqualTo(2001); + assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("B2:服务不可用类错误码保持当前状态(400)") + void serviceUnavailableKeepsCurrentStatus() { + assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("B2:响应体不包含异常类名、堆栈或内部路径") + void responseBodyCarriesNoInternalDetail() { + for (ErrorCode code : new ErrorCode[]{ + ErrorCode.FORBIDDEN, ErrorCode.USER_NOT_FOUND, ErrorCode.APPLICATION_DUPLICATE, + ErrorCode.UNAUTHORIZED, ErrorCode.SERVICE_UNAVAILABLE}) { + MockHttpServletRequest request = request(); + request.setRequestURI("/admin/scoring/fairness/overview"); + ResponseEntity> response = + handler.handleBusinessException(new BusinessException(code), request); + + assertThat(response.getBody()).isNotNull(); + String text = response.getBody().toString(); + assertThat(text) + .as("%s 响应体", code.name()) + .doesNotContain("Exception") + .doesNotContain("at com.mydreamjob") + .doesNotContain("GlobalExceptionHandler") + .doesNotContain("/admin/scoring/fairness/overview"); + assertThat(response.getBody().getRequestId()).isEqualTo("r01b-parity"); + } + } + + private static MockHttpServletRequest request() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/v1/parity"); + request.setAttribute("requestId", "r01b-parity"); + return request; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java new file mode 100644 index 0000000..cf19e9e --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java @@ -0,0 +1,257 @@ +package com.mydreamjob.common.r01b; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.exception.ErrorHttpStatusRegistry; +import com.mydreamjob.common.exception.GlobalExceptionHandler; +import com.mydreamjob.common.logging.RequestIdAccessor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * R01-B 独立复验:HTTP 映射与 **HEAD 基线**逐项对账。 + * + *

    期望值来源:{@code src/test/resources/r01b/error-code-http-status-baseline.tsv}, + * 该文件由独立复验者从 {@code git show HEAD:…GlobalExceptionHandler.java} 的 + * {@code buildBusinessHttpStatuses()} 提取生成,不复用工作区实现的任何映射表, + * 因此不构成同源假证明。

    + */ +class R01BIndependentHttpMappingParityTest { + + private static final String BASELINE = "/r01b/error-code-http-status-baseline.tsv"; + + private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); + private final GlobalExceptionHandler handler = + new GlobalExceptionHandler(new RequestIdAccessor(), registry); + + /** 从 HEAD 基线资源读取:ErrorCode 名称 → 数字码 → HEAD 状态。 */ + private static Map baseline() { + // 用有序结构保存 name -> {code, statusOrdinal} + Map rows = new LinkedHashMap<>(); + try (InputStream in = R01BIndependentHttpMappingParityTest.class.getResourceAsStream(BASELINE)) { + assertThat(in).as("基线资源必须存在: %s", BASELINE).isNotNull(); + try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) { + if (line.isBlank() || line.startsWith("#")) continue; + String[] parts = line.split("\t"); + rows.put(parts[0], new int[]{Integer.parseInt(parts[1]), statusOf(parts[2]).value()}); + } + } + } catch (Exception e) { + throw new IllegalStateException("failed to read baseline", e); + } + return rows; + } + + private static HttpStatus statusOf(String token) { + return switch (token) { + case "FORBIDDEN" -> HttpStatus.FORBIDDEN; + case "NOT_FOUND" -> HttpStatus.NOT_FOUND; + case "CONFLICT" -> HttpStatus.CONFLICT; + case "TOO_MANY_REQUESTS" -> HttpStatus.TOO_MANY_REQUESTS; + default -> HttpStatus.BAD_REQUEST; + }; + } + + // ── 166 项逐项对账 ──────────────────────────────────────── + + @Test + @DisplayName("独立复验:166 个枚举常量按名称逐项与 HEAD 基线对齐(不按数字去重)") + void everyEnumConstantMatchesHeadBaselineByName() { + Map base = baseline(); + + // 166 条,且 9103 的两个常量各自成行 —— 不能按数字去重后只剩 165 条 + assertThat(base).hasSize(166); + assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK"); + assertThat(ErrorCode.values()).hasSize(166); + + int checked = 0; + for (ErrorCode code : ErrorCode.values()) { + int[] expected = base.get(code.name()); + assertThat(expected).as("基线必须覆盖 %s", code.name()).isNotNull(); + assertThat(code.getCode()).as("%s 的数字码不得改变", code.name()).isEqualTo(expected[0]); + assertThat(registry.resolve(code).value()) + .as("%s 的 HTTP 状态必须与 HEAD 一致", code.name()) + .isEqualTo(expected[1]); + checked++; + } + assertThat(checked).isEqualTo(166); + } + + @Test + @DisplayName("独立复验:112 个显式映射 + 54 个默认 400") + void explicitAndDefaultCountsMatchBaseline() { + Map base = baseline(); + long explicitInBaseline = base.values().stream().filter(v -> v[1] != 400).count(); + + assertThat(explicitInBaseline).isEqualTo(112); + assertThat(base.size() - explicitInBaseline).isEqualTo(54); + + assertThat(registry.explicitMappingCount()).isEqualTo(112); + assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54); + // 112 个常量仅覆盖 111 个唯一数字码(9103 共用) + assertThat(registry.explicitNumericCodeCount()).isEqualTo(111); + } + + @Test + @DisplayName("独立复验:关键既有语义逐项保持") + void keySemanticsPreserved() { + assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.resolve(ErrorCode.FORBIDDEN)).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.resolve(ErrorCode.USER_NOT_FOUND)).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(registry.resolve(ErrorCode.APPLICATION_DUPLICATE)).isEqualTo(HttpStatus.CONFLICT); + assertThat(registry.resolve(ErrorCode.ABILITY_RETAKE_LIMIT)).isEqualTo(HttpStatus.TOO_MANY_REQUESTS); + } + + // ── 9103 重复码 ─────────────────────────────────────────── + + @Test + @DisplayName("独立复验:9103 的两个枚举常量都显式注册,且分别解析为 409") + void bothSharedNumericCodeConstantsAreExplicitlyMappedTo409() { + assertThat(ErrorCode.REVIEW_ALREADY_DECIDED.getCode()).isEqualTo(9103); + assertThat(ErrorCode.DUPLICATE_FEEDBACK.getCode()).isEqualTo(9103); + + assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.REVIEW_ALREADY_DECIDED)).isTrue(); + assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.DUPLICATE_FEEDBACK)).isTrue(); + assertThat(registry.resolve(ErrorCode.REVIEW_ALREADY_DECIDED)).isEqualTo(HttpStatus.CONFLICT); + assertThat(registry.resolve(ErrorCode.DUPLICATE_FEEDBACK)).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + @DisplayName("独立复验:运行时以数字码解析,两个 9103 常量不可区分(如实记录,不夸大)") + void runtimeCannotDistinguishTheTwoSharedConstants() { + // BusinessException 只保存 int,枚举身份在运行时丢失 + BusinessException a = new BusinessException(ErrorCode.REVIEW_ALREADY_DECIDED); + BusinessException b = new BusinessException(ErrorCode.DUPLICATE_FEEDBACK); + assertThat(a.getErrorCode()).isEqualTo(b.getErrorCode()).isEqualTo(9103); + + // 数字入口对两者给出同一结果:当前两者同为 409,故 HTTP 行为一致 + assertThat(registry.resolve(9103)).isEqualTo(HttpStatus.CONFLICT); + assertThat(a.getErrorCode()).isEqualTo(b.getErrorCode()); + + // Handler 也只拿到数字码,响应中的 code 相同 + ResponseEntity> ra = handler.handleBusinessException(a, request()); + ResponseEntity> rb = handler.handleBusinessException(b, request()); + assertThat(ra.getStatusCode()).isEqualTo(rb.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(ra.getBody().getCode()).isEqualTo(rb.getBody().getCode()).isEqualTo(9103); + } + + @Test + @DisplayName("独立复验:同数字码被映射到不同状态时初始化必须失败,不得静默任选") + void conflictingStatusForSharedNumericCodeFailsFast() throws Exception { + // 通过公开 Builder 构造"同数字、不同状态"的映射 + Map conflicting = new ErrorHttpStatusRegistry.Builder() + .put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED) + .put(HttpStatus.NOT_FOUND, ErrorCode.DUPLICATE_FEEDBACK) + .build(); + assertThat(conflicting).hasSize(2); + + // 数字索引构建(真实注册表在静态初始化时走同一条路径)必须拒绝该冲突 + Method indexByCode = ErrorHttpStatusRegistry.class + .getDeclaredMethod("indexByCode", Map.class); + indexByCode.setAccessible(true); + + assertThatThrownBy(() -> { + try { + indexByCode.invoke(null, conflicting); + } catch (java.lang.reflect.InvocationTargetException e) { + throw e.getCause(); + } + }) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("conflicting HTTP status mapping") + .hasMessageContaining("9103"); + + // 反证:状态相同时允许,且数字入口唯一 + Map same = new ErrorHttpStatusRegistry.Builder() + .put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED) + .put(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_FEEDBACK) + .build(); + assertThat(same).hasSize(2); + } + + @Test + @DisplayName("独立复验:重复注册同一枚举常量(即使状态相同)初始化即失败") + void duplicateEnumKeyFailsAtRegistration() { + assertThatThrownBy(() -> new ErrorHttpStatusRegistry.Builder() + .put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED) + .put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("duplicate HTTP status mapping"); + } + + @Test + @DisplayName("独立复验:映射不依赖枚举声明顺序") + void mappingDoesNotDependOnEnumDeclarationOrder() { + // 取一个映射区段,正序与逆序注册结果必须相同 + List notFound = List.of( + ErrorCode.USER_NOT_FOUND, ErrorCode.JOB_NOT_FOUND, ErrorCode.COMPANY_NOT_FOUND); + + ErrorHttpStatusRegistry.Builder asc = new ErrorHttpStatusRegistry.Builder(); + for (ErrorCode c : notFound) asc.put(HttpStatus.NOT_FOUND, c); + ErrorHttpStatusRegistry.Builder desc = new ErrorHttpStatusRegistry.Builder(); + for (int i = notFound.size() - 1; i >= 0; i--) desc.put(HttpStatus.NOT_FOUND, notFound.get(i)); + + assertThat(asc.build()).isEqualTo(desc.build()); + + // 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明 + Map base = baseline(); + for (ErrorCode c : ErrorCode.values()) { + assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]); + } + } + + @Test + @DisplayName("独立复验:未知数字码仍为 400,且处理器响应不含堆栈或内部类名") + void unknownCodeFallsBackTo400AndResponseIsClean() { + assertThat(registry.resolve(0)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.resolve(-1)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.resolve(123456789)).isEqualTo(HttpStatus.BAD_REQUEST); + + for (ErrorCode code : new ErrorCode[]{ + ErrorCode.FORBIDDEN, ErrorCode.USER_NOT_FOUND, ErrorCode.APPLICATION_DUPLICATE, + ErrorCode.UNAUTHORIZED, ErrorCode.SERVICE_UNAVAILABLE, ErrorCode.DUPLICATE_FEEDBACK}) { + MockHttpServletRequest request = request(); + request.setRequestURI("/r01b/independent/parity"); + ResponseEntity> response = + handler.handleBusinessException(new BusinessException(code), request); + + assertThat(response.getBody()).isNotNull(); + String text = response.getBody().toString(); + assertThat(text) + .as("%s 响应体", code.name()) + .doesNotContain("Exception") + .doesNotContain("at com.mydreamjob") + .doesNotContain("GlobalExceptionHandler") + .doesNotContain("ErrorHttpStatusRegistry") + .doesNotContain("/r01b/independent/parity"); + assertThat(response.getBody().getRequestId()).isEqualTo("r01b-independent"); + } + } + + private static MockHttpServletRequest request() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/r01b/independent"); + request.setAttribute("requestId", "r01b-independent"); + return request; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentRoleVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentRoleVerificationTest.java new file mode 100644 index 0000000..814c80c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentRoleVerificationTest.java @@ -0,0 +1,299 @@ +package com.mydreamjob.common.r01b; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.auth.support.TestAuthTokens; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.security.UserRoleGuard; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * R01-B 独立复验:角色授权语义、查询次数、边界与真实 HTTP 分层。 + * + *

    夹具使用**独占用户 ID / 手机号**,避开 {@code V1_1} 种子账号占用的 1/2/3 + * (被 {@code V43} 置为 inactive,会使 Token 在 Filter 阶段即被拒)。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class R01BIndependentRoleVerificationTest { + + private static final long USER_ID = 7_710_001L; + private static final long STUDENT_ID = 9_330_001L; + private static final long COMPANY_ID = 9_330_002L; + private static final long ADMIN_ID = 9_330_003L; + + private static final String ADMIN_ENDPOINT = "/admin/scoring/risk/overview"; + private static final String STUDENT_ENDPOINT = "/profile/me/career-progress"; + private static final String COMPANY_ENDPOINT = "/company/candidates"; + + @Autowired private MockMvc mockMvc; + @Autowired private JdbcTemplate jdbcTemplate; + + @MockBean private RedisTemplate redisTemplate; + + // ── 单元部分 ────────────────────────────────────────────── + + private UserRepository userRepository; + private UserRoleGuard guard; + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + guard = new UserRoleGuard(userRepository); + SecurityContextHolder.clearContext(); + + cleanupHttpFixtures(); + insertUserWithRoles(STUDENT_ID, "r01biv-student", "STUDENT", "STUDENT"); + insertUserWithRoles(COMPANY_ID, "r01biv-company", "COMPANY", "COMPANY"); + insertUserWithRoles(ADMIN_ID, "r01biv-admin", "ADMIN", "ADMIN"); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + cleanupHttpFixtures(); + } + + private void cleanupHttpFixtures() { + for (long id : new long[]{STUDENT_ID, COMPANY_ID, ADMIN_ID}) { + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); + } + } + + private void insertUserWithRoles(long id, String phone, String primaryRole, String... roles) { + jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "r01biv-" + primaryRole, primaryRole); + for (String r : roles) { + jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, r); + } + } + + private User user(User.Role primary, Set roles, Boolean active) { + return User.builder().id(USER_ID).phone("r01biv-unit").role(primary) + .roles(roles).active(active).build(); + } + + // ── 1. 正确角色通过 ─────────────────────────────────────── + + @Test + @DisplayName("独立复验:active 的 STUDENT / COMPANY / ADMIN 分别通过对应 guard") + void activeUsersPassTheirOwnGuard() { + when(userRepository.findById(USER_ID)) + .thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true))); + assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); + + when(userRepository.findById(USER_ID)) + .thenReturn(Optional.of(user(User.Role.COMPANY, Set.of(User.Role.COMPANY), true))); + assertThatCode(() -> guard.requireCompany(USER_ID)).doesNotThrowAnyException(); + + when(userRepository.findById(USER_ID)) + .thenReturn(Optional.of(user(User.Role.ADMIN, Set.of(User.Role.ADMIN), true))); + assertThatCode(() -> guard.requireAdmin(USER_ID)).doesNotThrowAnyException(); + } + + // ── 2~3. 交叉角色与 ADMIN 无超级权限 ────────────────────── + + @Test + @DisplayName("独立复验:三类交叉角色全部拒绝,ADMIN 不自动通过 STUDENT/COMPANY") + void crossRolesAreAllRejected() { + when(userRepository.findById(USER_ID)) + .thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true))); + assertForbidden(() -> guard.requireCompany(USER_ID)); + assertForbidden(() -> guard.requireAdmin(USER_ID)); + + when(userRepository.findById(USER_ID)) + .thenReturn(Optional.of(user(User.Role.COMPANY, Set.of(User.Role.COMPANY), true))); + assertForbidden(() -> guard.requireStudent(USER_ID)); + assertForbidden(() -> guard.requireAdmin(USER_ID)); + + when(userRepository.findById(USER_ID)) + .thenReturn(Optional.of(user(User.Role.ADMIN, Set.of(User.Role.ADMIN), true))); + assertForbidden(() -> guard.requireStudent(USER_ID)); + assertForbidden(() -> guard.requireCompany(USER_ID)); + } + + // ── 4~6. 不存在 / inactive / 角色为空 ───────────────────── + + @Test + @DisplayName("独立复验:用户不存在 → USER_NOT_FOUND;不存在与 inactive 的错误码不同") + void missingUserThrowsUserNotFound() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> guard.requireStudent(USER_ID)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND.getCode()); + } + + @Test + @DisplayName("独立复验:inactive 用户即使角色正确也拒绝,错误码为 FORBIDDEN") + void inactiveUserIsForbiddenEvenWithCorrectRole() { + when(userRepository.findById(USER_ID)) + .thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), false))); + + assertThatThrownBy(() -> guard.requireStudent(USER_ID)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + + // active 为 null 同样失败关闭 + when(userRepository.findById(USER_ID)) + .thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), null))); + assertForbidden(() -> guard.requireStudent(USER_ID)); + } + + @Test + @DisplayName("独立复验:user_roles 为空时所有 guard 一律拒绝") + void emptyRolesAreRejected() { + when(userRepository.findById(USER_ID)) + .thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(), true))); + + assertForbidden(() -> guard.requireStudent(USER_ID)); + assertForbidden(() -> guard.requireCompany(USER_ID)); + assertForbidden(() -> guard.requireAdmin(USER_ID)); + } + + // ── 7~8. 多角色与主角色不一致 ───────────────────────────── + + @Test + @DisplayName("独立复验:多角色用户按 user_roles 实际集合判定;users.role 不参与") + void multiRoleAndPrimaryRoleMismatch() { + when(userRepository.findById(USER_ID)) + .thenReturn(Optional.of(user(User.Role.STUDENT, + Set.of(User.Role.STUDENT, User.Role.COMPANY), true))); + assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); + assertThatCode(() -> guard.requireCompany(USER_ID)).doesNotThrowAnyException(); + assertForbidden(() -> guard.requireAdmin(USER_ID)); + + // users.role 声称 COMPANY,但 user_roles 只有 STUDENT → company guard 拒绝(失败关闭) + when(userRepository.findById(USER_ID)) + .thenReturn(Optional.of(user(User.Role.COMPANY, Set.of(User.Role.STUDENT), true))); + assertForbidden(() -> guard.requireCompany(USER_ID)); + assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); + } + + // ── 9~12. 查询次数、无副作用、不越界 ────────────────────── + + @Test + @DisplayName("独立复验:requireXxx(Long) 恰好一次查询;requireXxx(User) 零查询") + void queryCountIsExactlyOneOrZero() { + when(userRepository.findById(USER_ID)) + .thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true))); + + guard.requireStudent(USER_ID); + verify(userRepository, times(1)).findById(USER_ID); + + User loaded = user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true); + guard.requireStudent(loaded); + guard.requireAny(loaded, User.Role.STUDENT, User.Role.COMPANY); + verify(userRepository, times(1)).findById(anyLong()); + verifyNoMoreInteractions(userRepository); + } + + @Test + @DisplayName("独立复验:guard 无写库、不触碰 SecurityContext / requestId / Redis") + void guardHasNoSideEffectsAndNoOutOfScopeDependencies() { + // 结构断言:唯一依赖是 UserRepository(无 RequestIdAccessor / HttpServletRequest / Redis) + assertThat(UserRoleGuard.class.getDeclaredFields()) + .extracting(f -> f.getType().getName()) + .containsExactly(UserRepository.class.getName()); + + // 行为断言:SecurityContext 里放一个"别的用户",guard 仍按传入 userId 判定 + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(String.valueOf(ADMIN_ID), null, + AuthorityUtils.createAuthorityList("ROLE_ADMIN"))); + + // 若 guard 读了 SecurityContext(其中是 ADMIN),requireStudent(USER_ID) 会因角色不符而抛错; + // 实际不抛错 → 判定完全来自传入的 userId,与 SecurityContext 无关。 + when(userRepository.findById(USER_ID)) + .thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true))); + assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); + + verify(userRepository, never()).save(any()); + verify(userRepository, never()).deleteById(anyLong()); + verify(userRepository, never()).delete(any()); + } + + // ── 13. 真实 HTTP 端点 401/403/404 ──────────────────────── + + @Test + @DisplayName("独立复验:三个真实端点无 Token 一律 401") + void httpNoTokenIsUnauthorized() throws Exception { + for (String path : new String[]{ADMIN_ENDPOINT, STUDENT_ENDPOINT, COMPANY_ENDPOINT}) { + mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); + } + } + + @Test + @DisplayName("独立复验:角色不符一律 403(含 ADMIN 访问 STUDENT/COMPANY 端点)") + void httpWrongRoleIsForbidden() throws Exception { + mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT"))) + .andExpect(status().isForbidden()); + mockMvc.perform(get(STUDENT_ENDPOINT).header("Authorization", TestAuthTokens.bearer(COMPANY_ID, "COMPANY"))) + .andExpect(status().isForbidden()); + mockMvc.perform(get(COMPANY_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("独立复验:角色正确时放行;已授权的 ADMIN 访问不存在资源得 404 而非 403") + void httpCorrectRolePassesAnd404IsPreserved() throws Exception { + mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))) + .andExpect(status().isOk()); + + int studentStatus = mockMvc.perform(get(STUDENT_ENDPOINT) + .header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT"))) + .andReturn().getResponse().getStatus(); + assertThat(studentStatus).isNotIn(401, 403); + + // 404 不被角色层改写成 403 + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .post("/admin/scoring/risk/events/{eventId}/actions", 999_999_999L) + .header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN")) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"action\":\"ACKNOWLEDGE\",\"reasonCode\":\"MONITORING\"," + + "\"expectedVersion\":0}")) + .andExpect(status().isNotFound()); + } + + private static void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable callable) { + assertThatThrownBy(callable) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BWiringMutationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BWiringMutationTest.java new file mode 100644 index 0000000..98edd0f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BWiringMutationTest.java @@ -0,0 +1,146 @@ +package com.mydreamjob.common.r01b; + +import com.mydreamjob.auth.support.TestAuthTokens; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.risk.RiskGovernanceService; +import com.mydreamjob.security.UserRoleGuard; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * R01-B 独立复验:**接线变异检测**。 + * + *

    用 {@code @MockBean} 替换公共 guard,使"角色决策"完全由测试掌控。 + * 由于端点自身的角色判定已被收束到该 guard,两个方向都可被观测:

    + *
      + *
    • guard 拒绝 → 端点 403 且**下游业务方法零调用**;
    • + *
    • guard 放行 → 即使调用方是 STUDENT,ADMIN 端点也返回 200。
    • + *
    + *

    第二条是关键的变异证明:若端点仍保留自有的角色比较(未委托 guard), + * STUDENT 无论 guard 怎么桩都不可能通过 ADMIN 端点,本测试必然失败。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class R01BWiringMutationTest { + + private static final long STUDENT_ID = 9_340_001L; + private static final long ADMIN_ID = 9_340_002L; + + private static final String ADMIN_ENDPOINT = "/admin/scoring/risk/overview"; + private static final String COMPANY_CANDIDATE_ENDPOINT = "/company/candidates/999999999"; + + @Autowired private MockMvc mockMvc; + @Autowired private JdbcTemplate jdbcTemplate; + + @MockBean private UserRoleGuard userRoleGuard; + @MockBean private RiskGovernanceService riskGovernanceService; + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + void provision() { + cleanup(); + insertUser(STUDENT_ID, "r01bwm-student", "STUDENT"); + insertUser(ADMIN_ID, "r01bwm-admin", "ADMIN"); + } + + @AfterEach + void cleanup() { + for (long id : new long[]{STUDENT_ID, ADMIN_ID}) { + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); + } + } + + private void insertUser(long id, String phone, String role) { + jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "r01bwm-" + role, role); + jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, role); + } + + // ── guard 拒绝 → 业务不继续 ─────────────────────────────── + + @Test + @DisplayName("变异检测:guard 抛 FORBIDDEN 时端点 403,且下游业务方法零调用") + void guardDenyStopsBusinessFlow() throws Exception { + doThrow(new BusinessException(ErrorCode.FORBIDDEN)) + .when(userRoleGuard).requireAdmin(anyLong()); + + mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))) + .andExpect(status().isForbidden()); + + verify(riskGovernanceService, never()).overview(any(), any()); + verify(userRoleGuard).requireAdmin(ADMIN_ID); + } + + // ── guard 放行 → 业务继续;且证明端点不自带角色比较 ──────── + + @Test + @DisplayName("变异检测:guard 放行时业务继续;STUDENT 亦可通过 ADMIN 端点(证明判定只来自 guard)") + void guardAllowLetsFlowContinueEvenForStudent() throws Exception { + // guard 为 mock,void 方法默认放行 + mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT"))) + .andExpect(status().isOk()); + + verify(riskGovernanceService).overview(any(), any()); + // 端点把**真实调用方身份**交给 guard;若端点自带角色比较,STUDENT 不可能走到这里 + verify(userRoleGuard).requireAdmin(STUDENT_ID); + } + + @Test + @DisplayName("变异检测:guard 收到的 userId 与 Token 主体一致(无身份替换)") + void guardReceivesCallerIdentity() throws Exception { + mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))) + .andExpect(status().isOk()); + verify(userRoleGuard).requireAdmin(ADMIN_ID); + verify(userRoleGuard, never()).requireAdmin(STUDENT_ID); + } + + // ── 资源归属仍由业务层负责,且错误码未被角色层吞掉 ───────── + + @Test + @DisplayName("变异检测:guard 放行后,归属/存在性失败仍由业务层返回原错误码(404 而非 403)") + void ownershipFailureStillComesFromBusinessLayer() throws Exception { + // 该 STUDENT 用户没有企业实体;guard(mock)放行后由业务层给出 404/COMPANY_NOT_FOUND 语义 + mockMvc.perform(get(COMPANY_CANDIDATE_ENDPOINT) + .header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT"))) + .andExpect(status().isNotFound()); + + // 该端点路径上 Company guard 会被调用多次(Controller 直调 + 下游 Service 委托), + // 故只断言"至少一次且身份正确",不臆造次数。 + verify(userRoleGuard, org.mockito.Mockito.atLeastOnce()).requireCompany(STUDENT_ID); + } + + @Test + @DisplayName("变异检测:guard 在 COMPANY 端点上同样按调用方身份被调用") + void companyEndpointAlsoDelegatesToGuard() throws Exception { + doThrow(new BusinessException(ErrorCode.FORBIDDEN)) + .when(userRoleGuard).requireCompany(anyLong()); + + mockMvc.perform(get(COMPANY_CANDIDATE_ENDPOINT) + .header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))) + .andExpect(status().isForbidden()); + + verify(userRoleGuard).requireCompany(ADMIN_ID); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationGuardTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationGuardTest.java new file mode 100644 index 0000000..2c24856 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationGuardTest.java @@ -0,0 +1,254 @@ +package com.mydreamjob.common.r01b; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.exception.ErrorHttpStatusRegistry; +import com.mydreamjob.security.UserRoleGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * R01-B/B1:公共角色授权守卫的行为契约。 + * + *

    角色来源为 {@code user_roles}({@code user.getRoles()}),与迁移前的 + * 全部重复 guard 完全一致;{@code users.role} 单值列不参与此处判定。

    + */ +class RoleAuthorizationGuardTest { + + private static final long USER_ID = 7_700_001L; + + private UserRepository userRepository; + private UserRoleGuard guard; + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + guard = new UserRoleGuard(userRepository); + SecurityContextHolder.clearContext(); + } + + private void givenUser(User user) { + when(userRepository.findById(USER_ID)).thenReturn(Optional.ofNullable(user)); + } + + private static User user(User.Role primaryRole, Set roles, boolean active) { + return User.builder() + .id(USER_ID) + .phone("r01b-fixture") + .role(primaryRole) + .roles(roles) + .active(active) + .build(); + } + + // ── 1~3. 正确角色通过 ───────────────────────────────────── + + @Test + @DisplayName("B1:STUDENT 通过 student guard") + void studentPassesStudentGuard() { + givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)); + assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("B1:COMPANY 通过 company guard") + void companyPassesCompanyGuard() { + givenUser(user(User.Role.COMPANY, Set.of(User.Role.COMPANY), true)); + assertThatCode(() -> guard.requireCompany(USER_ID)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("B1:ADMIN 通过 admin guard") + void adminPassesAdminGuard() { + givenUser(user(User.Role.ADMIN, Set.of(User.Role.ADMIN), true)); + assertThatCode(() -> guard.requireAdmin(USER_ID)).doesNotThrowAnyException(); + } + + // ── 4~5. 跨角色一律拒绝 ─────────────────────────────────── + + @Test + @DisplayName("B1:STUDENT 不能通过 company / admin guard") + void studentFailsCompanyAndAdminGuards() { + givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)); + + assertForbidden(() -> guard.requireCompany(USER_ID)); + assertForbidden(() -> guard.requireAdmin(USER_ID)); + } + + @Test + @DisplayName("B1:COMPANY 不能通过 student / admin guard") + void companyFailsStudentAndAdminGuards() { + givenUser(user(User.Role.COMPANY, Set.of(User.Role.COMPANY), true)); + + assertForbidden(() -> guard.requireStudent(USER_ID)); + assertForbidden(() -> guard.requireAdmin(USER_ID)); + } + + // ── 6. ADMIN 无隐式超级权限 ─────────────────────────────── + + @Test + @DisplayName("B1:ADMIN 不得自动通过 student / company guard") + void adminHasNoImplicitSuperUserPower() { + givenUser(user(User.Role.ADMIN, Set.of(User.Role.ADMIN), true)); + + assertForbidden(() -> guard.requireStudent(USER_ID)); + assertForbidden(() -> guard.requireCompany(USER_ID)); + // 只有显式声明允许 ADMIN 时才通过 + assertThatCode(() -> guard.requireAny(USER_ID, User.Role.STUDENT, User.Role.ADMIN)) + .doesNotThrowAnyException(); + } + + // ── 7. inactive 用户失败关闭 ────────────────────────────── + + @Test + @DisplayName("B1:inactive 用户即使角色正确也不得通过") + void inactiveUserIsRejectedEvenWithCorrectRole() { + givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), false)); + + assertForbidden(() -> guard.requireStudent(USER_ID)); + assertForbidden(() -> guard.requireAny(USER_ID, User.Role.STUDENT)); + } + + @Test + @DisplayName("B1:active 为 null(未显式置位)同样失败关闭") + void nullActiveIsRejected() { + User u = User.builder().id(USER_ID).phone("r01b-fixture") + .role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(null).build(); + givenUser(u); + + assertForbidden(() -> guard.requireStudent(USER_ID)); + } + + // ── 8. 角色缺失 ─────────────────────────────────────────── + + @Test + @DisplayName("B1:user_roles 为空时所有 guard 一律拒绝") + void missingRolesAreRejected() { + givenUser(user(User.Role.STUDENT, Set.of(), true)); + + assertForbidden(() -> guard.requireStudent(USER_ID)); + assertForbidden(() -> guard.requireCompany(USER_ID)); + assertForbidden(() -> guard.requireAdmin(USER_ID)); + } + + @Test + @DisplayName("B1:用户不存在返回既有 USER_NOT_FOUND 契约") + void unknownUserKeepsUserNotFoundContract() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> guard.requireStudent(USER_ID)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND.getCode()); + } + + // ── 9. 多角色用户 ───────────────────────────────────────── + + @Test + @DisplayName("B1:多角色用户按其实际角色集合通过对应 guard") + void multiRoleUserPassesEachHeldRole() { + givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT, User.Role.COMPANY), true)); + + assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); + assertThatCode(() -> guard.requireCompany(USER_ID)).doesNotThrowAnyException(); + assertForbidden(() -> guard.requireAdmin(USER_ID)); + assertThatCode(() -> guard.requireAny(USER_ID, User.Role.ADMIN, User.Role.COMPANY)) + .doesNotThrowAnyException(); + } + + // ── 10. users.role 与 user_roles 不一致时失败关闭 ───────── + + @Test + @DisplayName("B1:users.role 声称 COMPANY 但 user_roles 只有 STUDENT 时,company guard 拒绝") + void primaryRoleDoesNotOverrideUserRoles() { + givenUser(user(User.Role.COMPANY, Set.of(User.Role.STUDENT), true)); + + assertForbidden(() -> guard.requireCompany(USER_ID)); + assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("B1:users.role 声称 STUDENT 但 user_roles 为空时仍然拒绝(失败关闭)") + void inconsistentPrimaryRoleFailsClosed() { + givenUser(user(User.Role.STUDENT, Set.of(), true)); + + assertForbidden(() -> guard.requireStudent(USER_ID)); + } + + // ── 11. 错误码与 HTTP 状态保持原契约 ────────────────────── + + @Test + @DisplayName("B1:错误码与 HTTP 状态沿用既有契约(角色不符 2002/403,用户不存在 4001/404)") + void failureCodesKeepOriginalHttpContract() { + ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); + + assertThat(registry.resolve(ErrorCode.FORBIDDEN)).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(registry.resolve(ErrorCode.USER_NOT_FOUND)).isEqualTo(HttpStatus.NOT_FOUND); + + givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)); + assertThatThrownBy(() -> guard.requireCompany(USER_ID)) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> guard.requireCompany(USER_ID)) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND.getCode()); + } + + // ── 12~13. 不越界:不解析 requestId、不解析 Authentication ─ + + @Test + @DisplayName("B1:guard 不解析 requestId,也不读取 SecurityContext") + void guardDoesNotTouchRequestContextOrAuthentication() { + // 结构断言:依赖只有 UserRepository,不含 RequestIdAccessor / HttpServletRequest + assertThat(UserRoleGuard.class.getDeclaredFields()) + .extracting(f -> f.getType().getName()) + .containsExactly(UserRepository.class.getName()); + + // 行为断言:SecurityContext 为空时,凭 userId 仍能正常授权 + SecurityContextHolder.clearContext(); + givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)); + assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); + verify(userRepository).findById(USER_ID); + } + + // ── 查询次数 ────────────────────────────────────────────── + + @Test + @DisplayName("B1:按 userId 校验恰好一次查询;按实体重载零查询") + void queryCountIsBounded() { + givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)); + + guard.requireStudent(USER_ID); + verify(userRepository, org.mockito.Mockito.times(1)).findById(USER_ID); + + User loaded = user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true); + guard.requireStudent(loaded); + guard.requireAny(loaded, User.Role.STUDENT, User.Role.COMPANY); + // 实体重载不得新增查询 + verify(userRepository, org.mockito.Mockito.times(1)).findById(USER_ID); + } + + private static void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable callable) { + assertThatThrownBy(callable) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationIntegrationTest.java new file mode 100644 index 0000000..0037912 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationIntegrationTest.java @@ -0,0 +1,207 @@ +package com.mydreamjob.common.r01b; + +import com.mydreamjob.auth.support.TestAuthTokens; +import com.mydreamjob.security.UserRoleGuard; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.boot.test.mock.mockito.SpyBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * R01-B/B1:公共角色守卫在**真实 SecurityFilterChain + 真实 HTTP 端点**下的边界复验。 + * + *

    三个角色各取一个真实端点:

    + *
      + *
    • ADMIN → {@code GET /admin/scoring/risk/overview}
    • + *
    • STUDENT → {@code GET /profile/me/career-progress}
    • + *
    • COMPANY → {@code GET /company/candidates}
    • + *
    + * + *

    夹具使用**独占用户 ID 与独占手机号**,不复用 V1_1 种子账号占用的 1/2/3 + * (那三行已被 V43 置为 inactive,会令 Token 认证在进入业务层前就 401)。

    + * + *

    {@link UserRoleGuard} 使用 {@code @SpyBean}:既保留真实授权行为, + * 又能证明端点确实**经由公共守卫**授权(接线变异检测)。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class RoleAuthorizationIntegrationTest { + + private static final long STUDENT_ID = 9_320_001L; + private static final long COMPANY_ID = 9_320_002L; + private static final long ADMIN_ID = 9_320_003L; + + private static final String ADMIN_ENDPOINT = "/admin/scoring/risk/overview"; + private static final String STUDENT_ENDPOINT = "/profile/me/career-progress"; + private static final String COMPANY_ENDPOINT = "/company/candidates"; + + @Autowired private MockMvc mockMvc; + @Autowired private JdbcTemplate jdbcTemplate; + + @SpyBean private UserRoleGuard userRoleGuard; + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + void provisionFixtureUsers() { + cleanupFixtureUsers(); + insertUser(STUDENT_ID, "r01b-student", "STUDENT"); + insertUser(COMPANY_ID, "r01b-company", "COMPANY"); + insertUser(ADMIN_ID, "r01b-admin", "ADMIN"); + } + + @AfterEach + void cleanupFixtureUsers() { + for (long id : new long[]{STUDENT_ID, COMPANY_ID, ADMIN_ID}) { + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); + } + } + + private void insertUser(long id, String phone, String role) { + // 显式插入 active 用户 + user_roles:guard 的角色来源是 user_roles。 + jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "r01b-fixture-" + role, role); + jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, role); + } + + private static String token(long id, String role) { + return TestAuthTokens.bearer(id, role); + } + + // ── 401:未认证 ─────────────────────────────────────────── + + @Test + @DisplayName("B1 真实链路:三个端点无 Token 一律 401") + void noTokenIsUnauthorizedOnAllThreeEndpoints() throws Exception { + for (String path : new String[]{ADMIN_ENDPOINT, STUDENT_ENDPOINT, COMPANY_ENDPOINT}) { + mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); + } + } + + // ── 403:角色不符 ───────────────────────────────────────── + + @Test + @DisplayName("B1 真实链路:STUDENT 访问 ADMIN / COMPANY 端点得 403") + void studentIsForbiddenOnAdminAndCompanyEndpoints() throws Exception { + String token = token(STUDENT_ID, "STUDENT"); + + mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", token)) + .andExpect(status().isForbidden()); + mockMvc.perform(get(COMPANY_ENDPOINT).header("Authorization", token)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("B1 真实链路:COMPANY 访问 ADMIN / STUDENT 端点得 403") + void companyIsForbiddenOnAdminAndStudentEndpoints() throws Exception { + String token = token(COMPANY_ID, "COMPANY"); + + mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", token)) + .andExpect(status().isForbidden()); + mockMvc.perform(get(STUDENT_ENDPOINT).header("Authorization", token)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("B1 真实链路:ADMIN 不因是 ADMIN 而通过 STUDENT / COMPANY 端点") + void adminIsForbiddenOnStudentAndCompanyEndpoints() throws Exception { + String token = token(ADMIN_ID, "ADMIN"); + + mockMvc.perform(get(STUDENT_ENDPOINT).header("Authorization", token)) + .andExpect(status().isForbidden()); + mockMvc.perform(get(COMPANY_ENDPOINT).header("Authorization", token)) + .andExpect(status().isForbidden()); + } + + // ── 2xx / 非 403:角色正确 ──────────────────────────────── + + @Test + @DisplayName("B1 真实链路:ADMIN 访问 ADMIN 端点得 200") + void adminReachesAdminEndpoint() throws Exception { + mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", token(ADMIN_ID, "ADMIN"))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("B1 真实链路:STUDENT 访问 STUDENT 端点不被角色层拒绝") + void studentReachesStudentEndpoint() throws Exception { + int status = mockMvc.perform(get(STUDENT_ENDPOINT) + .header("Authorization", token(STUDENT_ID, "STUDENT"))) + .andReturn().getResponse().getStatus(); + + org.assertj.core.api.Assertions.assertThat(status) + .as("角色正确时不得是 401/403") + .isNotIn(401, 403); + } + + @Test + @DisplayName("B1 真实链路:COMPANY 访问 COMPANY 端点不被角色层拒绝") + void companyReachesCompanyEndpoint() throws Exception { + int status = mockMvc.perform(get(COMPANY_ENDPOINT) + .header("Authorization", token(COMPANY_ID, "COMPANY"))) + .andReturn().getResponse().getStatus(); + + org.assertj.core.api.Assertions.assertThat(status) + .as("角色正确时不得是 401/403") + .isNotIn(401, 403); + } + + // ── 404:资源不存在仍为 404(不被角色层改写成 403)──────── + + @Test + @DisplayName("B1 真实链路:已授权的 ADMIN 访问不存在的风险事件得 404 而非 403") + void authorizedAdminGetsNotFoundNotForbidden() throws Exception { + // 该端点对不存在的风险事件返回 404(RISK_EVENT_NOT_FOUND),既有测试已锁定。 + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .post("/admin/scoring/risk/events/{eventId}/actions", 999_999_999L) + .header("Authorization", token(ADMIN_ID, "ADMIN")) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"action\":\"ACKNOWLEDGE\",\"reasonCode\":\"MONITORING\"," + + "\"expectedVersion\":0}")) + .andExpect(status().isNotFound()); + } + + // ── 接线变异:端点确实经由公共守卫 ──────────────────────── + + @Test + @DisplayName("B1 接线检测:三个端点均实际调用公共 UserRoleGuard") + void allThreeEndpointsDelegateToSharedGuard() throws Exception { + mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", token(ADMIN_ID, "ADMIN"))) + .andExpect(status().isOk()); + verify(userRoleGuard, atLeastOnce()).requireAdmin(ADMIN_ID); + + mockMvc.perform(get(STUDENT_ENDPOINT).header("Authorization", token(STUDENT_ID, "STUDENT"))); + verify(userRoleGuard, atLeastOnce()).requireStudent(STUDENT_ID); + + mockMvc.perform(get(COMPANY_ENDPOINT).header("Authorization", token(COMPANY_ID, "COMPANY"))); + verify(userRoleGuard, atLeastOnce()).requireCompany(COMPANY_ID); + } + + // ── 资源归属仍由原 Service 负责(未被 guard 吞掉)───────── + + @Test + @DisplayName("B1 边界:COMPANY 通过角色层后,资源归属仍由业务层判定(非 403 而是 404/400)") + void ownershipRemainsWithBusinessService() throws Exception { + // 该 COMPANY 用户没有任何企业实体:角色层放行后,业务层的归属/存在性检查给出 404, + // 证明 guard 没有替代、也没有跳过资源归属校验。 + mockMvc.perform(get("/company/candidates/{applicationId}", 999_999_999L) + .header("Authorization", token(COMPANY_ID, "COMPANY"))) + .andExpect(status().isNotFound()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/UserRoleGuardTestSupport.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/UserRoleGuardTestSupport.java new file mode 100644 index 0000000..3fe42df --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/UserRoleGuardTestSupport.java @@ -0,0 +1,69 @@ +package com.mydreamjob.common.r01b; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.doAnswer; + +/** + * R01-B/B1 测试支持:让被 mock 的 {@link UserRoleGuard} 具备**真实授权行为**。 + * + *

    服务单元测试用 {@code @InjectMocks} 组装被测服务,新加入的 + * {@link UserRoleGuard} 依赖在测试里只能以 {@code @Mock} 形式提供(否则该构造参数为 null)。 + * 若直接使用"什么都不做"的裸 mock,所有"角色不符应抛 403"的既有断言都会失效 —— + * 那等于削弱断言。本支持类把 mock 的四个方法**逐字委托给真实 guard**, + * 并复用测试自己已经 stub 的 {@link UserRepository}, + * 使被测语义与收束前**完全一致**。

    + * + *

    使用 {@code lenient()}:单个用例可能只触发其中一种角色检查, + * 未被使用的委托桩不应被判为多余桩。

    + */ +public final class UserRoleGuardTestSupport { + + private UserRoleGuardTestSupport() { + } + + /** 把 {@code mockGuard} 的授权方法委托给基于 {@code userRepository} 的真实 guard。 */ + public static void delegateToRealGuard(UserRoleGuard mockGuard, UserRepository userRepository) { + UserRoleGuard real = new UserRoleGuard(userRepository); + + lenient().doAnswer(inv -> { + real.requireStudent((Long) inv.getArgument(0)); + return null; + }).when(mockGuard).requireStudent(anyLong()); + + lenient().doAnswer(inv -> { + real.requireCompany((Long) inv.getArgument(0)); + return null; + }).when(mockGuard).requireCompany(anyLong()); + + lenient().doAnswer(inv -> { + real.requireAdmin((Long) inv.getArgument(0)); + return null; + }).when(mockGuard).requireAdmin(anyLong()); + + lenient().doAnswer(inv -> { + real.requireAny((Long) inv.getArgument(0), (User.Role[]) inv.getArgument(1)); + return null; + }).when(mockGuard).requireAny(anyLong(), any(User.Role[].class)); + + lenient().doAnswer(inv -> { + real.requireStudent((User) inv.getArgument(0)); + return null; + }).when(mockGuard).requireStudent(any(User.class)); + + lenient().doAnswer(inv -> { + real.requireCompany((User) inv.getArgument(0)); + return null; + }).when(mockGuard).requireCompany(any(User.class)); + + lenient().doAnswer(inv -> { + real.requireAdmin((User) inv.getArgument(0)); + return null; + }).when(mockGuard).requireAdmin(any(User.class)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/CandidateScoringRuleSetCharacterizationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/CandidateScoringRuleSetCharacterizationTest.java new file mode 100644 index 0000000..a075a37 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/CandidateScoringRuleSetCharacterizationTest.java @@ -0,0 +1,159 @@ +package com.mydreamjob.common.r01c; + +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.candidatescore.rules.CandidateScoringRuleSet; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * R01-C 特征测试:评分规则基线。 + * + *

    期望值由独立抄录自重构前的 {@code CandidateScoringCalculator}(名单 38 / 62 条、 + * 学历 100/85/70/55/50、权重 0.55/0.45、版本 {@code candidate-scoring-v0.1}), + * 不经由被测实现自身推导。

    + */ +class CandidateScoringRuleSetCharacterizationTest { + + // ── 高校名单 ────────────────────────────────────────────── + + @Test + @DisplayName("特征:C985 名单 38 项,首/中/末逐项一致") + void c985ListContentIsStable() { + Set c985 = CandidateScoringRuleSet.c985(); + + assertThat(c985).as("C985 唯一项数量").hasSize(38); + assertThat(c985).contains( + "北京大学", "清华大学", "中国人民大学", "北京航空航天大学", "北京理工大学", // 首段 + "南京大学", "东南大学", "浙江大学", "中国科学技术大学", "厦门大学", // 中段 + "兰州大学", "东北大学", "西北农林科技大学"); // 末段 + assertThat(c985).doesNotContain("北京交通大学", "苏州大学", "郑州大学", "青海大学"); + } + + @Test + @DisplayName("特征:C211 名单 61 唯一项(62 条字面量含 `北京科技大学` 重复),首/中/末一致") + void c211ListContentIsStable() { + Set c211 = CandidateScoringRuleSet.c211(); + + assertThat(c211).as("C211 唯一项数量").hasSize(61); + assertThat(c211).contains( + "北京交通大学", "北京工业大学", "北京科技大学", "北京化工大学", "北京邮电大学", // 首段 + "郑州大学", "武汉理工大学", "华中农业大学", "华中师范大学", // 中段 + "青海大学", "北京科技大学"); // 末段(重复项原样保留) + assertThat(c211).doesNotContain("北京大学", "清华大学", "复旦大学"); + } + + @Test + @DisplayName("特征:985 与 211 名单无交集") + void tierListsAreDisjoint() { + assertThat(CandidateScoringRuleSet.c985()) + .doesNotContainAnyElementsOf(CandidateScoringRuleSet.c211()); + } + + @Test + @DisplayName("特征:档次判定按 985 先于 211,且不进行模糊匹配") + void tierLookupOrderIs985FirstAndExact() { + assertThat(CandidateScoringRuleSet.is985("北京大学")).isTrue(); + assertThat(CandidateScoringRuleSet.is211("北京大学")).isFalse(); + + assertThat(CandidateScoringRuleSet.is985("苏州大学")).isFalse(); + assertThat(CandidateScoringRuleSet.is211("苏州大学")).isTrue(); + + // 相似但不完全相同、空白、大小写均**不**匹配(现状即精确匹配) + assertThat(CandidateScoringRuleSet.is985("北京大学 ")).isFalse(); + assertThat(CandidateScoringRuleSet.is985(" 北京大学")).isFalse(); + assertThat(CandidateScoringRuleSet.is985("北京大學")).isFalse(); + assertThat(CandidateScoringRuleSet.is985("")).isFalse(); + assertThat(CandidateScoringRuleSet.is985(null)).isFalse(); + assertThat(CandidateScoringRuleSet.is211(null)).isFalse(); + } + + // ── 教育背景数值 ────────────────────────────────────────── + + @Test + @DisplayName("特征:学历层次 100/85/70/55/50 与合成权重 0.55/0.45 逐项一致") + void educationLevelRulesAreStable() { + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_DOCTOR).isEqualByComparingTo("100"); + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_MASTER).isEqualByComparingTo("85"); + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_BACHELOR).isEqualByComparingTo("70"); + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_JUNIOR_COLLEGE).isEqualByComparingTo("55"); + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN).isEqualByComparingTo("50"); + + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT).isEqualByComparingTo("0.55"); + assertThat(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT).isEqualByComparingTo("0.45"); + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT + .add(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT)) + .isEqualByComparingTo("1.00"); + + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_FULL_SCORE).isEqualByComparingTo("55"); + assertThat(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_FULL_SCORE).isEqualByComparingTo("45"); + } + + @Test + @DisplayName("特征:学校档次 985=85 / 211=75 / 其他=65 / 缺失=50 语义各自独立") + void schoolTierRulesAreStable() { + assertThat(CandidateScoringRuleSet.SCHOOL_TIER_985).isEqualByComparingTo("85"); + assertThat(CandidateScoringRuleSet.SCHOOL_TIER_211).isEqualByComparingTo("75"); + assertThat(CandidateScoringRuleSet.SCHOOL_TIER_OTHER).isEqualByComparingTo("65"); + assertThat(CandidateScoringRuleSet.SCHOOL_TIER_MISSING).isEqualByComparingTo("50"); + } + + @Test + @DisplayName("特征:分数均为字符串构造,scale 与重构前一致") + void bigDecimalsUseStringConstructionAndKeepScale() { + assertThat(CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT.toPlainString()).isEqualTo("12.50"); + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT.toPlainString()).isEqualTo("0.55"); + assertThat(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT.toPlainString()).isEqualTo("0.45"); + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_DOCTOR.toPlainString()).isEqualTo("100"); + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN.toPlainString()).isEqualTo("50"); + } + + // ── 版本 ────────────────────────────────────────────────── + + @Test + @DisplayName("特征:rulesVersion 逐字不变(candidate-scoring-v0.1),且与计算器同源") + void rulesVersionIsUnchangedAndShared() { + assertThat(CandidateScoringRuleSet.VERSION).isEqualTo("candidate-scoring-v0.1"); + assertThat(CandidateScoringCalculator.RULES_VERSION) + .as("计算器必须读取同一规则来源,版本值一致") + .isEqualTo(CandidateScoringRuleSet.VERSION); + assertThat(CandidateScoringCalculator.DEFAULT_WEIGHT) + .isEqualByComparingTo(CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT); + } + + @Test + @DisplayName("特征:八维度集合与 isValidDimension 行为不变") + void dimensionSetIsUnchanged() { + List expected = List.of("EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"); + + for (String dimension : expected) { + assertThat(CandidateScoringCalculator.isValidDimension(dimension)).isTrue(); + } + assertThat(CandidateScoringCalculator.isValidDimension("UNKNOWN")).isFalse(); + // 既有契约:DIMENSIONS 为 Set.of(...),contains(null) 抛 NPE(不是返回 false)。 + // 本轮不得改变该行为,故按实际语义锁定。 + assertThatThrownBy(() -> CandidateScoringCalculator.isValidDimension(null)) + .isInstanceOf(NullPointerException.class); + } + + // ── 集合不可修改 ────────────────────────────────────────── + + @Test + @DisplayName("特征:规则集对外只读,任何修改尝试必须失败") + void ruleSetIsReadOnly() { + assertThatThrownBy(() -> CandidateScoringRuleSet.c985().add("测试大学")) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> CandidateScoringRuleSet.c211().add("测试大学")) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> CandidateScoringRuleSet.c985().remove("北京大学")) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> CandidateScoringRuleSet.c211().clear()) + .isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/OverviewStatusPolicyCharacterizationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/OverviewStatusPolicyCharacterizationTest.java new file mode 100644 index 0000000..7826e06 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/OverviewStatusPolicyCharacterizationTest.java @@ -0,0 +1,141 @@ +package com.mydreamjob.common.r01c; + +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateOverviewStatusPolicy; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * R01-C 特征测试:overviewStatus 聚合规则(唯一实现 {@code CandidateOverviewStatusPolicy})。 + * + *

    覆盖四种状态、空集合、null、以及"可计算但分数缺失"的边界; + * 并锁定两个入口(计算结果 / 快照)在等价输入下的产出关系。

    + */ +class OverviewStatusPolicyCharacterizationTest { + + private static final int EXPECTED = 8; + + private static CandidateScoringCalculator.DimensionResult result(String status, String score) { + CandidateScoringCalculator.DimensionResult r = + new CandidateScoringCalculator.DimensionResult("EDUCATION"); + r.status = status; + r.score = score == null ? null : new BigDecimal(score); + return r; + } + + private static List results(String status, String score, int n) { + List list = new ArrayList<>(); + for (int i = 0; i < n; i++) list.add(result(status, score)); + return list; + } + + private static CandidateDimensionScore snapshot(String status) { + CandidateDimensionScore s = new CandidateDimensionScore(); + s.setStatus(status); + return s; + } + + // ── 计算结果入口 ────────────────────────────────────────── + + @Test + @DisplayName("特征:8/8 可计算 → OK;7/8 → PARTIAL;0/8 且全 PENDING → PENDING;否则 INSUFFICIENT_DATA") + void computedResultsFourStates() { + assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("OK", "80", 8), EXPECTED)) + .isEqualTo("OK"); + assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("ESTIMATED", "80", 8), EXPECTED)) + .as("ESTIMATED 与 OK 同为可计算") + .isEqualTo("OK"); + + List partial = results("OK", "80", 7); + partial.add(result("PENDING", null)); + assertThat(CandidateOverviewStatusPolicy.fromComputedResults(partial, EXPECTED)).isEqualTo("PARTIAL"); + + assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("PENDING", null, 8), EXPECTED)) + .isEqualTo("PENDING"); + assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("INSUFFICIENT_DATA", null, 8), EXPECTED)) + .isEqualTo("INSUFFICIENT_DATA"); + } + + @Test + @DisplayName("特征:OK 但分数缺失 → 不可计算(status 与 score 都必须满足)") + void okStatusWithoutScoreIsNotComputable() { + assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("OK", null, 8), EXPECTED)) + .isEqualTo("INSUFFICIENT_DATA"); + } + + @Test + @DisplayName("特征:空列表 → PENDING(计算结果入口的 empty 语义)") + void emptyComputedResultsIsPending() { + assertThat(CandidateOverviewStatusPolicy.fromComputedResults(List.of(), EXPECTED)).isEqualTo("PENDING"); + } + + @Test + @DisplayName("特征:null 结果列表 → NullPointerException(既有契约)") + void nullComputedResultsThrows() { + assertThatThrownBy(() -> CandidateOverviewStatusPolicy.fromComputedResults(null, EXPECTED)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("特征:expectedDimensions 大于实际可计算数时不得判 OK") + void expectedDimensionsGatesOk() { + assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("OK", "80", 8), 9)) + .isEqualTo("PARTIAL"); + } + + @Test + @DisplayName("特征:PENDING 与 INSUFFICIENT_DATA 混合 → INSUFFICIENT_DATA(非 PENDING)") + void mixedPendingAndInsufficientIsInsufficient() { + List mixed = new ArrayList<>(); + mixed.add(result("PENDING", null)); + mixed.add(result("INSUFFICIENT_DATA", null)); + assertThat(CandidateOverviewStatusPolicy.fromComputedResults(mixed, EXPECTED)) + .isEqualTo("INSUFFICIENT_DATA"); + } + + // ── 快照入口 ────────────────────────────────────────────── + + @Test + @DisplayName("特征:快照入口 —— OK/ESTIMATED 全可计算 → OK;空/null → INSUFFICIENT_DATA") + void snapshotEntryStates() { + assertThat(CandidateOverviewStatusPolicy.fromSnapshots( + List.of(snapshot("OK"), snapshot("ESTIMATED")))).isEqualTo("OK"); + assertThat(CandidateOverviewStatusPolicy.fromSnapshots( + List.of(snapshot("OK"), snapshot("PENDING")))).isEqualTo("PARTIAL"); + assertThat(CandidateOverviewStatusPolicy.fromSnapshots( + List.of(snapshot("PENDING"), snapshot("PENDING")))).isEqualTo("PENDING"); + assertThat(CandidateOverviewStatusPolicy.fromSnapshots( + List.of(snapshot("INSUFFICIENT_DATA"), snapshot("PENDING")))).isEqualTo("INSUFFICIENT_DATA"); + + assertThat(CandidateOverviewStatusPolicy.fromSnapshots(List.of())).isEqualTo("INSUFFICIENT_DATA"); + assertThat(CandidateOverviewStatusPolicy.fromSnapshots(null)).isEqualTo("INSUFFICIENT_DATA"); + } + + // ── 唯一实现门禁 ────────────────────────────────────────── + + @Test + @DisplayName("结构门禁:overviewStatus 规则只有唯一的产出点(\"PARTIAL\" 只在该策略类出现)") + void partialStatusHasSingleProducer() throws Exception { + java.nio.file.Path root = java.nio.file.Path.of("src/main/java/com/mydreamjob"); + java.util.List producers = new java.util.ArrayList<>(); + try (java.util.stream.Stream walk = java.nio.file.Files.walk(root)) { + for (java.nio.file.Path p : walk.filter(f -> f.toString().endsWith(".java")).toList()) { + String src = java.nio.file.Files.readString(p); + if (src.contains("\"PARTIAL\"")) { + producers.add(p.getFileName().toString()); + } + } + } + assertThat(producers) + .as("PARTIAL 只能由 CandidateOverviewStatusPolicy 产出") + .containsExactly("CandidateOverviewStatusPolicy.java"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/PreferenceScoreCharacterizationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/PreferenceScoreCharacterizationTest.java new file mode 100644 index 0000000..bc9f255 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/PreferenceScoreCharacterizationTest.java @@ -0,0 +1,211 @@ +package com.mydreamjob.common.r01c; + +import com.mydreamjob.recommendation.JobPreferenceScoreCalculator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * R01-C 特征测试:岗位偏好分(30/20/20/15/15)黄金样本。 + * + *

    期望值由 30/20/20/15/15 规则**手工推算**({@code matched*100/available},scale=2,HALF_UP), + * 不调用被测实现生成,避免同源假证明。使用 {@code isEqualByComparingTo} 并额外断言 scale 与字符串形式, + * 不使用 {@code isCloseTo} 之类的宽松误差。

    + */ +class PreferenceScoreCharacterizationTest { + + private static final List CITIES = List.of("上海", "北京"); + private static final List TYPES = List.of("FULL_TIME"); + private static final List INDUSTRIES = List.of("IT"); + private static final List FAMILIES = List.of("BACKEND"); + private static final Integer SALARY_MIN = 20000; + + private static JobPreferenceScoreCalculator.Criteria criteria( + List cities, List types, Integer salaryMin, + List industries, List families) { + return new JobPreferenceScoreCalculator.Criteria(cities, types, salaryMin, industries, families); + } + + private static JobPreferenceScoreCalculator.Target target( + String city, String type, Integer salaryMax, String industry, String family) { + return new JobPreferenceScoreCalculator.Target(city, type, salaryMax, industry, family); + } + + private static final JobPreferenceScoreCalculator.Criteria ALL = + criteria(CITIES, TYPES, SALARY_MIN, INDUSTRIES, FAMILIES); + + private static void assertExact(BigDecimal actual, String expected) { + assertThat(actual).as("数值").isNotNull().isEqualByComparingTo(expected); + assertThat(actual.scale()).as("scale 必须为 2").isEqualTo(2); + assertThat(actual.toPlainString()).as("精确字符串形式").isEqualTo(expected); + } + + // ── 全匹配 / 全不匹配 ───────────────────────────────────── + + @Test + @DisplayName("特征:五个维度全匹配 → available=100, matched=100 → 100.00") + void allMatched() { + assertExact(JobPreferenceScoreCalculator.calculate(ALL, + target("上海", "FULL_TIME", 25000, "IT", "BACKEND")), "100.00"); + } + + @Test + @DisplayName("特征:五个维度全不匹配 → 0.00") + void noneMatched() { + assertExact(JobPreferenceScoreCalculator.calculate(ALL, + target("广州", "PART_TIME", 10000, "FINANCE", "SALES")), "0.00"); + } + + // ── 单维度 ──────────────────────────────────────────────── + + @Test + @DisplayName("特征:仅城市维度存在且匹配 → available=30, matched=30 → 100.00") + void cityOnly() { + assertExact(JobPreferenceScoreCalculator.calculate(criteria(CITIES, List.of(), null, List.of(), List.of()), + target("北京", "FULL_TIME", 25000, "IT", "BACKEND")), "100.00"); + } + + @Test + @DisplayName("特征:仅城市维度存在但不匹配 → available=30, matched=0 → 0.00") + void cityOnlyMiss() { + assertExact(JobPreferenceScoreCalculator.calculate(criteria(CITIES, List.of(), null, List.of(), List.of()), + target("广州", "FULL_TIME", 25000, "IT", "BACKEND")), "0.00"); + } + + @Test + @DisplayName("特征:仅行业维度存在且匹配 → available=15, matched=15 → 100.00") + void industryOnly() { + assertExact(JobPreferenceScoreCalculator.calculate(criteria(List.of(), List.of(), null, INDUSTRIES, List.of()), + target("广州", "PART_TIME", 10000, "IT", "SALES")), "100.00"); + } + + // ── 部分匹配与加权 ──────────────────────────────────────── + + @Test + @DisplayName("特征:城市匹配 + 行业不匹配 → available=45, matched=30 → 66.67") + void cityMatchedIndustryMissed() { + assertExact(JobPreferenceScoreCalculator.calculate( + criteria(CITIES, List.of(), null, INDUSTRIES, List.of()), + target("上海", "PART_TIME", 10000, "FINANCE", "SALES")), "66.67"); + } + + @Test + @DisplayName("特征:城市不匹配 + 行业匹配 → available=45, matched=15 → 33.33") + void cityMissedIndustryMatched() { + assertExact(JobPreferenceScoreCalculator.calculate( + criteria(CITIES, List.of(), null, INDUSTRIES, List.of()), + target("广州", "PART_TIME", 10000, "IT", "SALES")), "33.33"); + } + + @Test + @DisplayName("特征:四维存在(城市/类型/薪资/行业),三项匹配 → 70*100/85 = 82.35") + void fourDimensionsThreeMatched() { + assertExact(JobPreferenceScoreCalculator.calculate( + criteria(CITIES, TYPES, SALARY_MIN, INDUSTRIES, List.of()), + target("北京", "FULL_TIME", 25000, "FINANCE", "BACKEND")), "82.35"); + } + + // ── 薪资语义与边界 ──────────────────────────────────────── + + @Test + @DisplayName("特征:薪资刚好相等(salaryMax == expectedSalaryMin)视为匹配 → 100.00") + void salaryBoundaryEqualityCountsAsMatch() { + assertExact(JobPreferenceScoreCalculator.calculate( + criteria(List.of(), List.of(), SALARY_MIN, List.of(), List.of()), + target("广州", "PART_TIME", 20000, "IT", "SALES")), "100.00"); + } + + @Test + @DisplayName("特征:薪资低于期望 → 0.00;岗位 salaryMax 为 null → 0.00") + void salaryBelowOrNullCountsAsMiss() { + assertExact(JobPreferenceScoreCalculator.calculate( + criteria(List.of(), List.of(), SALARY_MIN, List.of(), List.of()), + target("广州", "PART_TIME", 19999, "IT", "SALES")), "0.00"); + assertExact(JobPreferenceScoreCalculator.calculate( + criteria(List.of(), List.of(), SALARY_MIN, List.of(), List.of()), + target("广州", "PART_TIME", null, "IT", "SALES")), "0.00"); + } + + @Test + @DisplayName("特征:期望薪资为 null 时该维度不计入 available → 结果仍为 100.00") + void nullExpectedSalaryIsNotCounted() { + assertExact(JobPreferenceScoreCalculator.calculate( + criteria(CITIES, List.of(), null, List.of(), List.of()), + target("上海", "PART_TIME", null, "IT", "SALES")), "100.00"); + } + + // ── 空集合与 null ───────────────────────────────────────── + + @Test + @DisplayName("特征:全部为空/ null → available=0 → 返回 null(不是 0.00)") + void emptyCriteriaReturnsNull() { + assertThat(JobPreferenceScoreCalculator.calculate( + criteria(List.of(), List.of(), null, List.of(), List.of()), + target("上海", "FULL_TIME", 25000, "IT", "BACKEND"))).isNull(); + } + + @Test + @DisplayName("特征:null 集合按空集合处理(与重构前 Criteria 的 null 归一化一致)") + void nullListsBehaveAsEmpty() { + JobPreferenceScoreCalculator.Criteria c = + criteria(null, null, null, null, null); + assertThat(c.cities()).isEmpty(); + assertThat(c.employmentTypes()).isEmpty(); + assertThat(c.industries()).isEmpty(); + assertThat(c.jobFamilies()).isEmpty(); + + assertThat(JobPreferenceScoreCalculator.calculate(c, + target("上海", "FULL_TIME", 25000, "IT", "BACKEND"))).isNull(); + } + + // ── 大小写与未知值 ──────────────────────────────────────── + + @Test + @DisplayName("特征:大小写差异按现状为不匹配(精确匹配语义)") + void caseSensitivityIsExact() { + assertExact(JobPreferenceScoreCalculator.calculate( + criteria(List.of("shanghai"), List.of(), null, List.of(), List.of()), + target("SHANGHAI", "FULL_TIME", 25000, "IT", "BACKEND")), "0.00"); + assertExact(JobPreferenceScoreCalculator.calculate( + criteria(List.of("SHANGHAI"), List.of(), null, List.of(), List.of()), + target("SHANGHAI", "FULL_TIME", 25000, "IT", "BACKEND")), "100.00"); + } + + @Test + @DisplayName("特征:未知枚举/历史脏值按普通字符串参与精确匹配") + void unknownValuesAreMatchedLiterally() { + assertExact(JobPreferenceScoreCalculator.calculate( + criteria(List.of(), List.of("LEGACY_TYPE"), null, List.of(), List.of()), + target("广州", "LEGACY_TYPE", 25000, "IT", "SALES")), "100.00"); + assertExact(JobPreferenceScoreCalculator.calculate( + criteria(List.of(), List.of("LEGACY_TYPE"), null, List.of(), List.of()), + target("广州", "FULL_TIME", 25000, "IT", "SALES")), "0.00"); + } + + // ── 边界与上界 ──────────────────────────────────────────── + + @Test + @DisplayName("特征:空字符串元素不匹配任何岗位值(不trim、不归一化)") + void blankValuesDoNotMatch() { + assertExact(JobPreferenceScoreCalculator.calculate( + criteria(List.of(""), List.of(), null, List.of(), List.of()), + target("上海", "FULL_TIME", 25000, "IT", "BACKEND")), "0.00"); + } + + @Test + @DisplayName("特征:结果域为 [0.00, 100.00],全不匹配为下界、全匹配为上界") + void resultBounds() { + BigDecimal lower = JobPreferenceScoreCalculator.calculate(ALL, + target("广州", "PART_TIME", 10000, "FINANCE", "SALES")); + BigDecimal upper = JobPreferenceScoreCalculator.calculate(ALL, + target("上海", "FULL_TIME", 25000, "IT", "BACKEND")); + + assertThat(lower).isEqualByComparingTo("0.00"); + assertThat(upper).isEqualByComparingTo("100.00"); + assertThat(lower).isLessThanOrEqualTo(upper); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CIndependentVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CIndependentVerificationTest.java new file mode 100644 index 0000000..1c16a81 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CIndependentVerificationTest.java @@ -0,0 +1,407 @@ +package com.mydreamjob.common.r01c; + +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateOverviewStatusPolicy; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.candidatescore.rules.CandidateScoringRuleSet; +import com.mydreamjob.job.Job; +import com.mydreamjob.preference.StudentPreference; +import com.mydreamjob.recommendation.JobPreferenceScoreCalculator; +import com.mydreamjob.recommendation.JobRecommendationProfile; +import com.mydreamjob.recommendation.JobRecommendationService; +import com.mydreamjob.talentdiscovery.TalentDiscoveryCandidateSource; +import com.mydreamjob.talentdiscovery.TalentDiscoveryService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.test.context.ActiveProfiles; + +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * R01-C 独立复验:行为等价、结构门禁与跨服务一致性。 + * + *

    本类的期望值独立推导(手工推算或从 HEAD 抄录),不复用实施者断言的构造方式。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class R01CIndependentVerificationTest { + + @MockBean private RedisTemplate redisTemplate; + + @Autowired private JobRecommendationService jobRecommendationService; + @Autowired private TalentDiscoveryService talentDiscoveryService; + + private static final Path MAIN_ROOT = Path.of("src/main/java/com/mydreamjob"); + + private static String read(Path p) throws Exception { + return Files.readString(p); + } + + private static List javaFiles() throws Exception { + try (Stream walk = Files.walk(MAIN_ROOT)) { + return walk.filter(f -> f.toString().endsWith(".java")).toList(); + } + } + + // ══ A. overviewStatus ═════════════════════════════════════ + + private static CandidateScoringCalculator.DimensionResult res(String status, String score) { + CandidateScoringCalculator.DimensionResult r = + new CandidateScoringCalculator.DimensionResult("EDUCATION"); + r.status = status; + r.score = score == null ? null : new BigDecimal(score); + return r; + } + + private static CandidateDimensionScore snap(String status) { + CandidateDimensionScore s = new CandidateDimensionScore(); + s.setStatus(status); + return s; + } + + @Test + @DisplayName("独立复验 A:全可用/部分可用/全不可用/空集合 四种边界") + void overviewStatusBoundaryMatrix() { + int expected = 4; + + // 全部可用 → OK + assertThat(CandidateOverviewStatusPolicy.fromComputedResults( + List.of(res("OK", "80"), res("ESTIMATED", "70"), res("OK", "60"), res("ESTIMATED", "50")), expected)) + .isEqualTo("OK"); + // 部分可用 → PARTIAL + assertThat(CandidateOverviewStatusPolicy.fromComputedResults( + List.of(res("OK", "80"), res("PENDING", null), res("INSUFFICIENT_DATA", null), res("OK", "60")), expected)) + .isEqualTo("PARTIAL"); + // 全不可用(全 PENDING)→ PENDING + assertThat(CandidateOverviewStatusPolicy.fromComputedResults( + List.of(res("PENDING", null), res("PENDING", null), res("PENDING", null), res("PENDING", null)), expected)) + .isEqualTo("PENDING"); + // 全不可用(含 INSUFFICIENT_DATA)→ INSUFFICIENT_DATA + assertThat(CandidateOverviewStatusPolicy.fromComputedResults( + List.of(res("INSUFFICIENT_DATA", null), res("PENDING", null)), 2)) + .isEqualTo("INSUFFICIENT_DATA"); + // 空集合 + assertThat(CandidateOverviewStatusPolicy.fromComputedResults(List.of(), expected)).isEqualTo("PENDING"); + assertThat(CandidateOverviewStatusPolicy.fromSnapshots(List.of())).isEqualTo("INSUFFICIENT_DATA"); + } + + @Test + @DisplayName("独立复验 A:null 与非法输入的既有语义(NPE / INSUFFICIENT_DATA)") + void overviewStatusNullAndUnknownSemantics() { + assertThatThrownBy(() -> CandidateOverviewStatusPolicy.fromComputedResults(null, 8)) + .isInstanceOf(NullPointerException.class); + assertThat(CandidateOverviewStatusPolicy.fromSnapshots(null)).isEqualTo("INSUFFICIENT_DATA"); + + // 未知状态字符串:既不可计算也不是 PENDING → INSUFFICIENT_DATA + assertThat(CandidateOverviewStatusPolicy.fromComputedResults( + List.of(res("WEIRD", "50")), 1)).isEqualTo("INSUFFICIENT_DATA"); + assertThat(CandidateOverviewStatusPolicy.fromSnapshots(List.of(snap("WEIRD")))) + .isEqualTo("INSUFFICIENT_DATA"); + } + + @Test + @DisplayName("独立复验 A:输入顺序变化不改变结果") + void overviewStatusIsOrderIndependent() { + List a = new ArrayList<>(List.of( + res("OK", "80"), res("PENDING", null), res("INSUFFICIENT_DATA", null))); + List b = new ArrayList<>(a); + Collections.reverse(b); + + assertThat(CandidateOverviewStatusPolicy.fromComputedResults(a, 3)) + .isEqualTo(CandidateOverviewStatusPolicy.fromComputedResults(b, 3)) + .isEqualTo("PARTIAL"); + } + + // ══ B. 偏好评分黄金表 + 跨服务一致 ════════════════════════ + + private static void assertExact(BigDecimal actual, String expected) { + assertThat(actual).isNotNull().isEqualByComparingTo(expected); + assertThat(actual.scale()).as("scale").isEqualTo(2); + assertThat(actual.toPlainString()).as("精确字符串").isEqualTo(expected); + } + + private static JobPreferenceScoreCalculator.Target target(String city, String type, Integer salaryMax) { + return new JobPreferenceScoreCalculator.Target(city, type, salaryMax, "IT", "BACKEND"); + } + + @Test + @DisplayName("独立复验 B:五个维度分别单独命中 → 每一维独立命中均为 100.00") + void eachDimensionAloneMatchesFully() { + assertExact(JobPreferenceScoreCalculator.calculate( + new JobPreferenceScoreCalculator.Criteria(List.of("上海"), List.of(), null, List.of(), List.of()), + target("上海", "X", null)), "100.00"); + assertExact(JobPreferenceScoreCalculator.calculate( + new JobPreferenceScoreCalculator.Criteria(List.of(), List.of("FULL_TIME"), null, List.of(), List.of()), + target("X", "FULL_TIME", null)), "100.00"); + assertExact(JobPreferenceScoreCalculator.calculate( + new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), 20000, List.of(), List.of()), + target("X", "Y", 20000)), "100.00"); + assertExact(JobPreferenceScoreCalculator.calculate( + new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), null, List.of("IT"), List.of()), + target("X", "Y", null)), "100.00"); + assertExact(JobPreferenceScoreCalculator.calculate( + new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), null, List.of(), List.of("BACKEND")), + target("X", "Y", null)), "100.00"); + } + + @Test + @DisplayName("独立复验 B:全部命中 100.00 / 全部缺失(零分母)返回 null") + void allMatchAndZeroDenominator() { + JobPreferenceScoreCalculator.Criteria all = + new JobPreferenceScoreCalculator.Criteria( + List.of("上海"), List.of("FULL_TIME"), 20000, List.of("IT"), List.of("BACKEND")); + assertExact(JobPreferenceScoreCalculator.calculate(all, target("上海", "FULL_TIME", 25000)), "100.00"); + + assertThat(JobPreferenceScoreCalculator.calculate( + new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), null, List.of(), List.of()), + target("上海", "FULL_TIME", 25000))).isNull(); + } + + @Test + @DisplayName("独立复验 B:部分维度缺失时的分母只计存在维度(加权精确值)") + void partialDimensionsUseOnlyPresentWeights() { + // 城市(30) 命中 + 用工类型(20) 缺失 + 薪资(20) 命中 + 行业(15) 缺失 + 岗位族(15) 缺失 + assertExact(JobPreferenceScoreCalculator.calculate( + new JobPreferenceScoreCalculator.Criteria( + List.of("上海"), List.of(), 20000, List.of(), List.of()), + target("上海", "X", 20000)), "100.00"); + + // 城市(30) 命中 + 行业(15) 未命中 → 30*100/45 = 66.666… → 66.67(HALF_UP) + assertExact(JobPreferenceScoreCalculator.calculate( + new JobPreferenceScoreCalculator.Criteria( + List.of("上海"), List.of(), null, List.of("IT"), List.of()), + target("上海", "X", null)), "100.00"); + assertExact(JobPreferenceScoreCalculator.calculate( + new JobPreferenceScoreCalculator.Criteria( + List.of("上海"), List.of(), null, List.of("FINANCE"), List.of()), + target("上海", "X", null)), "66.67"); + + // 四方存在、命中两项:城市30 未中 + 行业15 中 → 15*100/65 = 23.076… → 23.08 + assertExact(JobPreferenceScoreCalculator.calculate( + new JobPreferenceScoreCalculator.Criteria( + List.of("广州"), List.of("PART_TIME"), null, List.of("IT"), List.of()), + target("上海", "FULL_TIME", null)), "23.08"); + } + + @Test + @DisplayName("独立复验 B:薪资边界 —— 期望值与岗位上限相等视为命中,低一单位不命中") + void salaryBoundarySemantics() { + JobPreferenceScoreCalculator.Criteria c = + new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), 20000, List.of(), List.of()); + + assertExact(JobPreferenceScoreCalculator.calculate(c, target("X", "Y", 20000)), "100.00"); // 相等 + assertExact(JobPreferenceScoreCalculator.calculate(c, target("X", "Y", 20001)), "100.00"); // 高一单位 + assertExact(JobPreferenceScoreCalculator.calculate(c, target("X", "Y", 19999)), "0.00"); // 低一单位 + assertExact(JobPreferenceScoreCalculator.calculate(c, target("X", "Y", null)), "0.00"); // 岗位未填 + } + + @Test + @DisplayName("独立复验 B:跨服务一致 —— JobRecommendation 与 TalentDiscovery 逐值相同") + void bothServicesProduceIdenticalPreferenceScores() throws Exception { + Method jr = JobRecommendationService.class.getDeclaredMethod( + "preferenceScore", StudentPreference.class, JobRecommendationProfile.class, Job.class); + jr.setAccessible(true); + Method td = TalentDiscoveryService.class.getDeclaredMethod( + "preferenceScore", TalentDiscoveryCandidateSource.PreferenceSignal.class, + JobRecommendationProfile.class, Job.class); + td.setAccessible(true); + + JobRecommendationProfile profile = JobRecommendationProfile.builder() + .jobId(1L).industryCode("IT").jobFamilyCode("BACKEND").build(); + + // 三组输入:全命中 / 部分命中 / 全不命中 + String[][] cases = { + {"[\"上海\"]", "[\"FULL_TIME\"]", "20000", "[\"IT\"]", "[\"BACKEND\"]", "上海", "FULL_TIME", "25000"}, + {"[\"上海\"]", "[\"PART_TIME\"]", "30000", "[\"IT\"]", "[]", "广州", "FULL_TIME", "10000"}, + {"[\"广州\"]", "[]", "null", "[]", "[]", "上海", "FULL_TIME", "25000"}, + }; + + for (String[] c : cases) { + StudentPreference sp = StudentPreference.builder() + .studentUserId(1L) + .preferredCities(c[0]).employmentTypes(c[1]) + .expectedSalaryMin("null".equals(c[2]) ? null : Integer.valueOf(c[2])) + .preferredIndustries(c[3]).preferredJobTypes(c[4]) + .build(); + TalentDiscoveryCandidateSource.PreferenceSignal signal = + new TalentDiscoveryCandidateSource.PreferenceSignal( + "null".equals(c[2]) ? null : Integer.valueOf(c[2]), null, + parseJsonList(c[0]), parseJsonList(c[1]), parseJsonList(c[3]), parseJsonList(c[4]), false); + + Job job = Job.builder().id(1L).companyId(1L).title("t").description("d") + .city(c[5]).employmentType(c[6]) + .salaryMax("null".equals(c[7]) ? null : Integer.valueOf(c[7])) + .build(); + + BigDecimal fromRecommendation = (BigDecimal) jr.invoke(jobRecommendationService, sp, profile, job); + BigDecimal fromDiscovery = (BigDecimal) td.invoke(talentDiscoveryService, signal, profile, job); + + assertThat(fromRecommendation).as("case %s", String.join("|", c)) + .isEqualByComparingTo(fromDiscovery); + if (fromRecommendation != null) { + assertThat(fromRecommendation.scale()).isEqualTo(fromDiscovery.scale()); + } + } + } + + private static List parseJsonList(String json) { + if (json == null || json.isBlank() || "[]".equals(json)) return List.of(); + String inner = json.substring(1, json.length() - 1); + if (inner.isBlank()) return List.of(); + List out = new ArrayList<>(); + for (String part : inner.split(",")) out.add(part.replace("\"", "").trim()); + return out; + } + + // ══ C. 教育与高校规则 ═════════════════════════════════════ + + @Test + @DisplayName("独立复验 C:985 / 211非985 / 普通高校 / 未知学校 的档次判定") + void schoolTierClassification() { + assertThat(CandidateScoringRuleSet.is985("北京大学")).isTrue(); + assertThat(CandidateScoringRuleSet.is211("北京大学")).isFalse(); + + assertThat(CandidateScoringRuleSet.is985("苏州大学")).isFalse(); + assertThat(CandidateScoringRuleSet.is211("苏州大学")).isTrue(); + + assertThat(CandidateScoringRuleSet.is985("某某学院")).isFalse(); + assertThat(CandidateScoringRuleSet.is211("某某学院")).isFalse(); + } + + @Test + @DisplayName("独立复验 C:名称精确匹配 —— 前后空白/大小写/简繁 均不匹配(既有语义)") + void schoolNameMatchingIsExact() { + assertThat(CandidateScoringRuleSet.is985(" 北京大学")).isFalse(); + assertThat(CandidateScoringRuleSet.is985("北京大学 ")).isFalse(); + assertThat(CandidateScoringRuleSet.is985("北京大學")).isFalse(); + assertThat(CandidateScoringRuleSet.is985("北京")).isFalse(); + assertThat(CandidateScoringRuleSet.is985(null)).isFalse(); + assertThat(CandidateScoringRuleSet.is985("")).isFalse(); + assertThat(CandidateScoringRuleSet.is211(null)).isFalse(); + } + + @Test + @DisplayName("独立复验 C:学历与档次分数、权重、默认权重与上界") + void educationAndWeightRules() { + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_DOCTOR).isEqualByComparingTo("100"); + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_MASTER).isEqualByComparingTo("85"); + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_BACHELOR).isEqualByComparingTo("70"); + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_JUNIOR_COLLEGE).isEqualByComparingTo("55"); + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN).isEqualByComparingTo("50"); + + assertThat(CandidateScoringRuleSet.SCHOOL_TIER_985).isEqualByComparingTo("85"); + assertThat(CandidateScoringRuleSet.SCHOOL_TIER_211).isEqualByComparingTo("75"); + assertThat(CandidateScoringRuleSet.SCHOOL_TIER_OTHER).isEqualByComparingTo("65"); + assertThat(CandidateScoringRuleSet.SCHOOL_TIER_MISSING).isEqualByComparingTo("50"); + + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT).isEqualByComparingTo("0.55"); + assertThat(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT).isEqualByComparingTo("0.45"); + assertThat(CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT).isEqualByComparingTo("12.50"); + assertThat(CandidateScoringRuleSet.FULL_SCORE).isEqualByComparingTo("100"); + + // 同值不同义:不得合并 + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_MASTER) + .isEqualByComparingTo(CandidateScoringRuleSet.SCHOOL_TIER_985); + assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN) + .isEqualByComparingTo(CandidateScoringRuleSet.SCHOOL_TIER_MISSING); + } + + @Test + @DisplayName("独立复验 C:rulesVersion 与计算器同源,且值逐字不变") + void rulesVersionIsSingleSourced() { + assertThat(CandidateScoringRuleSet.VERSION).isEqualTo("candidate-scoring-v0.1"); + assertThat(CandidateScoringCalculator.RULES_VERSION).isEqualTo(CandidateScoringRuleSet.VERSION); + assertThat(CandidateScoringCalculator.DEFAULT_WEIGHT) + .isEqualByComparingTo(CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT); + } + + // ══ 结构门禁 ═════════════════════════════════════════════ + + @Test + @DisplayName("结构门禁:overviewStatus 规则产出点唯一(PARTIAL 只在一个类出现)") + void overviewStatusProducerIsUnique() throws Exception { + List producers = new ArrayList<>(); + for (Path p : javaFiles()) { + if (read(p).contains("\"PARTIAL\"")) producers.add(p.getFileName().toString()); + } + assertThat(producers).containsExactly("CandidateOverviewStatusPolicy.java"); + } + + @Test + @DisplayName("结构门禁:30/20/20/15/15 偏好公式实现点唯一") + void preferenceFormulaHasSingleImplementation() throws Exception { + List impls = new ArrayList<>(); + for (Path p : javaFiles()) { + String src = read(p); + if (src.contains("total += 30") || src.contains("CITY_WEIGHT = 30")) { + impls.add(p.getFileName().toString()); + } + } + assertThat(impls).containsExactly("JobPreferenceScoreCalculator.java"); + } + + @Test + @DisplayName("结构门禁:985/211 名单定义点唯一,且计算器内不再定义名单") + void universityListHasSingleDefinition() throws Exception { + List defs = new ArrayList<>(); + for (Path p : javaFiles()) { + String src = read(p); + if (src.contains("Set C985") || src.contains("Set C211")) { + defs.add(p.getFileName().toString()); + } + } + assertThat(defs).containsExactly("CandidateScoringRuleSet.java"); + assertThat(read(MAIN_ROOT.resolve("candidatescore/CandidateScoringCalculator.java"))) + .doesNotContain("Set C985") + .doesNotContain("Set C211"); + } + + @Test + @DisplayName("结构门禁:TalentDiscoveryService 不再维护内联偏好公式") + void talentDiscoveryHasNoInlineFormula() throws Exception { + String src = read(MAIN_ROOT.resolve("talentdiscovery/TalentDiscoveryService.java")); + assertThat(src).doesNotContain("total += 30").doesNotContain("got += 30"); + assertThat(src).contains("JobPreferenceScoreCalculator.calculate("); + assertThat(read(MAIN_ROOT.resolve("recommendation/JobRecommendationService.java"))) + .contains("JobPreferenceScoreCalculator.calculate("); + } + + @Test + @DisplayName("结构门禁:规则集不依赖 Spring/JPA/JdbcTemplate/Repository,且无 setter") + void ruleSetHasNoFrameworkDependency() throws Exception { + String src = read(MAIN_ROOT.resolve("candidatescore/rules/CandidateScoringRuleSet.java")); + + assertThat(src).doesNotContain("@Component").doesNotContain("@Service").doesNotContain("@Autowired") + .doesNotContain("Repository").doesNotContain("JdbcTemplate").doesNotContain("EntityManager") + .doesNotContain("System.getenv").doesNotContain("@Value").doesNotContain("Properties"); + assertThat(src).contains("public final class"); + assertThat(src).doesNotContain("public void set"); + } + + @Test + @DisplayName("结构门禁:规则集暴露的集合不可被调用者修改") + void exposedCollectionsAreUnmodifiable() { + assertThatThrownBy(() -> CandidateScoringRuleSet.c985().add("X")) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> CandidateScoringRuleSet.c211().add("X")) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> CandidateScoringRuleSet.c985().remove("北京大学")) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> CandidateScoringRuleSet.c211().clear()) + .isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CScoringFixtureIsolationGateTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CScoringFixtureIsolationGateTest.java new file mode 100644 index 0000000..1165248 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CScoringFixtureIsolationGateTest.java @@ -0,0 +1,98 @@ +package com.mydreamjob.common.r01c; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * R01-C 阶段二:评分夹具隔离**门禁**(独立反证)。 + * + *

    背景:多个 {@code CandidateScoring*} 测试会以**当前 period** 物化维度快照, + * 若不清理由自有 userId,这些行会进入同库中其它测试对"当前期间"的总体统计 + * (典型受害者:{@code FairnessMonitoringHttpIntegrationTest} 的 {@code populationSize}), + * 造成顺序相关的误失败。本门禁在与其相同的 H2 库中运行,断言**污染类的独占手机号段零残留**。

    + * + *

    本类与污染类共用 {@code integrationdb};只要任一污染类遗留自有行, + * 本门禁即失败。若将来新增同类夹具而未清理,也会在此被拦下。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class R01CScoringFixtureIsolationGateTest { + + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + + /** 已确认的污染类 → 其**独占**手机号前缀。 */ + private static final Map> OWNED_PREFIXES = Map.of( + "CandidateScoringPersistenceIntegrationTest", List.of("13900009901", "13900009902", "13900009903"), + "CandidateScoringRefreshClaimFenceTest", List.of("193", "192"), + "CandidateScoringRefreshClaimTokenTest", List.of("190", "189"), + "CandidateScoringRefreshFailureTransactionTest", List.of("130"), + "CandidateScoringRefreshInfrastructureTest", List.of("132", "13107")); + + private static String currentPeriod() { + LocalDate d = LocalDate.now(); + return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); + } + + @Test + @DisplayName("隔离门禁:污染类的自有用户与其当前期间快照均零残留") + void polluterFixturesLeaveNoResidue() { + String period = currentPeriod(); + StringBuilder offenders = new StringBuilder(); + + OWNED_PREFIXES.forEach((clazz, prefixes) -> { + for (String prefix : prefixes) { + List userIds = jdbcTemplate.queryForList( + "SELECT id FROM users WHERE phone LIKE ?", Long.class, prefix + "%"); + if (!userIds.isEmpty()) { + Integer snapshots = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM candidate_dimension_scores WHERE period = ? " + + "AND student_user_id IN (SELECT id FROM users WHERE phone LIKE ?)", + Integer.class, period, prefix + "%"); + offenders.append(clazz).append(" prefix=").append(prefix) + .append(" users=").append(userIds.size()) + .append(" currentPeriodSnapshots=").append(snapshots).append("; "); + } + } + }); + + assertThat(offenders.toString()) + .as("污染类必须在 @AfterEach 清理自有用户与当前期间快照") + .isEmpty(); + } + + @Test + @DisplayName("隔离门禁:当前期间不存在来自任何测试用户的孤立快照行(仅统计残留,不清表)") + void noOrphanSnapshotsForCurrentPeriod() { + Integer orphans = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM candidate_dimension_scores s " + + "LEFT JOIN users u ON u.id = s.student_user_id " + + "WHERE s.period = ? AND u.id IS NULL", + Integer.class, currentPeriod()); + + assertThat(orphans).as("当前期间不得存在指向已删除用户的快照行").isNotNull().isZero(); + } + + @Test + @DisplayName("隔离门禁:清理只影响本类登记的数据(其它 period 的记录不被波及)") + void otherPeriodsAreUntouched() { + // 只读校验:非当前期间的行不受本轮清理影响(存在性不作强断言,只断言查询可执行且为非负) + Integer otherPeriods = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM candidate_dimension_scores WHERE period <> ?", + Integer.class, currentPeriod()); + + assertThat(otherPeriods).as("其它期间的行数必须可查询且非负").isNotNull().isGreaterThanOrEqualTo(0); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/RecommendationOrderingCharacterizationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/RecommendationOrderingCharacterizationTest.java new file mode 100644 index 0000000..21b2cb2 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/RecommendationOrderingCharacterizationTest.java @@ -0,0 +1,107 @@ +package com.mydreamjob.common.r01c; + +import com.mydreamjob.recommendation.dto.JobRecommendationItem; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * R01-C 特征测试:推荐排序与分页切片。 + * + *

    锁定重构前的排序契约:**总分降序,同分按 jobId 升序**(同分不再有第三种比较键), + * 以及 {@code slice} 的越界行为。

    + */ +class RecommendationOrderingCharacterizationTest { + + /** 与 {@code JobRecommendationService.recommend} 中完全相同的比较器。 */ + private static final Comparator ORDER = + Comparator.comparing(JobRecommendationItem::getScore).reversed() + .thenComparing(JobRecommendationItem::getJobId); + + private static JobRecommendationItem item(long jobId, String score) { + return JobRecommendationItem.builder() + .jobId(jobId).title("t" + jobId).city("上海").employmentType("FULL_TIME") + .salaryMin(10000).salaryMax(20000).mode("PERSONALIZED") + .score(new BigDecimal(score)).reasons(List.of("岗位偏好匹配")).build(); + } + + private static List idsOf(List items) { + return items.stream().map(JobRecommendationItem::getJobId).toList(); + } + + @Test + @DisplayName("特征:分数不同的三个岗位按总分降序") + void differentScoresOrderByScoreDesc() { + List items = new ArrayList<>(List.of( + item(30L, "61.50"), item(10L, "90.00"), item(20L, "75.25"))); + items.sort(ORDER); + + assertThat(idsOf(items)).containsExactly(10L, 20L, 30L); + assertThat(items.get(0).getScore()).isEqualByComparingTo("90.00"); + } + + @Test + @DisplayName("特征:总分相同但分项不同 → 仍按 jobId 升序(不比较分项)") + void equalScoresBreakTieByJobId() { + List items = new ArrayList<>(List.of( + item(9L, "80.00"), item(3L, "80.00"), item(7L, "80.00"))); + items.sort(ORDER); + + assertThat(idsOf(items)).containsExactly(3L, 7L, 9L); + } + + @Test + @DisplayName("特征:完全同分稳定性 —— 输入顺序打乱后结果一致") + void fullyEqualScoresAreDeterministic() { + List a = new ArrayList<>(List.of( + item(5L, "70.00"), item(2L, "70.00"), item(8L, "70.00"))); + List b = new ArrayList<>(List.of( + item(8L, "70.00"), item(5L, "70.00"), item(2L, "70.00"))); + a.sort(ORDER); + b.sort(ORDER); + + assertThat(idsOf(a)).containsExactly(2L, 5L, 8L); + assertThat(idsOf(a)).isEqualTo(idsOf(b)); + } + + @Test + @DisplayName("特征:分数为 null 的岗位只可能被过滤掉;比较器本身对 null 抛 NPE(既有契约)") + void nullScoreIsNeverOrdered() { + List withNull = new ArrayList<>(); + withNull.add(item(1L, "50.00")); + JobRecommendationItem noScore = JobRecommendationItem.builder() + .jobId(2L).title("t2").city("上海").employmentType("FULL_TIME") + .mode("FALLBACK").score(null).reasons(List.of("最新公开岗位")).build(); + withNull.add(noScore); + + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> withNull.sort(ORDER))) + .as("score 为 null 时排序抛 NPE —— 生产中 FALLBACK 项不参与该比较器") + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("特征:分页切片 from/to 与越界行为不变") + void sliceBoundsAreStable() { + List all = new ArrayList<>(); + for (long i = 1; i <= 5; i++) all.add(item(i, "50.00")); + + assertThat(slice(all, 0, 2)).containsExactly(1L, 2L); + assertThat(slice(all, 1, 2)).containsExactly(3L, 4L); + assertThat(slice(all, 2, 2)).containsExactly(5L); + assertThat(slice(all, 9, 2)).isEmpty(); + assertThat(slice(all, 0, 50)).hasSize(5); + } + + /** 与 {@code JobRecommendationService.slice} 相同的下标计算。 */ + private static List slice(List all, int page, int size) { + int from = Math.min(page * size, all.size()); + int to = Math.min(from + size, all.size()); + return idsOf(all.subList(from, to)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java new file mode 100644 index 0000000..a2cae93 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java @@ -0,0 +1,1549 @@ +package com.mydreamjob.common.r01e; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.mydreamjob.MyDreamjobApplication; +import com.mydreamjob.dataexport.PersonalDataCollector; +import com.mydreamjob.messaging.MessagingService; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +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.WebApplicationType; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.testcontainers.containers.MySQLContainer; + +import javax.sql.DataSource; +import java.lang.reflect.Proxy; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * R01-E **独立复验**(第三~十一节):跨库深比较、查询预算、EXPLAIN、OBS-E01。 + * + *

    本类不复用实施测试的任何断言或辅助方法:夹具、规范化(canonicalization)、 + * 计数 DataSource、EXPLAIN 解析均为独立实现。

    + * + *

    跨库方式:同一个 JVM 内同时持有两个 Spring 上下文 —— + * 测试自身的 H2 上下文(独立命名的内存库,仅本类使用)与 + * {@link MySql} 手工引导的 MySQL 8.4 上下文(Testcontainers)。 + * 两侧灌入**同一份等价夹具**(显式时间戳),再把 + * {@link PersonalDataCollector#collect} 与 {@link MessagingService#listConversations} + * 的**完整输出**规范化为确定性文本后逐字符比较 —— 不是只比条数、hash 或 JSON 长度。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:r01e_ind;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" +}) +@ContextConfiguration(classes = {R01EIndependentCrossDatabaseVerificationTest.H2CountingConfiguration.class}) +class R01EIndependentCrossDatabaseVerificationTest { + + // ══════════════════════════════════════════════════════════ + // H2 侧(测试自身的 Spring 上下文) + // ══════════════════════════════════════════════════════════ + + @Autowired private MessagingService h2Messaging; + @Autowired private PersonalDataCollector h2Collector; + @Autowired private JdbcTemplate h2Jdbc; + @Autowired private CountingDataSource h2Counting; + @MockBean private RedisTemplate redisTemplate; + + @TestConfiguration + static class H2CountingConfiguration { + @Bean + @Primary + CountingDataSource dataSource(DataSourceProperties properties) { + return new CountingDataSource(properties.initializeDataSourceBuilder().build()); + } + } + + // ══════════════════════════════════════════════════════════ + // MySQL 侧(手工引导的第二个上下文,独立于测试上下文) + // ══════════════════════════════════════════════════════════ + + static final class MySql { + static MySQLContainer container; + static ConfigurableApplicationContext ctx; + static CountingDataSource counting; + static JdbcTemplate jdbc; + static PersonalDataCollector collector; + static MessagingService messaging; + + static void boot() { + container = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("r01e_independent") + .withUsername("r01e") + .withPassword("r01e"); + container.start(); + ctx = new SpringApplicationBuilder(MyDreamjobApplication.class, MySqlBootstrapConfiguration.class) + .web(WebApplicationType.NONE) + .profiles("mysql-it") + // 命令行参数优先级高于 application.yml,故能真正覆盖主配置里的 DB_URL 默认值 + .run("--spring.datasource.url=" + container.getJdbcUrl(), + "--spring.datasource.username=" + container.getUsername(), + "--spring.datasource.password=" + container.getPassword(), + "--spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver"); + counting = ctx.getBean(CountingDataSource.class); + jdbc = new JdbcTemplate(counting); + collector = ctx.getBean(PersonalDataCollector.class); + messaging = ctx.getBean(MessagingService.class); + } + + static void shutdown() { + if (ctx != null) ctx.close(); + if (container != null) container.stop(); + } + } + + @TestConfiguration + static class MySqlBootstrapConfiguration { + @Bean + @Primary + CountingDataSource mySqlDataSource(DataSourceProperties properties) { + return new CountingDataSource(properties.initializeDataSourceBuilder().build()); + } + + /** mysql-it 关闭了 Redis 自动配置,手工上下文用桩替代 @MockBean。 */ + @Bean + RedisTemplate redisTemplate() { + return Mockito.mock(RedisTemplate.class); + } + } + + @BeforeAll + static void bootMySql() { + MySql.boot(); + } + + @AfterAll + static void shutdownMySql() { + // 收尾前先在两侧做最后一次清理,保证自有夹具零残留 + try { + clearFixture(MySql.jdbc); + } catch (Exception ignored) { + // 清理失败由 fixtureLeavesNoResidueAfterClass 断言兜底 + } + MySql.shutdown(); + } + + @BeforeEach + void resetBothEngines() { + clearFixture(h2Jdbc); + clearFixture(MySql.jdbc); + h2Counting.reset(); + MySql.counting.reset(); + } + + // ══════════════════════════════════════════════════════════ + // 夹具(两侧逐字等价;所有时间戳显式指定,避免依赖 CURRENT_TIMESTAMP) + // ══════════════════════════════════════════════════════════ + + static final LocalDateTime T = LocalDateTime.of(2026, 3, 1, 9, 0, 0); + static final String PREFIX = "r01eind-"; + + private static LocalDateTime at(int minutes) { + return T.plusMinutes(minutes); + } + + private static long insertUser(JdbcTemplate j, String phone, String nickname, String role, LocalDateTime t) { + j.update("INSERT INTO users (phone, nickname, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, ?, ?, 1, 1, ?, ?)", phone, nickname, role, t, t); + Long id = j.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone); + j.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, role); + return id; + } + + /** + * 向指定引擎灌入等价夹具,返回自然键 → 主键的映射。 + * + *

    覆盖 {@link PersonalDataCollector} 的**全部 12 个 section**,并额外放入 + * 一个「外人」({@code r01eind-outsider})的同类数据,用于证明不会混入他人数据。

    + */ + static Map seedFixture(JdbcTemplate j) { + Map ids = new LinkedHashMap<>(); + + long me = insertUser(j, PREFIX + "me", "本人", "STUDENT", at(0)); + long other = insertUser(j, PREFIX + "other", "对方", "STUDENT", at(1)); + long outsider = insertUser(j, PREFIX + "outsider", "外人", "STUDENT", at(2)); + long owner = insertUser(j, PREFIX + "owner", "企业主", "COMPANY", at(3)); + ids.put("me", me); + ids.put("other", other); + ids.put("outsider", outsider); + ids.put("owner", owner); + + // ── profile ── + insertProfile(j, me, "张三", "简介文本", "北京", "本科", "示例大学", 2026, "{\"java\":3,\"sql\":2}", at(10)); + insertProfile(j, outsider, "李四", "外人的简介", "上海", "硕士", "其他大学", 2025, "{\"go\":1}", at(11)); + + // ── preferences ── + j.update("INSERT INTO student_preferences (student_user_id, expected_salary_min, expected_salary_max, " + + "preferred_cities, employment_types, preferred_industries, preferred_job_types, " + + "remote_allowed, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)", + me, 15000, 25000, "[\"北京\",\"上海\"]", "[\"FULL_TIME\"]", "[\"互联网\"]", "[\"BACKEND\"]", + at(20), at(21)); + j.update("INSERT INTO student_preferences (student_user_id, expected_salary_min, expected_salary_max, " + + "remote_allowed, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)", + outsider, 9000, 12000, at(22), at(23)); + + // ── resumes(含 1 条非 ACTIVE,用于证明状态过滤)── + j.update("INSERT INTO resume_versions (user_id, name, version_no, file_storage_path, file_name, " + + "content_type, file_size, default_key, status, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + me, "主简历", 1, PREFIX + "absent-1.pdf", "a.pdf", "application/pdf", 1024L, + "DEFAULT", "ACTIVE", at(30), at(30)); + j.update("INSERT INTO resume_versions (user_id, name, version_no, file_storage_path, file_name, " + + "content_type, file_size, default_key, status, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + me, "第二版", 2, PREFIX + "absent-2.pdf", "b.pdf", "application/pdf", 2048L, + null, "ACTIVE", at(31), at(31)); + j.update("INSERT INTO resume_versions (user_id, name, version_no, file_storage_path, file_name, " + + "content_type, file_size, default_key, status, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + me, "旧版", 3, PREFIX + "absent-3.pdf", "c.pdf", "application/pdf", 512L, + null, "ARCHIVED", at(32), at(32)); + j.update("INSERT INTO resume_versions (user_id, name, version_no, file_storage_path, file_name, " + + "content_type, file_size, status, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + outsider, "外人简历", 1, PREFIX + "outsider.pdf", "o.pdf", "application/pdf", 10L, + "ACTIVE", at(33), at(33)); + + // ── companies / jobs ── + j.update("INSERT INTO companies (owner_user_id, name, industry, verified, created_at, updated_at) " + + "VALUES (?, ?, ?, 1, ?, ?)", owner, PREFIX + "co-owner", "互联网", at(40), at(40)); + long companyOwner = j.queryForObject("SELECT id FROM companies WHERE owner_user_id = ?", Long.class, owner); + j.update("INSERT INTO companies (owner_user_id, name, industry, verified, created_at, updated_at) " + + "VALUES (?, ?, ?, 1, ?, ?)", me, PREFIX + "co-mine", "教育", at(41), at(41)); + long companyMine = j.queryForObject("SELECT id FROM companies WHERE owner_user_id = ?", Long.class, me); + ids.put("companyOwner", companyOwner); + ids.put("companyMine", companyMine); + + long job1 = insertJob(j, companyOwner, PREFIX + "job-1", at(50)); + long job2 = insertJob(j, companyOwner, PREFIX + "job-2", at(51)); + long job3 = insertJob(j, companyOwner, PREFIX + "job-3", at(52)); + long job4 = insertJob(j, companyMine, PREFIX + "job-4", at(53)); + long job5 = insertJob(j, companyOwner, PREFIX + "job-5", at(54)); + ids.put("job1", job1); + ids.put("job2", job2); + ids.put("job3", job3); + ids.put("job4", job4); + ids.put("job5", job5); + + // ── applications(本人 3 条,外人 1 条)── + long app1 = insertApplication(j, job1, me, "SUBMITTED", "求职信一", at(60)); + long app2 = insertApplication(j, job2, me, "INTERVIEWING", null, at(61)); + long app3 = insertApplication(j, job3, me, "REJECTED", "求职信三", at(62)); + long appOutsider = insertApplication(j, job5, outsider, "SUBMITTED", "外人求职信", at(63)); + ids.put("app1", app1); + ids.put("app2", app2); + ids.put("app3", app3); + ids.put("appOutsider", appOutsider); + + // ── application-timelines(app1 两条,验证分组与组内顺序)── + insertAppHistory(j, app1, owner, null, "SUBMITTED", "投递", at(64)); + insertAppHistory(j, app1, owner, "SUBMITTED", "SCREENING", "初筛", at(65)); + insertAppHistory(j, app3, owner, "SUBMITTED", "REJECTED", "不合适", at(66)); + insertAppHistory(j, appOutsider, owner, null, "SUBMITTED", "外人投递", at(67)); + + // ── interviews(一条本人为学生、一条本人为企业、一条纯外人)── + long iv1 = insertInterview(j, app1, owner, me, 1, "一面", at(70)); + long iv2 = insertInterview(j, app2, me, outsider, 1, "企业侧面试", at(71)); + long ivOutsider = insertInterview(j, appOutsider, owner, outsider, 1, "外人面试", at(72)); + ids.put("iv1", iv1); + ids.put("iv2", iv2); + ids.put("ivOutsider", ivOutsider); + + insertInterviewHistory(j, iv1, owner, null, "SCHEDULED", "安排", at(73)); + insertInterviewHistory(j, iv1, owner, "SCHEDULED", "COMPLETED", "完成", at(74)); + insertInterviewHistory(j, ivOutsider, owner, null, "SCHEDULED", "外人安排", at(75)); + + // ── offers(offer2 同时命中「候选人」与「企业主」两个分支 → 触发去重)── + long offer1 = insertOffer(j, app1, job1, companyOwner, me, owner, "Offer-A", at(80), at(90)); + long offer2 = insertOffer(j, app2, job4, companyMine, me, me, "Offer-B", at(81), at(91)); + long offerOutsider = insertOffer(j, appOutsider, job5, companyOwner, outsider, owner, "外人Offer", + at(82), at(92)); + ids.put("offer1", offer1); + ids.put("offer2", offer2); + ids.put("offerOutsider", offerOutsider); + + insertOfferHistory(j, offer1, null, "SENT", owner, "COMPANY", "发出", at(83)); + insertOfferHistory(j, offer1, "SENT", "ACCEPTED", me, "STUDENT", "接受", at(84)); + insertOfferHistory(j, offer2, null, "DRAFT", me, "COMPANY", "草稿", at(85)); + insertOfferHistory(j, offerOutsider, null, "SENT", owner, "COMPANY", "外人", at(86)); + + // ── feedback ── + j.update("INSERT INTO candidate_feedback (application_id, company_user_id, student_user_id, rating, " + + "content, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + app1, owner, me, 5, "表现优秀", "[\"沟通\",\"技术\"]", at(100), at(100)); + j.update("INSERT INTO candidate_feedback (application_id, company_user_id, student_user_id, rating, " + + "content, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + appOutsider, owner, outsider, 3, "外人评价", null, at(101), at(101)); + + // ── favorites ── + j.update("INSERT INTO job_favorites (job_id, student_user_id, created_at) VALUES (?, ?, ?)", job1, me, at(110)); + j.update("INSERT INTO job_favorites (job_id, student_user_id, created_at) VALUES (?, ?, ?)", job2, me, at(111)); + j.update("INSERT INTO job_favorites (job_id, student_user_id, created_at) VALUES (?, ?, ?)", + job1, outsider, at(112)); + + // ── notifications(含 read_at 为 null 与非 null)── + j.update("INSERT INTO notifications (user_id, type, title, content, business_type, business_id, " + + "is_read, read_at, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?)", + me, "APPLICATION", "投递已收到", "内容一", "APPLICATION", app1, at(120)); + j.update("INSERT INTO notifications (user_id, type, title, content, business_type, business_id, " + + "is_read, read_at, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)", + me, "OFFER", "收到 Offer", "内容二", "OFFER", offer1, at(121), at(122)); + j.update("INSERT INTO notifications (user_id, type, title, content, is_read, created_at) " + + "VALUES (?, ?, ?, ?, 0, ?)", outsider, "APPLICATION", "外人通知", "外人", at(123)); + + // ── conversations + messages(供 E1 的 conversations/messages section)── + long c1 = insertConversation(j, me, "DIRECT", "会话一", at(130), at(140)); + long c2 = insertConversation(j, other, "DIRECT", null, at(131), at(141)); + long c3 = insertConversation(j, me, "TALENT_INVITATION", "邀约会话", at(132), at(142)); + long cOutsider = insertConversation(j, outsider, "DIRECT", "外人会话", at(133), at(143)); + ids.put("c1", c1); + ids.put("c2", c2); + ids.put("c3", c3); + ids.put("cOutsider", cOutsider); + + addMember(j, c1, me); + addMember(j, c1, other); + addMember(j, c2, me); + addMember(j, c2, other); + addMember(j, c3, me); + addMember(j, c3, outsider); + addMember(j, cOutsider, outsider); + + insertMessage(j, c1, other, "c1-m1", "SENT", at(150)); + insertMessage(j, c1, me, "c1-m2", "SENT", at(151)); + insertMessage(j, c1, other, "c1-m3", "RECALLED", at(152)); + insertMessage(j, c2, other, "c2-m1", "SENT", at(153)); + insertMessage(j, c3, me, "c3-m1", "SENT", at(154)); + insertMessage(j, cOutsider, outsider, "out-m1", "SENT", at(155)); + + // ── candidate-scoring ── + insertDimensionScore(j, me, "EDUCATION", "2026-02", new BigDecimal("88.00"), "OK", + "{\"a\":1}", "12.50", at(160)); + insertDimensionScore(j, me, "ABILITY", "2026-02", null, "INSUFFICIENT_DATA", null, "20.00", at(161)); + insertDimensionScore(j, outsider, "EDUCATION", "2026-02", new BigDecimal("70.00"), "OK", + "{\"b\":2}", "12.50", at(162)); + + long appeal = insertAppeal(j, me, "ABILITY", "2026-02", "PENDING", "理由", at(170)); + long appealOutsider = insertAppeal(j, outsider, "EDUCATION", "2026-02", "APPROVED", "外人理由", at(171)); + ids.put("appeal", appeal); + ids.put("appealOutsider", appealOutsider); + + insertAppealHistory(j, appeal, null, "PENDING", owner, "ADMIN", "提交", at(172)); + insertAppealHistory(j, appeal, "PENDING", "APPROVED", owner, "ADMIN", "通过", at(173)); + insertAppealHistory(j, appealOutsider, null, "PENDING", owner, "ADMIN", "外人提交", at(174)); + + return ids; + } + + private static void insertProfile(JdbcTemplate j, long userId, String realName, String bio, String location, + String education, String school, int graduationYear, String skills, + LocalDateTime t) { + j.update("INSERT INTO student_profiles (user_id, real_name, bio, location, education, school, " + + "graduation_year, skills_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + userId, realName, bio, location, education, school, graduationYear, skills, t, t); + } + + private static long insertJob(JdbcTemplate j, long companyId, String title, LocalDateTime t) { + j.update("INSERT INTO jobs (company_id, title, description, city, employment_type, status, " + + "created_at, updated_at) VALUES (?, ?, '描述', '北京', 'FULL_TIME', 'PUBLISHED', ?, ?)", + companyId, title, t, t); + return j.queryForObject("SELECT id FROM jobs WHERE title = ?", Long.class, title); + } + + private static long insertApplication(JdbcTemplate j, long jobId, long studentUserId, String status, + String coverLetter, LocalDateTime t) { + j.update("INSERT INTO applications (job_id, student_user_id, status, cover_letter, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?)", jobId, studentUserId, status, coverLetter, t, t); + return j.queryForObject("SELECT id FROM applications WHERE job_id = ? AND student_user_id = ?", + Long.class, jobId, studentUserId); + } + + private static void insertAppHistory(JdbcTemplate j, long appId, long operator, String from, String to, + String reason, LocalDateTime t) { + j.update("INSERT INTO application_status_history (application_id, operator_user_id, from_status, " + + "to_status, reason, created_at) VALUES (?, ?, ?, ?, ?, ?)", appId, operator, from, to, reason, t); + } + + private static long insertInterview(JdbcTemplate j, long appId, long companyUserId, long studentUserId, + int roundNo, String title, LocalDateTime t) { + j.update("INSERT INTO interviews (application_id, company_user_id, student_user_id, round_no, title, " + + "interview_type, start_at, end_at, timezone, interviewer_name, address, notes, status, " + + "created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'VIDEO', ?, ?, 'Asia/Shanghai', ?, ?, ?, " + + "'SCHEDULED', ?, ?)", + appId, companyUserId, studentUserId, roundNo, title, t, t.plusHours(1), "面试官", "地址", "备注", t, t); + return j.queryForObject("SELECT id FROM interviews WHERE application_id = ? AND round_no = ? AND title = ?", + Long.class, appId, roundNo, title); + } + + private static void insertInterviewHistory(JdbcTemplate j, long interviewId, long actor, String from, + String to, String reason, LocalDateTime t) { + j.update("INSERT INTO interview_status_history (interview_id, from_status, to_status, actor_user_id, " + + "reason, created_at) VALUES (?, ?, ?, ?, ?, ?)", interviewId, from, to, actor, reason, t); + } + + private static long insertOffer(JdbcTemplate j, long appId, long jobId, long companyId, long candidateId, + long createdBy, String title, LocalDateTime t, LocalDateTime expireAt) { + j.update("INSERT INTO offers (application_id, job_id, company_id, candidate_id, title, salary_min, " + + "salary_max, salary_currency, salary_period, work_city, employment_type, probation_months, " + + "benefits, description, expire_at, status, version, created_by, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, 'CNY', 'MONTH', '北京', 'FULL_TIME', 3, ?, '说明', ?, " + + "'SENT', 0, ?, ?, ?)", + appId, jobId, companyId, candidateId, title, 20000, 30000, "五险一金", expireAt, createdBy, t, t); + return j.queryForObject("SELECT id FROM offers WHERE application_id = ?", Long.class, appId); + } + + private static void insertOfferHistory(JdbcTemplate j, long offerId, String from, String to, long operatorId, + String operatorRole, String reason, LocalDateTime t) { + j.update("INSERT INTO offer_status_history (offer_id, from_status, to_status, operator_id, operator_role, " + + "reason, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + offerId, from, to, operatorId, operatorRole, reason, t); + } + + private static long insertConversation(JdbcTemplate j, long createdBy, String type, String title, + LocalDateTime created, LocalDateTime updated) { + j.update("INSERT INTO conversations (title, type, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + title, type, createdBy, created, updated); + return j.queryForObject("SELECT id FROM conversations WHERE created_by = ? AND created_at = ?", + Long.class, createdBy, created); + } + + private static void addMember(JdbcTemplate j, long convId, long userId) { + j.update("INSERT INTO conversation_members (conversation_id, user_id, joined_at) VALUES (?, ?, ?)", + convId, userId, T); + } + + private static void insertMessage(JdbcTemplate j, long convId, long sender, String content, String status, + LocalDateTime t) { + j.update("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, ?, ?)", convId, sender, content, status, t); + } + + private static void insertDimensionScore(JdbcTemplate j, long studentUserId, String dimension, String period, + BigDecimal score, String status, String subScores, String weight, + LocalDateTime t) { + j.update("INSERT INTO candidate_dimension_scores (student_user_id, dimension, period, score, status, " + + "sub_scores_json, weight, rules_version, calculated_at, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, 'v1', ?, ?, ?)", + studentUserId, dimension, period, score, status, subScores, new BigDecimal(weight), t, t, t); + } + + private static long insertAppeal(JdbcTemplate j, long candidateId, String dimension, String period, + String status, String reason, LocalDateTime t) { + j.update("INSERT INTO candidate_scoring_appeals (candidate_user_id, dimension, period, status, reason, " + + "rules_version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'v1', ?, ?)", + candidateId, dimension, period, status, reason, t, t); + return j.queryForObject("SELECT id FROM candidate_scoring_appeals WHERE candidate_user_id = ? " + + "AND dimension = ? AND period = ?", Long.class, candidateId, dimension, period); + } + + private static void insertAppealHistory(JdbcTemplate j, long appealId, String from, String to, long actor, + String role, String reason, LocalDateTime t) { + j.update("INSERT INTO candidate_scoring_appeal_history (appeal_id, from_status, to_status, actor_user_id, " + + "actor_role, reason, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + appealId, from, to, actor, role, reason, t); + } + + /** 按外键顺序清理本类夹具;只删除 {@code r01eind-} 前缀的自有数据,不触碰种子数据。 */ + static void clearFixture(JdbcTemplate j) { + // MySQL 禁止在 DELETE 的子查询中引用目标表本身(错误 1093), + // 故自引用表一律用直接谓词,仅跨表关联才用子查询。 + String u = "(SELECT id FROM users WHERE phone LIKE '" + PREFIX + "%')"; + String myConvs = "(SELECT id FROM conversations WHERE created_by IN " + u + ")"; + String myApps = "(SELECT id FROM applications WHERE student_user_id IN " + u + ")"; + String myCompanies = "(SELECT id FROM companies WHERE owner_user_id IN " + u + ")"; + String interviewPredicate = "student_user_id IN " + u + " OR company_user_id IN " + u; + String myInterviews = "(SELECT id FROM interviews WHERE " + interviewPredicate + ")"; + String offerPredicate = "candidate_id IN " + u + " OR company_id IN " + myCompanies; + String myOffers = "(SELECT id FROM offers WHERE " + offerPredicate + ")"; + String myAppeals = "(SELECT id FROM candidate_scoring_appeals WHERE candidate_user_id IN " + u + ")"; + + j.update("DELETE FROM message_reads WHERE message_id IN (SELECT id FROM messages WHERE conversation_id IN " + + myConvs + ")"); + j.update("DELETE FROM messages WHERE conversation_id IN " + myConvs); + j.update("DELETE FROM conversation_members WHERE conversation_id IN " + myConvs + " OR user_id IN " + u); + j.update("DELETE FROM conversations WHERE created_by IN " + u); + j.update("DELETE FROM application_status_history WHERE application_id IN " + myApps); + j.update("DELETE FROM interview_status_history WHERE interview_id IN " + myInterviews); + j.update("DELETE FROM interview_scores WHERE interview_id IN " + myInterviews); + j.update("DELETE FROM interviews WHERE " + interviewPredicate); + j.update("DELETE FROM offer_status_history WHERE offer_id IN " + myOffers); + j.update("DELETE FROM offer_reminders WHERE offer_id IN " + myOffers); + j.update("DELETE FROM offer_counter_offers WHERE offer_id IN " + myOffers + " OR student_id IN " + u); + j.update("DELETE FROM offers WHERE " + offerPredicate); + j.update("DELETE FROM candidate_scoring_appeal_history WHERE appeal_id IN " + myAppeals); + j.update("DELETE FROM candidate_scoring_snapshot_history WHERE candidate_user_id IN " + u); + j.update("DELETE FROM candidate_scoring_appeals WHERE candidate_user_id IN " + u); + j.update("DELETE FROM candidate_scoring_rollback_limits WHERE student_user_id IN " + u); + j.update("DELETE FROM candidate_dimension_scores WHERE student_user_id IN " + u); + j.update("DELETE FROM candidate_feedback WHERE student_user_id IN " + u + " OR company_user_id IN " + u); + j.update("DELETE FROM job_favorites WHERE student_user_id IN " + u); + j.update("DELETE FROM notifications WHERE user_id IN " + u); + j.update("DELETE FROM resume_versions WHERE user_id IN " + u); + j.update("DELETE FROM student_preferences WHERE student_user_id IN " + u); + j.update("DELETE FROM student_profiles WHERE user_id IN " + u); + j.update("DELETE FROM applications WHERE student_user_id IN " + u); + j.update("DELETE FROM jobs WHERE company_id IN " + myCompanies); + j.update("DELETE FROM companies WHERE owner_user_id IN " + u); + j.update("DELETE FROM user_roles WHERE user_id IN " + u); + j.update("DELETE FROM users WHERE phone LIKE '" + PREFIX + "%'"); + } + + // ══════════════════════════════════════════════════════════ + // 规范化(canonicalization):把完整输出变成确定性文本 + // ══════════════════════════════════════════════════════════ + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * 把 {@link PersonalDataCollector.CollectResult} 的**完整** section 输出规范化为文本。 + * + *

    显式呈现 null、按 {@code LocalDateTime.toString()} 呈现时间、按 List 原序呈现集合顺序、 + * 按 key 排序呈现 Map 字段;JSON 列做**语义等价**归一(MySQL 会重排 JSON 键并加空格, + * H2 保留字面量),从而比较的是语义而非引擎的序列化风格。

    + */ + static String canonicalResult(PersonalDataCollector.CollectResult r) { + StringBuilder sb = new StringBuilder(); + sb.append("sectionOrder=").append(new ArrayList<>(r.getSections().keySet())).append('\n'); + for (Map.Entry e : r.getSections().entrySet()) { + sb.append("section[").append(e.getKey()).append("]=").append(canon(e.getValue())).append('\n'); + } + sb.append("binaryKeys=").append(new ArrayList<>(new java.util.TreeSet<>(r.getBinaries().keySet()))) + .append('\n'); + return sb.toString(); + } + + /** 会话列表的完整视图规范化(key 排序,避免 HashMap 迭代顺序干扰)。 */ + static String canonicalConversations(List> list) { + StringBuilder sb = new StringBuilder(); + for (Map v : list) { + sb.append(canon(new TreeMap<>(v))).append('\n'); + } + return sb.toString(); + } + + private static String canon(Object v) { + if (v == null) return "null"; + if (v instanceof Map m) { + Map sorted = new TreeMap<>(); + m.forEach((k, value) -> sorted.put(String.valueOf(k), value)); + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : sorted.entrySet()) { + if (!first) sb.append(','); + first = false; + sb.append(e.getKey()).append('=').append(canon(e.getValue())); + } + return sb.append('}').toString(); + } + if (v instanceof List l) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < l.size(); i++) { + if (i > 0) sb.append(','); + sb.append(canon(l.get(i))); + } + return sb.append(']').toString(); + } + if (v instanceof Object[] arr) return canon(List.of(arr)); + if (v instanceof LocalDateTime dt) return dt.toString(); + if (v instanceof LocalDate d) return d.toString(); + if (v instanceof BigDecimal bd) return bd.stripTrailingZeros().toPlainString(); + if (v instanceof String s) return '"' + normalizeJson(s) + '"'; + return String.valueOf(v); + } + + /** + * JSON 列的**语义**归一,而不是表示归一。 + * + *

    实测差异(见 {@code e1-raw-json.txt}):同一个 JSON 列, + * MySQL 8 回读为 JSON 对象字面量 {@code {"java":3}}; + * H2(MODE=MySQL)回读为被转义的 JSON **字符串**标量 {@code "{\"java\":3}"}。 + * 两者语义相同,故此处递归解码到对象/数组后按键排序重排, + * 只对确实是 JSON 的字符串生效,其余字符串原样保留。

    + */ + private static String normalizeJson(String s) { + String decoded = decodeJsonValue(s.trim()); + return decoded != null ? decoded : s; + } + + /** 返回归一后的 JSON 文本;若该字符串不是 JSON(对象/数组/双重编码字符串)则返回 null。 */ + private static String decodeJsonValue(String t) { + boolean object = t.startsWith("{") && t.endsWith("}"); + boolean array = t.startsWith("[") && t.endsWith("]"); + boolean quoted = t.length() >= 2 && t.startsWith("\"") && t.endsWith("\""); + if (!object && !array && !quoted) return null; + try { + JsonNode node = MAPPER.readTree(t); + if (node == null) return null; + if (node.isTextual()) { + // 双重编码:字符串里仍然是 JSON(H2 的 JSON 列回读形态) + return decodeJsonValue(node.asText().trim()); + } + if (node.isObject() || node.isArray()) { + return MAPPER.writeValueAsString(sortKeys(node)); + } + return null; + } catch (Exception ignored) { + return null; + } + } + + /** 取出输出中 JSON 列的**原始**(未归一)取值,用于报告中如实记录跨库表示差异。 */ + private static String rawJsonSamples(PersonalDataCollector.CollectResult r) { + Object profile = r.getSections().get("profile"); + Object preferences = r.getSections().get("preferences"); + return "profile.skills=" + (profile instanceof Map p ? p.get("skills") : null) + + " | preferences.preferredCities=" + + (preferences instanceof Map p ? p.get("preferredCities") : null); + } + + private static JsonNode sortKeys(JsonNode node) { + if (node.isObject()) { + ObjectNode out = MAPPER.createObjectNode(); + List names = new ArrayList<>(); + node.fieldNames().forEachRemaining(names::add); + Collections.sort(names); + for (String n : names) out.set(n, sortKeys(node.get(n))); + return out; + } + if (node.isArray()) { + com.fasterxml.jackson.databind.node.ArrayNode out = MAPPER.createArrayNode(); + node.forEach(child -> out.add(sortKeys(child))); + return out; + } + return node; + } + + /** 把完整 dump 落盘,便于报告引用与失败时人工比对。 */ + private static void dump(String name, String content) { + try { + Path dir = Path.of("target", "r01e-independent"); + Files.createDirectories(dir); + Files.writeString(dir.resolve(name), content); + } catch (Exception ignored) { + // 诊断输出 + } + } + + // ══════════════════════════════════════════════════════════ + // 1 / 5. 查询预算(独立计数实现,统计真实 JDBC 语句与 SQL 文本) + // ══════════════════════════════════════════════════════════ + + /** 只包一层、记录**真实 JDBC 语句文本**的 DataSource(独立于实施测试的实现)。 */ + static final class CountingDataSource extends org.springframework.jdbc.datasource.AbstractDataSource { + private final DataSource delegate; + private final AtomicInteger count = new AtomicInteger(); + private final List sqls = Collections.synchronizedList(new ArrayList<>()); + + CountingDataSource(DataSource delegate) { + this.delegate = delegate; + } + + void reset() { + count.set(0); + sqls.clear(); + } + + int count() { + return count.get(); + } + + List sqls() { + synchronized (sqls) { + return new ArrayList<>(sqls); + } + } + + long matching(String lowerCaseFragment) { + return sqls().stream().filter(s -> s.toLowerCase().contains(lowerCaseFragment)).count(); + } + + @Override public Connection getConnection() throws java.sql.SQLException { + return wrap(delegate.getConnection()); + } + + @Override public Connection getConnection(String u, String p) throws java.sql.SQLException { + return wrap(delegate.getConnection(u, p)); + } + + private Connection wrap(Connection c) { + return (Connection) Proxy.newProxyInstance( + Connection.class.getClassLoader(), new Class[]{Connection.class}, + (proxy, method, args) -> { + if ("prepareStatement".equals(method.getName()) && args != null && args.length > 0 + && args[0] instanceof String sql) { + count.incrementAndGet(); + sqls.add(sql); + } else if ("createStatement".equals(method.getName())) { + count.incrementAndGet(); + } + return method.invoke(c, args); + }); + } + } + + // ══════════════════════════════════════════════════════════ + // 测试 + // ══════════════════════════════════════════════════════════ + + @Test + @DisplayName("引导:两个引擎均可执行真实 SQL,且等价夹具得到相同主键") + void bothEnginesAreLiveAndFixtureIdsAgree() { + assertThat(MySql.container.isRunning()).as("MySQL Testcontainers 必须真实运行").isTrue(); + assertThat(h2Jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class)).isNotNull(); + assertThat(MySql.jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class)).isNotNull(); + + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + + assertThat(a.keySet()).isEqualTo(b.keySet()); + assertThat(b).as("等价夹具在两个引擎上应得到相同主键(同一 JVM、两侧均为全新建库)").isEqualTo(a); + } + + @Test + @DisplayName("E1:collector 完整输出在 H2 与 MySQL 上逐字段一致(含 null / 时间 / 顺序 / 关联数据)") + void e1CollectorOutputIsIdenticalAcrossEngines() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + assertThat(b).as("两侧等价夹具").isEqualTo(a); + + PersonalDataCollector.CollectResult onH2 = h2Collector.collect(a.get("me")); + PersonalDataCollector.CollectResult onMy = MySql.collector.collect(b.get("me")); + + String dumpH2 = canonicalResult(onH2); + String dumpMy = canonicalResult(onMy); + dump("e1-h2.txt", dumpH2); + dump("e1-mysql.txt", dumpMy); + dump("e1-raw-json.txt", "h2: " + rawJsonSamples(onH2) + "\nmysql: " + rawJsonSamples(onMy) + "\n"); + + assertThat(dumpH2).as("E1 collector 完整输出跨库必须逐字段一致").isEqualTo(dumpMy); + + // 反向自证:置换任意一个字段值后必须不再相等 + String perturbed = dumpMy.replace("\"张三\"", "\"张四\""); + assertThat(perturbed).as("规范化必须是逐字段敏感的(反证)").isNotEqualTo(dumpH2); + + // section 顺序本身也是输出契约的一部分 + assertThat(new ArrayList<>(onH2.getSections().keySet())).containsExactly( + "profile", "preferences", "resumes", "applications", "application-timelines", + "interviews", "interview-timelines", "offers", "offer-timelines", "feedback", + "favorites", "notifications", "conversations", "messages", + "candidate-scoring", "scoring-appeals"); + } + + @Test + @DisplayName("E1:collector 输出不含其他用户数据") + void e1CollectorOutputExcludesOtherUsers() { + Map ids = seedFixture(h2Jdbc); + seedFixture(MySql.jdbc); + + String dump = canonicalResult(h2Collector.collect(ids.get("me"))); + dump("e1-no-foreign.txt", dump); + + for (String foreign : List.of("李四", "外人的简介", "外人简历", "外人求职信", "外人评价", "外人通知", + "外人会话", "out-m1", "外人Offer", "外人理由", "外人面试")) { + assertThat(dump).as("他人数据不得混入导出:%s", foreign).doesNotContain(foreign); + } + // 关联数据:外人 id 不得以其任何形式出现 + assertThat(dump).doesNotContain("outsider"); + } + + @Test + @DisplayName("E1:1 / 10 / 50 条投递的真实 JDBC 语句数恒为 20/20/20(与实施报告同基线,两侧实测)") + void e1QueryBudgetIsFlatOnBothEngines() { + int[] sizes = {1, 10, 50}; + int[] onH2 = new int[3]; + int[] onMy = new int[3]; + List h2SqlFor1 = null; + List mySqlFor1 = null; + + for (int s = 0; s < sizes.length; s++) { + Map measured = measureE1Minimal(sizes[s]); + onH2[s] = measured.get("h2"); + onMy[s] = measured.get("mysql"); + if (s == 0) { + h2SqlFor1 = List.copyOf(h2Counting.sqls()); + mySqlFor1 = List.copyOf(MySql.counting.sqls()); + } + } + + dump("e1-budget.txt", "minimalFixture h2=" + java.util.Arrays.toString(onH2) + + " mysql=" + java.util.Arrays.toString(onMy) + "\n"); + + assertThat(onH2).as("E1 H2:SQL 数必须精确为 20/20/20").containsExactly(20, 20, 20); + assertThat(onMy).as("E1 MySQL:SQL 数必须精确为 20/20/20").containsExactly(20, 20, 20); + + // 排除「测试准备 / 清理」SQL:计数在夹具灌入完成之后才 reset, + // 故上述 20 条全部来自 collect(),且全部是 SELECT + assertThat(h2SqlFor1).as("H2 侧实测语句必须全部是 collect() 发出的查询").hasSize(20) + .allMatch(sql -> sql.trim().toUpperCase().startsWith("SELECT")); + assertThat(mySqlFor1).as("MySQL 侧实测语句必须全部是 collect() 发出的查询").hasSize(20) + .allMatch(sql -> sql.trim().toUpperCase().startsWith("SELECT")); + } + + @Test + @DisplayName("E1:12 个 section 全部有数据时,SQL 数仍不随记录数增长(两侧实测)") + void e1QueryBudgetIsFlatWithRichFixture() { + int[] sizes = {1, 10, 50}; + int[] onH2 = new int[3]; + int[] onMy = new int[3]; + for (int s = 0; s < sizes.length; s++) { + Map measured = measureE1Rich(sizes[s]); + onH2[s] = measured.get("h2"); + onMy[s] = measured.get("mysql"); + } + dump("e1-budget-rich.txt", "richFixture h2=" + java.util.Arrays.toString(onH2) + + " mysql=" + java.util.Arrays.toString(onMy) + "\n"); + + assertThat(onH2[1]).as("H2:10 条时不得高于 1 条时").isEqualTo(onH2[0]); + assertThat(onH2[2]).as("H2:50 条时不得高于 1 条时").isEqualTo(onH2[0]); + assertThat(onMy[1]).as("MySQL:10 条时不得高于 1 条时").isEqualTo(onMy[0]); + assertThat(onMy[2]).as("MySQL:50 条时不得高于 1 条时").isEqualTo(onMy[0]); + } + + /** + * 与实施报告同基线的最小夹具:仅 me/other 两个用户、一家公司、N 个岗位与 N 条投递。 + * 用于直接对账报告中「1/10/50 → 20/20/20」的结论。 + */ + private Map measureE1Minimal(int size) { + return measureE1(size, false); + } + + private Map measureE1Rich(int size) { + return measureE1(size, true); + } + + private Map measureE1(int size, boolean rich) { + clearFixture(h2Jdbc); + clearFixture(MySql.jdbc); + Map out = new LinkedHashMap<>(); + out.put("h2", measureE1On(h2Jdbc, h2Counting, h2Collector, size, rich)); + out.put("mysql", measureE1On(MySql.jdbc, MySql.counting, MySql.collector, size, rich)); + return out; + } + + /** 灌入夹具与 N 条投递,reset 计数器后调用 collect(),返回实测真实 JDBC 语句数。 */ + private int measureE1On(JdbcTemplate j, CountingDataSource counting, + PersonalDataCollector collector, int size, boolean rich) { + Map ids = rich ? seedFixture(j) : seedMinimalE1(j); + for (int i = 0; i < size; i++) { + long jobId = insertJob(j, ids.get("companyOwner"), PREFIX + "extra-job-" + i, at(200 + i)); + insertApplication(j, jobId, ids.get("me"), "SUBMITTED", "附加" + i, at(200 + i)); + } + counting.reset(); + collector.collect(ids.get("me")); + return counting.count(); + } + + /** 与实施报告同基线的最小夹具:两个用户 + 一家(他人持有的)公司,无其他 section 数据。 */ + private Map seedMinimalE1(JdbcTemplate j) { + Map ids = new LinkedHashMap<>(); + long me = insertUser(j, PREFIX + "me", "本人", "STUDENT", at(0)); + long other = insertUser(j, PREFIX + "other", "对方", "STUDENT", at(1)); + j.update("INSERT INTO companies (owner_user_id, name, industry, verified, created_at, updated_at) " + + "VALUES (?, ?, '互联网', 1, ?, ?)", other, PREFIX + "co-owner", at(40), at(40)); + ids.put("me", me); + ids.put("other", other); + ids.put("companyOwner", + j.queryForObject("SELECT id FROM companies WHERE owner_user_id = ?", Long.class, other)); + return ids; + } + + @Test + @DisplayName("E2:1 / 20 / 100 会话的真实 JDBC 语句数恒为 3,且 SQL 形态固定(H2 与 MySQL 均实测)") + void e2QueryBudgetIsFlatOnBothEngines() { + int[] sizes = {1, 20, 100}; + int[] onH2 = new int[3]; + int[] onMy = new int[3]; + List h2Sql = null; + List mySql = null; + + for (int s = 0; s < sizes.length; s++) { + clearFixture(h2Jdbc); + clearFixture(MySql.jdbc); + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + List expected = new ArrayList<>(); + for (int i = 0; i < sizes[s]; i++) { + expected.add(addConversationWithUnread(h2Jdbc, a, i)); + addConversationWithUnread(MySql.jdbc, b, i); + } + + h2Counting.reset(); + List> listH2 = h2Messaging.listConversations(a.get("me")); + onH2[s] = h2Counting.count(); + + MySql.counting.reset(); + List> listMy = MySql.messaging.listConversations(b.get("me")); + onMy[s] = MySql.counting.count(); + + assertThat(listH2).as("size=%d H2", sizes[s]).hasSize(sizes[s] + 3); + assertThat(listMy).as("size=%d MySQL", sizes[s]).hasSize(sizes[s] + 3); + assertThat(canonicalConversations(listH2)).as("size=%d 跨库逐字段一致", sizes[s]) + .isEqualTo(canonicalConversations(listMy)); + + if (s == 2) { + h2Sql = List.copyOf(h2Counting.sqls()); + mySql = List.copyOf(MySql.counting.sqls()); + } + } + + dump("e2-budget.txt", "h2=" + java.util.Arrays.toString(onH2) + "\nmysql=" + + java.util.Arrays.toString(onMy) + "\n--- h2 sql ---\n" + String.join("\n", h2Sql) + + "\n--- mysql sql ---\n" + String.join("\n", mySql) + "\n"); + + assertThat(onH2).as("E2 H2:SQL 数必须精确为 3/3/3").containsExactly(3, 3, 3); + assertThat(onMy).as("E2 MySQL:SQL 数必须精确为 3/3/3").containsExactly(3, 3, 3); + + // 3 条语句必须是「成员关系 + 会话 + 分组未读」,且未读查询只出现 1 次 + assertThat(h2Sql).hasSize(3); + assertThat(mySql).hasSize(3); + assertThat(h2Sql.stream().filter(s -> s.contains("ROW_NUMBER")).count()) + .as("H2:分组未读查询只执行一次").isEqualTo(1); + assertThat(mySql.stream().filter(s -> s.contains("ROW_NUMBER")).count()) + .as("MySQL:分组未读查询只执行一次").isEqualTo(1); + } + + private long addConversationWithUnread(JdbcTemplate j, Map ids, int index) { + long cid = insertConversation(j, ids.get("me"), "DIRECT", PREFIX + "scale-" + index, + at(300 + index), at(400 + index)); + addMember(j, cid, ids.get("me")); + addMember(j, cid, ids.get("other")); + insertMessage(j, cid, ids.get("other"), "scale-" + index, "SENT", at(400 + index)); + return cid; + } + + @Test + @DisplayName("E2:空会话页不执行任何未读查询") + void e2EmptyConversationPageIssuesNoUnreadQuery() { + long nobody = insertUser(h2Jdbc, PREFIX + "nobody", "无会话者", "STUDENT", at(0)); + long nobodyMy = insertUser(MySql.jdbc, PREFIX + "nobody", "无会话者", "STUDENT", at(0)); + + h2Counting.reset(); + assertThat(h2Messaging.listConversations(nobody)).isEmpty(); + List h2Sql = h2Counting.sqls(); + + MySql.counting.reset(); + assertThat(MySql.messaging.listConversations(nobodyMy)).isEmpty(); + List mySql = MySql.counting.sqls(); + + assertThat(h2Sql).as("H2:空会话页只发出一条成员关系查询").hasSize(1); + assertThat(mySql).as("MySQL:空会话页只发出一条成员关系查询").hasSize(1); + assertThat(h2Sql.get(0)).as("不得执行未读查询").doesNotContain("ROW_NUMBER"); + assertThat(mySql.get(0)).as("不得执行未读查询").doesNotContain("ROW_NUMBER"); + assertThat(h2Counting.matching("row_number")).isZero(); + assertThat(MySql.counting.matching("row_number")).isZero(); + } + + // ══════════════════════════════════════════════════════════ + // 3. E2 H2/MySQL 深比较:逐条语义 + // ══════════════════════════════════════════════════════════ + + private static Map unreadByConversation(List> list) { + Map out = new TreeMap<>(); + for (Map v : list) { + out.put(((Number) v.get("id")).longValue(), ((Number) v.get("unreadCount")).longValue()); + } + return out; + } + + private static void assertUnread(JdbcTemplate j, MessagingService svc, long me, long convId, long expected, + String what) { + Map map = unreadByConversation(svc.listConversations(me)); + assertThat(map.get(convId)).as("%s", what).isEqualTo(expected); + } + + @Test + @DisplayName("E2 深比较①:本人发送未读按现状计入(OBS-E01),他人发送未读计入") + void e2SelfSentAndOtherSentUnreadAreCounted() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + + // c1 中:c1-m1(他人 SENT)、c1-m2(本人 SENT)、c1-m3(RECALLED) + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 2, + "H2:本人 1 条 + 他人 1 条 = 2(撤回排除;OBS-E01:本人发送计入)"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 2, + "MySQL:本人 1 条 + 他人 1 条 = 2(撤回排除;OBS-E01:本人发送计入)"); + + // 隔离验证:c1-m2 已被读取后,本人发送的那条不再计入 → 1 + long c1m2 = h2Jdbc.queryForObject("SELECT id FROM messages WHERE conversation_id = ? AND content = ?", + Long.class, a.get("c1"), "c1-m2"); + long c1m2My = MySql.jdbc.queryForObject("SELECT id FROM messages WHERE conversation_id = ? AND content = ?", + Long.class, b.get("c1"), "c1-m2"); + h2Jdbc.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", + c1m2, a.get("me"), at(500)); + MySql.jdbc.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", + c1m2My, b.get("me"), at(500)); + + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 1, "H2:本人那条已读 → 1"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 1, "MySQL:本人那条已读 → 1"); + } + + @Test + @DisplayName("E2 深比较②:message_reads 排除、RECALLED 排除,跨库逐值一致") + void e2ReadAndRecalledExcluded() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + + long m1 = msgId(h2Jdbc, a.get("c1"), "c1-m1"); + long m1My = msgId(MySql.jdbc, b.get("c1"), "c1-m1"); + h2Jdbc.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", + m1, a.get("me"), at(500)); + MySql.jdbc.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", + m1My, b.get("me"), at(500)); + + // c1: m1 已读排除、m2 本人 SENT 计入、m3 RECALLED 排除 → 1 + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 1, "H2:已读与撤回均排除 → 1"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 1, "MySQL:已读与撤回均排除 → 1"); + + // 把 m2 也标记为撤回 → 0;此时聚合应缺行并由 Service 补 0 + h2Jdbc.update("UPDATE messages SET status = 'RECALLED' WHERE id = ?", msgId(h2Jdbc, a.get("c1"), "c1-m2")); + MySql.jdbc.update("UPDATE messages SET status = 'RECALLED' WHERE id = ?", + msgId(MySql.jdbc, b.get("c1"), "c1-m2")); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 0, "H2:全部排除 → 聚合缺行 → 补 0"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 0, "MySQL:全部排除 → 聚合缺行 → 补 0"); + + // 另一个读者的已读记录不得影响本人(两侧同步写入,保持自增计数一致) + markRead(h2Jdbc, msgId(h2Jdbc, a.get("c1"), "c1-m1"), a.get("other")); + markRead(MySql.jdbc, msgId(MySql.jdbc, b.get("c1"), "c1-m1"), b.get("other")); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 0, "H2:他人已读不影响本人"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 0, "MySQL:他人已读不影响本人"); + } + + private static long msgId(JdbcTemplate j, long convId, String content) { + return j.queryForObject("SELECT id FROM messages WHERE conversation_id = ? AND content = ?", + Long.class, convId, content); + } + + @Test + @DisplayName("E2 深比较③:多会话分组、非当前用户会话不返回、聚合缺失补 0") + void e2GroupingIsolationAndZeroFill() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + + List> onH2 = h2Messaging.listConversations(a.get("me")); + List> onMy = MySql.messaging.listConversations(b.get("me")); + + assertThat(canonicalConversations(onH2)).as("会话列表跨库逐字段一致").isEqualTo(canonicalConversations(onMy)); + + Map h2Map = unreadByConversation(onH2); + Map myMap = unreadByConversation(onMy); + + assertThat(h2Map.keySet()).as("H2:本人应是 c1/c2/c3 三个会话") + .containsExactlyInAnyOrder(a.get("c1"), a.get("c2"), a.get("c3")); + assertThat(myMap.keySet()).as("MySQL:本人应是 c1/c2/c3 三个会话") + .containsExactlyInAnyOrder(b.get("c1"), b.get("c2"), b.get("c3")); + assertThat(h2Map).as("非当前用户会话绝不返回,且未读逐值一致").isEqualTo(myMap); + + assertThat(h2Map).doesNotContainKey(a.get("cOutsider")); + assertThat(h2Map.get(a.get("c1"))).isEqualTo(2L); + assertThat(h2Map.get(a.get("c2"))).isEqualTo(1L); + assertThat(h2Map.get(a.get("c3"))).as("本人发送的 1 条未读(OBS-E01)").isEqualTo(1L); + } + + @Test + @DisplayName("E2 深比较④:最近第 500 条计入、第 501 条排除(跨库一致的窗口边界)") + void e2FiveHundredWindowBoundary() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + long cH2 = a.get("c1"); + long cMy = b.get("c1"); + + // 清掉 c1 既有消息,改为 501 条 created_at 严格递增的未读消息 + clearMessages(h2Jdbc, cH2); + clearMessages(MySql.jdbc, cMy); + bulkMessages(h2Jdbc, cH2, a.get("other"), 501); + bulkMessages(MySql.jdbc, cMy, b.get("other"), 501); + + // 第 501 条(最旧,content=m0000)应被窗口排除 → 恰好 500 + assertUnread(h2Jdbc, h2Messaging, a.get("me"), cH2, 500, "H2:窗口上限 500"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), cMy, 500, "MySQL:窗口上限 500"); + + // 把最旧一条标记为已读 → 仍为 500,证明它本就在窗口之外 + markRead(h2Jdbc, msgId(h2Jdbc, cH2, "m0000"), a.get("me")); + markRead(MySql.jdbc, msgId(MySql.jdbc, cMy, "m0000"), b.get("me")); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), cH2, 500, "H2:最旧一条已在窗口外,标记已读不改变结果"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), cMy, 500, "MySQL:最旧一条已在窗口外,标记已读不改变结果"); + + // 把最新一条(m0500)标记为已读 → 499,证明窗口保留的是最新一侧 + markRead(h2Jdbc, msgId(h2Jdbc, cH2, "m0500"), a.get("me")); + markRead(MySql.jdbc, msgId(MySql.jdbc, cMy, "m0500"), b.get("me")); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), cH2, 499, "H2:窗口保留最新 500 条"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), cMy, 499, "MySQL:窗口保留最新 500 条"); + } + + private static void clearMessages(JdbcTemplate j, long convId) { + j.update("DELETE FROM message_reads WHERE message_id IN (SELECT id FROM messages WHERE conversation_id = ?)", + convId); + j.update("DELETE FROM messages WHERE conversation_id = ?", convId); + } + + private static void markRead(JdbcTemplate j, long messageId, long reader) { + j.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", + messageId, reader, at(600)); + } + + /** 批量写入 n 条 created_at 严格递增的消息,content 为 m0000..mNNNN。 */ + private static void bulkMessages(JdbcTemplate j, long convId, long sender, int n) { + List batch = new ArrayList<>(); + for (int i = 0; i < n; i++) { + batch.add(new Object[]{convId, sender, String.format("m%04d", i), "SENT", T.plusSeconds(i)}); + } + j.batchUpdate("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, ?, ?)", batch); + } + + @Test + @DisplayName("E2 深比较⑤:created_at 相同时的排序语义(小规模确定;跨 500 边界时窗口总数仍恒为 500)") + void e2IdenticalCreatedAtOrderingSemantics() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + + // 小规模:3 条 created_at 完全相同、全部未读 → 计数与顺序无关,必须为 3 + clearMessages(h2Jdbc, a.get("c1")); + clearMessages(MySql.jdbc, b.get("c1")); + bulkTied(h2Jdbc, a.get("c1"), a.get("other"), 3); + bulkTied(MySql.jdbc, b.get("c1"), b.get("other"), 3); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 3, "H2:同一时刻 3 条全部计入"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 3, "MySQL:同一时刻 3 条全部计入"); + + // 其中一条已读 → 2,仍然与排序无关 + markRead(h2Jdbc, msgId(h2Jdbc, a.get("c1"), "tied-1"), a.get("me")); + markRead(MySql.jdbc, msgId(MySql.jdbc, b.get("c1"), "tied-1"), b.get("me")); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 2, "H2:同一时刻 3 条中已读 1 条 → 2"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 2, "MySQL:同一时刻 3 条中已读 1 条 → 2"); + + // 跨窗口边界:501 条 created_at 完全相同 —— ORDER BY 无 tiebreaker, + // 「保留哪 500 条」由引擎决定;但窗口计数与引擎无关,必须恒为 500。 + clearMessages(h2Jdbc, a.get("c1")); + clearMessages(MySql.jdbc, b.get("c1")); + bulkTied(h2Jdbc, a.get("c1"), a.get("other"), 501); + bulkTied(MySql.jdbc, b.get("c1"), b.get("other"), 501); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 500, "H2:同刻 501 条 → 窗口恒为 500"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 500, "MySQL:同刻 501 条 → 窗口恒为 500"); + } + + private static void bulkTied(JdbcTemplate j, long convId, long sender, int n) { + List batch = new ArrayList<>(); + for (int i = 0; i < n; i++) { + batch.add(new Object[]{convId, sender, "tied-" + i, "SENT", T.plusDays(1)}); + } + j.batchUpdate("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, ?, ?)", batch); + } + + @Test + @DisplayName("E2 深比较⑥:重复 conversationId 不重复计数;空列表由 Service 短路,不下发 IN ()") + void e2DuplicateAndEmptyConversationIds() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + + // 重复 id:直接对 Repository 使用的同一 SQL 施加 [c1, c1] + String sql = unreadSql(2); + List> dupH2 = h2Jdbc.queryForList(sql, a.get("c1"), a.get("c1"), a.get("me")); + List> dupMy = MySql.jdbc.queryForList(sql, b.get("c1"), b.get("c1"), b.get("me")); + + assertThat(dupH2).as("H2:重复 id 只产出一行").hasSize(1); + assertThat(dupMy).as("MySQL:重复 id 只产出一行").hasSize(1); + assertThat(((Number) dupH2.get(0).get("unreadCount")).longValue()).isEqualTo(2L); + assertThat(((Number) dupMy.get(0).get("unreadCount")).longValue()).isEqualTo(2L); + + // 重复 id 与单 id 结果逐值一致 + List> singleH2 = h2Jdbc.queryForList(unreadSql(1), a.get("c1"), a.get("me")); + List> singleMy = MySql.jdbc.queryForList(unreadSql(1), b.get("c1"), b.get("me")); + assertThat(((Number) singleH2.get(0).get("unreadCount")).longValue()) + .isEqualTo(((Number) dupH2.get(0).get("unreadCount")).longValue()); + assertThat(((Number) singleMy.get(0).get("unreadCount")).longValue()) + .isEqualTo(((Number) dupMy.get(0).get("unreadCount")).longValue()); + + // 空列表:Service 在取到空 memberships 后直接返回,不下发未读查询(见 e2EmptyConversationPageIssuesNoUnreadQuery) + long emptyH2 = insertUser(h2Jdbc, PREFIX + "empty", "空用户", "STUDENT", at(0)); + long emptyMy = insertUser(MySql.jdbc, PREFIX + "empty", "空用户", "STUDENT", at(0)); + assertThat(h2Messaging.listConversations(emptyH2)).isEmpty(); + assertThat(MySql.messaging.listConversations(emptyMy)).isEmpty(); + } + + // ══════════════════════════════════════════════════════════ + // 4. MySQL EXPLAIN FORMAT=JSON + // ══════════════════════════════════════════════════════════ + + /** 与 MessageRepository.countUnreadByConversationIds 逐字一致的原生 SQL。 */ + static String unreadSql(int placeholders) { + return "SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount " + + "FROM (SELECT m.id, m.conversation_id, m.status, " + + "ROW_NUMBER() OVER (PARTITION BY m.conversation_id ORDER BY m.created_at DESC) AS row_no " + + "FROM messages m WHERE m.conversation_id IN (" + + String.join(",", Collections.nCopies(placeholders, "?")) + ")) ranked " + + "LEFT JOIN message_reads mr ON mr.message_id = ranked.id AND mr.reader_user_id = ? " + + "WHERE ranked.row_no <= 500 AND (ranked.status IS NULL OR ranked.status <> 'RECALLED') " + + "AND mr.id IS NULL GROUP BY ranked.conversation_id"; + } + + /** + * EXPLAIN 得到的「表节点」信息。 + * + *

    注意:MySQL 的 {@code EXPLAIN FORMAT=JSON} 里 {@code table_name} 是**别名**。 + * 被测语句逐字取自 {@code MessageRepository.countUnreadByConversationIds}, + * 其别名为 {@code messages m}、{@code message_reads mr}、派生表 {@code ranked}, + * 故此处表节点分别为 {@code m} / {@code mr} / {@code ranked}。

    + */ + private record TableNode(String tableName, String accessType, Object possibleKeys, Object key, + Object rowsExamined, Object filtered, Object attachedCondition) { + } + + private static final String ALIAS_MESSAGES = "m"; + private static final String ALIAS_MESSAGE_READS = "mr"; + private static final String ALIAS_RANKED = "ranked"; + + private static void collectTableNodes(JsonNode node, List out) { + if (node == null) return; + if (node.isObject()) { + JsonNode table = node.get("table"); + if (table != null && table.isObject() && table.has("table_name")) { + out.add(new TableNode( + table.path("table_name").asText(), + table.path("access_type").asText(), + table.has("possible_keys") ? MAPPER.convertValue(table.get("possible_keys"), Object.class) : null, + table.has("key") ? table.get("key").asText() : null, + table.has("rows_examined_per_scan") ? table.get("rows_examined_per_scan").asLong() : null, + table.has("filtered") ? table.get("filtered").asText() : null, + table.has("attached_condition") ? table.get("attached_condition").asText() : null)); + } + node.fields().forEachRemaining(e -> collectTableNodes(e.getValue(), out)); + } else if (node.isArray()) { + node.forEach(child -> collectTableNodes(child, out)); + } + } + + private static boolean findBool(JsonNode node, String field) { + if (node == null) return false; + if (node.isObject()) { + if (node.has(field) && node.get(field).asBoolean(false)) return true; + for (JsonNode child : node) { + if (findBool(child, field)) return true; + } + } else if (node.isArray()) { + for (JsonNode child : node) { + if (findBool(child, field)) return true; + } + } + return false; + } + + @Test + @DisplayName("MySQL EXPLAIN:selective 规模下 messages/message_reads 命中既有索引;派生表 ranked 逐项记录") + void mysqlExplainReportsAccessPathsPerTable() { + ExplainFixture fixture = new ExplainFixture(); + fixture.reset(); + + List report = new ArrayList<>(); + Map perScale = new LinkedHashMap<>(); + Map probes = new LinkedHashMap<>(); + + for (int[] spec : ASSERTED_SCALES) { + perScale.put(fixture.label(spec), explainUnread(fixture.targets(spec[0], spec[1]))); + } + for (int[] spec : SELECTIVITY_PROBES) { + probes.put(fixture.label(spec), explainUnread(fixture.targets(spec[0], spec[1]))); + } + + report.add("## 断言规模(查询的会话集合相对 messages 高度 selective)"); + append(report, perScale, fixture); + report.add("## 选择性探测(不参与断言,用于定位代价拐点)"); + append(report, probes, fixture); + dump("e2-explain.txt", String.join("\n", report) + "\n"); + System.out.println("[R01E-INDEPENDENT-EXPLAIN]\n" + String.join("\n", report)); + + perScale.forEach((label, result) -> assertBaseTablesUseIndex(label, result.nodes())); + probes.forEach(this::recordProbeOutcome); + } + + /** 断言的 EXPLAIN 规模:查询到的会话只占 messages 表很小比例(真实用户会话列表的形态)。 */ + private static final int[][] ASSERTED_SCALES = {{1, 3}, {20, 3}, {100, 3}, {1, 600}}; + + /** 选择性探测:查询到的会话占比逐步升高,用于找到代价拐点(结果只记录、不判定)。 */ + private static final int[][] SELECTIVITY_PROBES = {{2, 600}, {5, 600}, {20, 600}}; + + private final Map probeOutcomes = new LinkedHashMap<>(); + + private void append(List report, Map map, ExplainFixture fixture) { + map.forEach((label, result) -> { + report.add("### " + label + " selectivity=" + fixture.selectivity(label) + + " using_temporary_table=" + result.temporary() + + " using_filesort=" + result.filesort()); + for (TableNode n : result.nodes()) { + report.add(String.format(" table=%s access_type=%s possible_keys=%s key=%s rows=%s filtered=%s", + n.tableName(), n.accessType(), n.possibleKeys(), n.key(), n.rowsExamined(), n.filtered())); + } + }); + } + + private void recordProbeOutcome(String label, ExplainResult result) { + result.nodes().stream().filter(n -> ALIAS_MESSAGES.equals(n.tableName())).findFirst() + .ifPresent(n -> probeOutcomes.put(label, + n.accessType() + "/key=" + n.key() + "/rows=" + n.rowsExamined())); + dump("e2-explain-probes.txt", probeOutcomes.toString()); + } + + private void assertBaseTablesUseIndex(String label, List nodes) { + List derived = nodes.stream().filter(n -> ALIAS_RANKED.equals(n.tableName())).toList(); + List messages = nodes.stream().filter(n -> ALIAS_MESSAGES.equals(n.tableName())).toList(); + List reads = nodes.stream().filter(n -> ALIAS_MESSAGE_READS.equals(n.tableName())).toList(); + + assertThat(derived).as("%s:必须出现派生结果 ranked", label).isNotEmpty(); + assertThat(messages).as("%s:必须出现基础表 messages(别名 m)", label).isNotEmpty(); + assertThat(reads).as("%s:必须出现基础表 message_reads(别名 mr;LEFT JOIN 被优化省略则此处失败)", + label).isNotEmpty(); + + // 派生结果 ranked:MySQL 对 ROW_NUMBER() 派生表默认物化,access_type=ALL 属预期, + // 准确表述为「派生结果顺序扫描」,不能与基础表混为一谈。 + for (TableNode d : derived) { + assertThat(d.accessType()).as("%s:派生结果 ranked 的 access_type", label) + .isIn("ALL", "ref", "eq_ref", "index", "range"); + } + + for (TableNode m : messages) { + assertThat(m.accessType()) + .as("%s:基础表 messages 不得全表扫描(access_type=%s, key=%s, possible_keys=%s)", + label, m.accessType(), m.key(), m.possibleKeys()) + .isNotEqualTo("ALL"); + assertThat(m.key()).as("%s:messages 必须命中既有索引", label) + .isEqualTo("idx_messages_conversation_created"); + } + for (TableNode r : reads) { + assertThat(r.accessType()) + .as("%s:基础表 message_reads 不得全表扫描(access_type=%s, key=%s)", + label, r.accessType(), r.key()) + .isNotEqualTo("ALL"); + assertThat(r.key()).as("%s:message_reads 必须命中既有索引", label).isNotNull(); + } + } + + /** + * EXPLAIN 夹具:背景消息只铺一次,各规模只替换「目标会话」。 + * + *

    没有背景数据时 messages 表几乎只包含查询范围内的行,MySQL 选择全表扫描是**正确**的代价决策, + * 却会让 EXPLAIN 失去验收意义;真实场景中一个用户的会话只占全表很小比例。

    + */ + private final class ExplainFixture { + private final Map h2Ids = new LinkedHashMap<>(); + private final Map myIds = new LinkedHashMap<>(); + + void reset() { + clearFixture(h2Jdbc); + clearFixture(MySql.jdbc); + h2Ids.putAll(seedFixture(h2Jdbc)); + myIds.putAll(seedFixture(MySql.jdbc)); + seedBackgroundMessages(h2Jdbc, h2Ids.get("owner")); + seedBackgroundMessages(MySql.jdbc, myIds.get("owner")); + } + + String label(int[] spec) { + return "conversations=" + spec[0] + ",messagesPerConversation=" + spec[1] + + ",targetRows=" + (spec[0] * spec[1]); + } + + /** 目标行数 / messages 总行数。 */ + String selectivity(String label) { + return String.format("%.2f%%", 100.0 * targetRows(label) / totalMessages()); + } + + private long targetRows(String label) { + String v = label.substring(label.indexOf("targetRows=") + "targetRows=".length()); + return Long.parseLong(v); + } + + private long totalMessages() { + return MySql.jdbc.queryForObject("SELECT COUNT(*) FROM messages", Long.class); + } + + List targets(int conversations, int msgs) { + clearExplainTargets(h2Jdbc); + clearExplainTargets(MySql.jdbc); + List h2 = insertExplainTargets(h2Jdbc, h2Ids, conversations, msgs); + List my = insertExplainTargets(MySql.jdbc, myIds, conversations, msgs); + assertThat(h2).as("EXPLAIN 目标会话两侧主键应一致").isEqualTo(my); + return my; + } + } + + private void clearExplainTargets(JdbcTemplate j) { + String t = "(SELECT id FROM conversations WHERE title LIKE '" + PREFIX + "explain-%')"; + j.update("DELETE FROM message_reads WHERE message_id IN (SELECT id FROM messages " + + "WHERE conversation_id IN " + t + ")"); + j.update("DELETE FROM messages WHERE conversation_id IN " + t); + j.update("DELETE FROM conversation_members WHERE conversation_id IN " + t); + j.update("DELETE FROM conversations WHERE title LIKE '" + PREFIX + "explain-%'"); + } + + private List insertExplainTargets(JdbcTemplate j, Map ids, int conversations, int msgs) { + List out = new ArrayList<>(); + for (int c = 0; c < conversations; c++) { + long cid = insertConversation(j, ids.get("me"), "DIRECT", PREFIX + "explain-" + c, + at(700 + c), at(800 + c)); + addMember(j, cid, ids.get("me")); + addMember(j, cid, ids.get("other")); + out.add(cid); + List batch = new ArrayList<>(); + for (int i = 0; i < msgs; i++) { + String status = (i % 50 == 7) ? "RECALLED" : "SENT"; + batch.add(new Object[]{cid, ids.get("other"), "e" + c + "-" + i, status, T.plusSeconds(i)}); + } + j.batchUpdate("INSERT INTO messages (conversation_id, sender_user_id, content, status, " + + "created_at) VALUES (?, ?, ?, ?, ?)", batch); + } + for (int c = 0; c < Math.min(conversations, 5); c++) { + List reads = new ArrayList<>(); + for (int i = 0; i < Math.min(msgs, 20); i += 3) { + Long mid = j.queryForObject("SELECT id FROM messages WHERE conversation_id = ? AND content = ?", + Long.class, out.get(c), "e" + c + "-" + i); + reads.add(new Object[]{mid, ids.get("me"), T.plusSeconds(i)}); + } + j.batchUpdate("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", reads); + } + return out; + } + + private record ExplainResult(List nodes, boolean temporary, boolean filesort) { + } + + private ExplainResult explainUnread(List convIds) { + // ANALYZE TABLE 会返回结果集,必须走 query 而非 update + MySql.jdbc.queryForList("ANALYZE TABLE messages"); + MySql.jdbc.queryForList("ANALYZE TABLE message_reads"); + Object[] args = new Object[convIds.size() + 1]; + for (int i = 0; i < convIds.size(); i++) args[i] = convIds.get(i); + args[convIds.size()] = 0L; // readerUserId:用一个几乎不存在的读者,保证 plan 稳定 + + List> rows = MySql.jdbc.queryForList( + "EXPLAIN FORMAT=JSON " + unreadSql(convIds.size()), args); + assertThat(rows).isNotEmpty(); + String json = String.valueOf(rows.get(0).get("EXPLAIN")); + try { + JsonNode root = MAPPER.readTree(json); + List out = new ArrayList<>(); + collectTableNodes(root, out); + return new ExplainResult(out, findBool(root, "using_temporary_table"), + findBool(root, "using_filesort")); + } catch (Exception ex) { + throw new IllegalStateException("EXPLAIN JSON 解析失败", ex); + } + } + + /** 背景规模:非查询范围的会话数与每会话消息数,用于让 `messages` 上的既有索引具备真实选择性。 */ + private static final int BACKGROUND_CONVERSATIONS = 500; + private static final int BACKGROUND_MESSAGES_PER_CONVERSATION = 20; + + /** + * 背景消息:不属于被测 {@code conversationIds} 的会话与消息。 + * + *

    没有背景数据时,{@code messages} 表几乎只包含查询范围内的行,MySQL 选择全表扫描是**正确**的 + * 代价决策,却会让 EXPLAIN 失去验收意义。真实场景中一个用户的会话只占全表很小比例, + * 故这里先铺入背景数据,再观察既有索引是否被命中。

    + */ + private void seedBackgroundMessages(JdbcTemplate j, long owner) { + List convs = new ArrayList<>(); + for (int c = 0; c < BACKGROUND_CONVERSATIONS; c++) { + convs.add(new Object[]{PREFIX + "bg-" + c, "DIRECT", owner, at(900), at(901)}); + } + j.batchUpdate("INSERT INTO conversations (title, type, created_by, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?)", convs); + + List bgIds = j.queryForList("SELECT id FROM conversations WHERE title LIKE '" + PREFIX + + "bg-%' ORDER BY id", Long.class); + List msgs = new ArrayList<>(); + for (Long cid : bgIds) { + for (int i = 0; i < BACKGROUND_MESSAGES_PER_CONVERSATION; i++) { + msgs.add(new Object[]{cid, owner, "bg-" + cid + "-" + i, "SENT", T.plusSeconds(i)}); + } + } + j.batchUpdate("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, ?, ?)", msgs); + } + + @Test + @DisplayName("结构:跨库夹具在两侧留下的自有行数完全一致(读取路径不改数据)") + void fixtureFootprintMatchesAcrossEngines() { + seedFixture(h2Jdbc); + seedFixture(MySql.jdbc); + + String[] tables = {"users", "user_roles", "companies", "jobs", "applications", + "application_status_history", "interviews", "interview_status_history", "offers", + "offer_status_history", "candidate_feedback", "job_favorites", "notifications", + "conversations", "conversation_members", "messages", "message_reads", + "student_profiles", "student_preferences", "resume_versions", + "candidate_dimension_scores", "candidate_scoring_appeals", + "candidate_scoring_appeal_history"}; + for (String table : tables) { + Integer onH2 = h2Jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class); + Integer onMy = MySql.jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class); + assertThat(onMy).as("%s 的自有行数跨库应一致", table).isEqualTo(onH2); + } + } + + // ══════════════════════════════════════════════════════════ + // 5. 夹具零残留 + // ══════════════════════════════════════════════════════════ + + @Test + @DisplayName("隔离:清理后自有夹具在两个引擎上的残留均为 0") + void fixtureLeavesNoResidue() { + seedFixture(h2Jdbc); + seedFixture(MySql.jdbc); + clearFixture(h2Jdbc); + clearFixture(MySql.jdbc); + + assertThat(residue(h2Jdbc)).as("H2 侧自有残留").isZero(); + assertThat(residue(MySql.jdbc)).as("MySQL 侧自有残留").isZero(); + } + + private static long residue(JdbcTemplate j) { + long total = 0; + String users = "(SELECT id FROM users WHERE phone LIKE '" + PREFIX + "%')"; + for (String sql : List.of( + "SELECT COUNT(*) FROM users WHERE phone LIKE '" + PREFIX + "%'", + "SELECT COUNT(*) FROM user_roles WHERE user_id IN " + users, + "SELECT COUNT(*) FROM messages WHERE sender_user_id IN " + users, + "SELECT COUNT(*) FROM message_reads WHERE reader_user_id IN " + users, + "SELECT COUNT(*) FROM conversation_members WHERE user_id IN " + users, + "SELECT COUNT(*) FROM conversations WHERE created_by IN " + users, + "SELECT COUNT(*) FROM applications WHERE student_user_id IN " + users, + "SELECT COUNT(*) FROM jobs WHERE title LIKE '" + PREFIX + "%'", + "SELECT COUNT(*) FROM companies WHERE name LIKE '" + PREFIX + "%'", + "SELECT COUNT(*) FROM notifications WHERE user_id IN " + users)) { + total += j.queryForObject(sql, Integer.class); + } + return total; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentSideEffectIsolationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentSideEffectIsolationTest.java new file mode 100644 index 0000000..f325961 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentSideEffectIsolationTest.java @@ -0,0 +1,441 @@ +package com.mydreamjob.common.r01e; + +import com.mydreamjob.dataexport.PersonalDataCollector; +import com.mydreamjob.messaging.MessagingService; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +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.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * R01-E **独立复验**(第五节):副作用、隔离与反证。 + * + *

    与实施测试的差异:本类独立实现夹具与快照(25 张表、列集合不同), + * 并且额外验证三件事 ——

    + *
      + *
    1. 反证:故意改动一行(改 / 增 / 删)时,快照断言**必须失败**, + * 否则「零变化」只是没在比;
    2. + *
    3. RabbitMQ:用 {@code @MockBean RabbitTemplate} 直接断言读路径**零 AMQP 交互**;
    4. + *
    5. 回归门禁:本类运行在**共享的 integration H2 库**上,故可以直接检查 + * {@code R01EQueryBudgetAcceptanceTest} 的 {@code r01e-} 夹具是否已零残留 + * (此前缺 {@code @AfterEach} 导致 60 项失败的正是这一点)。
    6. + *
    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +@org.junit.jupiter.api.TestInstance(org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS) +class R01EIndependentSideEffectIsolationTest { + + private static final String PREFIX = "r01eiso-"; + private static final LocalDateTime T = LocalDateTime.of(2026, 4, 1, 8, 0, 0); + + @Autowired private MessagingService messagingService; + @Autowired private PersonalDataCollector personalDataCollector; + @Autowired private JdbcTemplate jdbc; + @MockBean private RedisTemplate redisTemplate; + @MockBean private RabbitTemplate rabbitTemplate; + + private Long me; + private Long other; + private Long companyId; + private Long jobId; + + @BeforeEach + void setUp() { + cleanup(); + me = user(PREFIX + "me"); + other = user(PREFIX + "other"); + } + + @AfterEach + void tearDown() { + cleanup(); + } + + /** 退场门禁:本类跑完后,自有夹具与既有实施测试的夹具都必须零残留。 */ + @AfterAll + void assertNoResidueAfterClass() { + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE phone LIKE '" + PREFIX + "%'", + Integer.class)) + .as("本类自有用户残留必须为 0").isZero(); + assertThat(residue()).as("本类自有夹具残留必须为 0").isZero(); + assertThat(jdbc.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone LIKE 'r01e-%' AND phone NOT LIKE '" + PREFIX + "%'", + Integer.class)) + .as("实施测试的 r01e- 夹具残留必须为 0").isZero(); + } + + // ══════════════════════════════════════════════════════════ + // 夹具 + // ══════════════════════════════════════════════════════════ + + private void cleanup() { + String u = "(SELECT id FROM users WHERE phone LIKE '" + PREFIX + "%')"; + String convs = "(SELECT id FROM conversations WHERE created_by IN " + u + ")"; + String apps = "(SELECT id FROM applications WHERE student_user_id IN " + u + ")"; + String companies = "(SELECT id FROM companies WHERE owner_user_id IN " + u + ")"; + String ivs = "(SELECT id FROM interviews WHERE student_user_id IN " + u + + " OR company_user_id IN " + u + ")"; + String offers = "(SELECT id FROM offers WHERE candidate_id IN " + u + " OR company_id IN " + + companies + ")"; + String appeals = "(SELECT id FROM candidate_scoring_appeals WHERE candidate_user_id IN " + u + ")"; + + jdbc.update("DELETE FROM message_reads WHERE message_id IN (SELECT id FROM messages " + + "WHERE conversation_id IN " + convs + ")"); + jdbc.update("DELETE FROM messages WHERE conversation_id IN " + convs); + jdbc.update("DELETE FROM conversation_members WHERE conversation_id IN " + convs + " OR user_id IN " + u); + jdbc.update("DELETE FROM conversations WHERE created_by IN " + u); + jdbc.update("DELETE FROM application_status_history WHERE application_id IN " + apps); + jdbc.update("DELETE FROM interview_status_history WHERE interview_id IN " + ivs); + jdbc.update("DELETE FROM interviews WHERE student_user_id IN " + u + " OR company_user_id IN " + u); + jdbc.update("DELETE FROM offer_status_history WHERE offer_id IN " + offers); + jdbc.update("DELETE FROM offer_counter_offers WHERE offer_id IN " + offers + " OR student_id IN " + u); + jdbc.update("DELETE FROM offers WHERE candidate_id IN " + u + " OR company_id IN " + companies); + jdbc.update("DELETE FROM candidate_scoring_appeal_history WHERE appeal_id IN " + appeals); + jdbc.update("DELETE FROM candidate_scoring_snapshot_history WHERE candidate_user_id IN " + u); + jdbc.update("DELETE FROM candidate_scoring_appeals WHERE candidate_user_id IN " + u); + jdbc.update("DELETE FROM candidate_dimension_scores WHERE student_user_id IN " + u); + jdbc.update("DELETE FROM candidate_feedback WHERE student_user_id IN " + u + " OR company_user_id IN " + u); + jdbc.update("DELETE FROM job_favorites WHERE student_user_id IN " + u); + jdbc.update("DELETE FROM notifications WHERE user_id IN " + u); + jdbc.update("DELETE FROM resume_versions WHERE user_id IN " + u); + jdbc.update("DELETE FROM student_preferences WHERE student_user_id IN " + u); + jdbc.update("DELETE FROM student_profiles WHERE user_id IN " + u); + jdbc.update("DELETE FROM applications WHERE student_user_id IN " + u); + jdbc.update("DELETE FROM jobs WHERE title LIKE '" + PREFIX + "%'"); + jdbc.update("DELETE FROM companies WHERE name LIKE '" + PREFIX + "%'"); + jdbc.update("DELETE FROM user_roles WHERE user_id IN " + u); + jdbc.update("DELETE FROM users WHERE phone LIKE '" + PREFIX + "%'"); + companyId = null; + jobId = null; + } + + private Long user(String phone) { + jdbc.update("INSERT INTO users (phone, nickname, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, ?, 'STUDENT', 1, 1, ?, ?)", phone, phone, T, T); + Long id = jdbc.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone); + jdbc.update("INSERT INTO user_roles (user_id, role) VALUES (?, 'STUDENT')", id); + return id; + } + + /** 灌入覆盖全部 12 个 section 的夹具,使 collect() 真正走满所有分支。 */ + private void seed() { + jdbc.update("INSERT INTO student_profiles (user_id, real_name, location, skills_json, created_at, " + + "updated_at) VALUES (?, '张三', '北京', '{\"java\":3}', ?, ?)", me, T, T); + jdbc.update("INSERT INTO student_preferences (student_user_id, expected_salary_min, preferred_cities, " + + "remote_allowed, created_at, updated_at) VALUES (?, 15000, '[\"北京\"]', 1, ?, ?)", me, T, T); + jdbc.update("INSERT INTO resume_versions (user_id, name, version_no, file_storage_path, file_name, " + + "content_type, file_size, status, created_at, updated_at) " + + "VALUES (?, '简历', 1, ?, 'a.pdf', 'application/pdf', 100, 'ACTIVE', ?, ?)", + me, PREFIX + "absent.pdf", T, T); + + jdbc.update("INSERT INTO companies (owner_user_id, name, verified, created_at, updated_at) " + + "VALUES (?, ?, 1, ?, ?)", other, PREFIX + "co", T, T); + companyId = jdbc.queryForObject("SELECT id FROM companies WHERE owner_user_id = ?", Long.class, other); + jdbc.update("INSERT INTO jobs (company_id, title, description, city, employment_type, status, " + + "created_at, updated_at) VALUES (?, ?, 'd', '北京', 'FULL_TIME', 'PUBLISHED', ?, ?)", + companyId, PREFIX + "job", T, T); + jobId = jdbc.queryForObject("SELECT id FROM jobs WHERE title = ?", Long.class, PREFIX + "job"); + jdbc.update("INSERT INTO applications (job_id, student_user_id, status, created_at, updated_at) " + + "VALUES (?, ?, 'SUBMITTED', ?, ?)", jobId, me, T, T); + Long appId = jdbc.queryForObject("SELECT id FROM applications WHERE job_id = ?", Long.class, jobId); + jdbc.update("INSERT INTO application_status_history (application_id, operator_user_id, from_status, " + + "to_status, created_at) VALUES (?, ?, NULL, 'SUBMITTED', ?)", appId, other, T); + + jdbc.update("INSERT INTO interviews (application_id, company_user_id, student_user_id, round_no, title, " + + "interview_type, start_at, end_at, status, created_at, updated_at) " + + "VALUES (?, ?, ?, 1, '一面', 'VIDEO', ?, ?, 'SCHEDULED', ?, ?)", + appId, other, me, T, T.plusHours(1), T, T); + Long ivId = jdbc.queryForObject("SELECT id FROM interviews WHERE application_id = ?", Long.class, appId); + jdbc.update("INSERT INTO interview_status_history (interview_id, from_status, to_status, actor_user_id, " + + "created_at) VALUES (?, NULL, 'SCHEDULED', ?, ?)", ivId, other, T); + + jdbc.update("INSERT INTO offers (application_id, job_id, company_id, candidate_id, title, description, " + + "expire_at, status, version, created_by, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, 'Offer', '说明', ?, 'SENT', 0, ?, ?, ?)", + appId, jobId, companyId, me, T.plusDays(30), other, T, T); + Long offerId = jdbc.queryForObject("SELECT id FROM offers WHERE application_id = ?", Long.class, appId); + jdbc.update("INSERT INTO offer_status_history (offer_id, from_status, to_status, operator_id, " + + "operator_role, created_at) VALUES (?, NULL, 'SENT', ?, 'COMPANY', ?)", offerId, other, T); + + jdbc.update("INSERT INTO candidate_feedback (application_id, company_user_id, student_user_id, rating, " + + "content, created_at, updated_at) VALUES (?, ?, ?, 5, '不错', ?, ?)", appId, other, me, T, T); + jdbc.update("INSERT INTO job_favorites (job_id, student_user_id, created_at) VALUES (?, ?, ?)", + jobId, me, T); + jdbc.update("INSERT INTO notifications (user_id, type, title, is_read, created_at) " + + "VALUES (?, 'APPLICATION', '通知', 0, ?)", me, T); + + jdbc.update("INSERT INTO candidate_dimension_scores (student_user_id, dimension, period, score, status, " + + "weight, rules_version, calculated_at, created_at, updated_at) " + + "VALUES (?, 'EDUCATION', '2026-03', 88.00, 'OK', 12.50, 'v1', ?, ?, ?)", me, T, T, T); + jdbc.update("INSERT INTO candidate_scoring_appeals (candidate_user_id, dimension, period, status, " + + "reason, rules_version, created_at, updated_at) " + + "VALUES (?, 'EDUCATION', '2026-03', 'PENDING', '理由', 'v1', ?, ?)", me, T, T); + Long appealId = jdbc.queryForObject("SELECT id FROM candidate_scoring_appeals WHERE candidate_user_id = ?", + Long.class, me); + jdbc.update("INSERT INTO candidate_scoring_appeal_history (appeal_id, from_status, to_status, " + + "actor_user_id, actor_role, created_at) VALUES (?, NULL, 'PENDING', ?, 'ADMIN', ?)", + appealId, other, T); + + jdbc.update("INSERT INTO conversations (title, type, created_by, created_at, updated_at) " + + "VALUES ('会话', 'DIRECT', ?, ?, ?)", me, T, T); + Long convId = jdbc.queryForObject("SELECT id FROM conversations WHERE created_by = ?", Long.class, me); + jdbc.update("INSERT INTO conversation_members (conversation_id, user_id, joined_at) VALUES (?, ?, ?)", + convId, me, T); + jdbc.update("INSERT INTO conversation_members (conversation_id, user_id, joined_at) VALUES (?, ?, ?)", + convId, other, T); + jdbc.update("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, 'm1', 'SENT', ?)", convId, other, T); + jdbc.update("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, 'm2', 'SENT', ?)", convId, me, T.plusMinutes(1)); + Long m1 = jdbc.queryForObject("SELECT MIN(id) FROM messages WHERE conversation_id = ?", Long.class, convId); + jdbc.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", m1, me, T); + } + + // ══════════════════════════════════════════════════════════ + // 快照:25 张表的稳定字段,按主键排序逐字段比对 + // ══════════════════════════════════════════════════════════ + + private Map>> snapshot() { + Map>> s = new LinkedHashMap<>(); + s.put("users", q("SELECT id, phone, nickname, role, is_active, is_verified FROM users ORDER BY id")); + s.put("user_roles", q("SELECT user_id, role FROM user_roles ORDER BY user_id, role")); + s.put("companies", q("SELECT id, owner_user_id, name, verified FROM companies ORDER BY id")); + s.put("jobs", q("SELECT id, company_id, title, status FROM jobs ORDER BY id")); + s.put("applications", q("SELECT id, job_id, student_user_id, status FROM applications ORDER BY id")); + s.put("application_status_history", + q("SELECT id, application_id, to_status FROM application_status_history ORDER BY id")); + s.put("interviews", q("SELECT id, application_id, company_user_id, student_user_id, status " + + "FROM interviews ORDER BY id")); + s.put("interview_status_history", + q("SELECT id, interview_id, to_status FROM interview_status_history ORDER BY id")); + s.put("offers", q("SELECT id, application_id, candidate_id, status FROM offers ORDER BY id")); + s.put("offer_status_history", + q("SELECT id, offer_id, to_status FROM offer_status_history ORDER BY id")); + s.put("candidate_feedback", q("SELECT id, application_id, rating FROM candidate_feedback ORDER BY id")); + s.put("job_favorites", q("SELECT id, job_id, student_user_id FROM job_favorites ORDER BY id")); + s.put("notifications", q("SELECT id, user_id, type, is_read FROM notifications ORDER BY id")); + s.put("audit_logs", q("SELECT id, user_id, action, entity_type FROM audit_logs ORDER BY id")); + s.put("auth_tokens", q("SELECT id, user_id, token_type, revoked FROM auth_tokens ORDER BY id")); + s.put("conversations", q("SELECT id, type, created_by, updated_at FROM conversations ORDER BY id")); + s.put("conversation_members", + q("SELECT conversation_id, user_id FROM conversation_members ORDER BY conversation_id, user_id")); + s.put("messages", + q("SELECT id, conversation_id, sender_user_id, status, version FROM messages ORDER BY id")); + s.put("message_reads", q("SELECT id, message_id, reader_user_id FROM message_reads ORDER BY id")); + s.put("student_profiles", q("SELECT id, user_id, real_name FROM student_profiles ORDER BY id")); + s.put("student_preferences", + q("SELECT id, student_user_id, remote_allowed FROM student_preferences ORDER BY id")); + s.put("resume_versions", q("SELECT id, user_id, version_no, status FROM resume_versions ORDER BY id")); + s.put("candidate_dimension_scores", + q("SELECT id, student_user_id, dimension, status FROM candidate_dimension_scores ORDER BY id")); + s.put("candidate_scoring_appeals", + q("SELECT id, candidate_user_id, status FROM candidate_scoring_appeals ORDER BY id")); + s.put("candidate_scoring_appeal_history", + q("SELECT id, appeal_id, to_status FROM candidate_scoring_appeal_history ORDER BY id")); + return s; + } + + private List> q(String sql) { + return jdbc.queryForList(sql); + } + + private static void assertNoChange(Map>> before, + Map>> after, String what) { + assertThat(after.keySet()).isEqualTo(before.keySet()); + for (String table : before.keySet()) { + assertThat(after.get(table)) + .as("%s 期间 %s 必须逐字段零变化", what, table) + .isEqualTo(before.get(table)); + } + } + + // ══════════════════════════════════════════════════════════ + // 测试 + // ══════════════════════════════════════════════════════════ + + @Test + @DisplayName("只读:collector.collect() 前后 25 张表逐字段零变化,且零 AMQP 交互") + void collectIsReadOnly() { + seed(); + Map>> before = snapshot(); + + PersonalDataCollector.CollectResult result = personalDataCollector.collect(me); + + assertThat(result).isNotNull(); + assertThat(result.getSections()).isNotEmpty(); + assertNoChange(before, snapshot(), "collect"); + Mockito.verifyNoInteractions(rabbitTemplate); + } + + @Test + @DisplayName("只读:listConversations() 前后 25 张表逐字段零变化,且零 AMQP 交互") + void listConversationsIsReadOnly() { + seed(); + Map>> before = snapshot(); + + List> list = messagingService.listConversations(me); + + assertThat(list).hasSize(1); + // m1(他人)已被本人读取 → 排除;m2(本人发送、无 message_reads)→ 按 OBS-E01 计入,故为 1 + assertThat(list.get(0).get("unreadCount")).isEqualTo(1L); + assertNoChange(before, snapshot(), "listConversations"); + Mockito.verifyNoInteractions(rabbitTemplate); + } + + @Test + @DisplayName("只读:getConversation 与 collect 组合调用后仍零变化") + void combinedReadPathsAreReadOnly() { + seed(); + Long convId = jdbc.queryForObject("SELECT id FROM conversations WHERE created_by = ?", Long.class, me); + Map>> before = snapshot(); + + messagingService.listConversations(me); + messagingService.getConversation(convId, me); + personalDataCollector.collect(me); + + assertNoChange(before, snapshot(), "listConversations+getConversation+collect"); + Mockito.verifyNoInteractions(rabbitTemplate); + } + + @Test + @DisplayName("通知与审计:读取路径不新增任何行") + void noNotificationsAndAuditsAreCreated() { + seed(); + Integer notificationsBefore = jdbc.queryForObject("SELECT COUNT(*) FROM notifications", Integer.class); + Integer auditsBefore = jdbc.queryForObject("SELECT COUNT(*) FROM audit_logs", Integer.class); + + messagingService.listConversations(me); + personalDataCollector.collect(me); + + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM notifications", Integer.class)) + .as("不新增通知").isEqualTo(notificationsBefore); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM audit_logs", Integer.class)) + .as("不新增审计").isEqualTo(auditsBefore); + Mockito.verifyNoInteractions(rabbitTemplate); + } + + // ── 反证:快照必须真的能发现变化 ───────────────────────── + + @Test + @DisplayName("反证:修改一行时,快照断言必须失败") + void snapshotDetectsUpdate() { + seed(); + Map>> before = snapshot(); + jdbc.update("UPDATE users SET nickname = '被篡改' WHERE id = ?", me); + Map>> after = snapshot(); + + assertThatThrownBy(() -> assertNoChange(before, after, "mutation")) + .as("若此处不抛错,说明快照断言形同虚设") + .isInstanceOf(AssertionError.class) + .hasMessageContaining("users"); + } + + @Test + @DisplayName("反证:新增一行时,快照断言必须失败") + void snapshotDetectsInsert() { + seed(); + Map>> before = snapshot(); + jdbc.update("INSERT INTO notifications (user_id, type, title, is_read, created_at) " + + "VALUES (?, 'APPLICATION', '新增', 0, ?)", me, T); + Map>> after = snapshot(); + + assertThatThrownBy(() -> assertNoChange(before, after, "insertion")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("notifications"); + } + + @Test + @DisplayName("反证:删除一行时,快照断言必须失败") + void snapshotDetectsDelete() { + seed(); + Map>> before = snapshot(); + jdbc.update("DELETE FROM message_reads WHERE reader_user_id = ?", me); + Map>> after = snapshot(); + + assertThatThrownBy(() -> assertNoChange(before, after, "deletion")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("message_reads"); + } + + @Test + @DisplayName("反证:仅一个字段变化(messages.status)也必须被捕获") + void snapshotDetectsSingleColumnChange() { + seed(); + Long convId = jdbc.queryForObject("SELECT id FROM conversations WHERE created_by = ?", Long.class, me); + Map>> before = snapshot(); + jdbc.update("UPDATE messages SET status = 'RECALLED' WHERE conversation_id = ? AND status = 'SENT'", convId); + Map>> after = snapshot(); + + assertThatThrownBy(() -> assertNoChange(before, after, "single column")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("messages"); + } + + // ── 回归门禁:既有实施测试的夹具零残留 ──────────────────── + + @Test + @DisplayName("回归门禁:实施测试的 r01e- 夹具在本类可见范围内残留为 0(复验缺失 @AfterEach 的旧问题)") + void implementationFixturesLeaveNoResidue() { + assertThat(jdbc.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone LIKE 'r01e-%' AND phone NOT LIKE '" + PREFIX + "%'", + Integer.class)) + .as("R01EQueryBudgetAcceptanceTest 的 r01e- 用户必须已按 @AfterEach 清干净").isZero(); + assertThat(jdbc.queryForObject( + "SELECT COUNT(*) FROM messages WHERE content LIKE 'm%' AND sender_user_id IN " + + "(SELECT id FROM users WHERE phone LIKE 'r01e-%' AND phone NOT LIKE '" + PREFIX + "%')", + Integer.class)) + .as("实施测试的消息不得残留").isZero(); + assertThat(jdbc.queryForObject( + "SELECT COUNT(*) FROM companies WHERE name = 'r01e-co'", Integer.class)) + .as("实施测试的公司不得残留").isZero(); + assertThat(jdbc.queryForObject( + "SELECT COUNT(*) FROM jobs WHERE title = 'r01e-job'", Integer.class)) + .as("实施测试的岗位不得残留").isZero(); + } + + @Test + @DisplayName("隔离:本类夹具在本用例退场后残留为 0") + void ownFixtureLeavesNoResidue() { + seed(); + cleanup(); + + assertThat(residue()).as("本类自有残留").isZero(); + } + + private long residue() { + long total = 0; + String u = "(SELECT id FROM users WHERE phone LIKE '" + PREFIX + "%')"; + List queries = new ArrayList<>(List.of( + "SELECT COUNT(*) FROM users WHERE phone LIKE '" + PREFIX + "%'", + "SELECT COUNT(*) FROM user_roles WHERE user_id IN " + u, + "SELECT COUNT(*) FROM messages WHERE sender_user_id IN " + u, + "SELECT COUNT(*) FROM message_reads WHERE reader_user_id IN " + u, + "SELECT COUNT(*) FROM conversation_members WHERE user_id IN " + u, + "SELECT COUNT(*) FROM conversations WHERE created_by IN " + u, + "SELECT COUNT(*) FROM applications WHERE student_user_id IN " + u, + "SELECT COUNT(*) FROM notifications WHERE user_id IN " + u)); + for (String sql : queries) { + total += jdbc.queryForObject(sql, Integer.class); + } + return total; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java new file mode 100644 index 0000000..a2f78e4 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java @@ -0,0 +1,343 @@ +package com.mydreamjob.common.r01e; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.messaging.MessagingService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * R01-E:**真实 MySQL 8** 上的 E2 跨库一致性与执行计划证据(Testcontainers)。 + * + *

    不做生产改动(E1/E2 已由 R01-D2/D3 交付)。本类证明:

    + *
      + *
    1. 未读分组 SQL 在 MySQL 上与 H2 上的**逐值一致**;
    2. + *
    3. 1 / 20 / 100 会话下 {@code listConversations} 的 SQL 数恒定;
    4. + *
    5. {@code EXPLAIN FORMAT=JSON} 的索引使用、access type、temporary / filesort 情况。
    6. + *
    + * + *

    EXPLAIN 使用与 {@code MessageRepository.countUnreadByConversationIds} **逐字相同**的 SQL + * 与同一批绑定参数,避免解释一条并不存在的语句。

    + */ +@Testcontainers(disabledWithoutDocker = true) +@SpringBootTest +@ActiveProfiles("mysql-it") +class R01EMySqlQueryCountAndExplainTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("mydreamjob_r01e") + .withUsername("r01e") + .withPassword("r01e"); + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + } + + /** 与 MessageRepository 中原生查询**逐字一致**(仅换行为便于阅读)。 */ + private static final String UNREAD_SQL = """ + SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount + FROM ( + SELECT m.id, m.conversation_id, m.status, + ROW_NUMBER() OVER ( + PARTITION BY m.conversation_id + ORDER BY m.created_at DESC + ) AS row_no + FROM messages m + WHERE m.conversation_id IN (%s) + ) ranked + LEFT JOIN message_reads mr + ON mr.message_id = ranked.id + AND mr.reader_user_id = ? + WHERE ranked.row_no <= 500 + AND (ranked.status IS NULL OR ranked.status <> 'RECALLED') + AND mr.id IS NULL + GROUP BY ranked.conversation_id + """; + + @Autowired private MessagingService messagingService; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + + private Long me; + private Long other; + + @BeforeEach + void setUp() { + jdbcTemplate.update("DELETE FROM message_reads"); + jdbcTemplate.update("DELETE FROM messages"); + jdbcTemplate.update("DELETE FROM conversation_members"); + jdbcTemplate.update("DELETE FROM conversations"); + jdbcTemplate.update("DELETE FROM user_roles"); + jdbcTemplate.update("DELETE FROM users"); + me = user("r01e-my-self"); + other = user("r01e-my-other"); + } + + private Long user(String phone) { + jdbcTemplate.update("INSERT INTO users (phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, 'STUDENT', 1, 1)", phone, phone); + Long id = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone); + jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, 'STUDENT')", id); + return id; + } + + private Long conversation() { + jdbcTemplate.update("INSERT INTO conversations (type, created_by, created_at, updated_at) " + + "VALUES ('DIRECT', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", me); + Long cid = jdbcTemplate.queryForObject("SELECT MAX(id) FROM conversations", Long.class); + jdbcTemplate.update("INSERT INTO conversation_members (conversation_id, user_id) VALUES (?, ?)", cid, me); + jdbcTemplate.update("INSERT INTO conversation_members (conversation_id, user_id) VALUES (?, ?)", cid, other); + return cid; + } + + private void message(Long cid, Long sender, int i, String status) { + jdbcTemplate.update("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)", cid, sender, "m" + i, status); + } + + private String inClause(int n) { + return String.join(",", java.util.Collections.nCopies(n, "?")); + } + + @Test + @DisplayName("MySQL:分组未读数与逐会话语义逐值一致(含撤回与已读排除)") + void groupedUnreadMatchesPerConversationSemantics() { + Long c1 = conversation(); + Long c2 = conversation(); + message(c1, other, 1, "SENT"); + message(c1, other, 2, "SENT"); + message(c1, other, 3, "RECALLED"); // 撤回 → 排除 + message(c2, other, 4, "SENT"); + Long readId = jdbcTemplate.queryForObject( + "SELECT MIN(id) FROM messages WHERE conversation_id = ? AND status = 'SENT'", Long.class, c1); + jdbcTemplate.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) " + + "VALUES (?, ?, CURRENT_TIMESTAMP)", readId, me); // 已读 1 条 → 排除 + + List> rows = jdbcTemplate.queryForList( + String.format(UNREAD_SQL, inClause(2)), c1, c2, me); + Map byConv = new LinkedHashMap<>(); + for (Map r : rows) { + byConv.put(((Number) r.get("conversationId")).longValue(), ((Number) r.get("unreadCount")).longValue()); + } + + assertThat(byConv.get(c1)).as("2 条 SENT − 1 条已读 = 1").isEqualTo(1L); + assertThat(byConv.get(c2)).as("1 条 SENT").isEqualTo(1L); + + // 与 Service 路径一致(Service 对未返回的会话补 0) + List> list = messagingService.listConversations(me); + Map svc = new LinkedHashMap<>(); + for (Map v : list) svc.put(((Number) v.get("id")).longValue(), v.get("unreadCount")); + assertThat(svc.get(c1)).isEqualTo(1L); + assertThat(svc.get(c2)).isEqualTo(1L); + } + + @Test + @DisplayName("MySQL:空列表与不属用户的 conversationId 均不返回") + void emptyAndForeignConversations() { + assertThat(jdbcTemplate.queryForList(String.format(UNREAD_SQL, inClause(1)), -1L, me)).isEmpty(); + + Long c1 = conversation(); + message(c1, other, 1, "SENT"); + // 不传该会话 id → 不返回 + assertThat(jdbcTemplate.queryForList(String.format(UNREAD_SQL, inClause(1)), -1L, me)).isEmpty(); + } + + @Test + @DisplayName("MySQL:1 / 20 / 100 会话下 Service SQL 数与结果均正确") + void conversationScaleIsFlatAndCorrect() { + int[] sizes = {1, 20, 100}; + for (int size : sizes) { + jdbcTemplate.update("DELETE FROM message_reads"); + jdbcTemplate.update("DELETE FROM messages"); + jdbcTemplate.update("DELETE FROM conversation_members"); + jdbcTemplate.update("DELETE FROM conversations"); + + for (int i = 0; i < size; i++) { + Long cid = conversation(); + for (int m = 0; m < 3; m++) message(cid, other, m, "SENT"); + } + + List> list = messagingService.listConversations(me); + assertThat(list).as("size=%d", size).hasSize(size); + for (Map v : list) { + assertThat(v.get("unreadCount")).as("size=%d 每会话 3 条未读", size).isEqualTo(3L); + } + } + } + + @Test + @DisplayName("MySQL EXPLAIN FORMAT=JSON:记录索引、access type、temporary 与 filesort") + void explainUnreadQuery() throws Exception { + // 铺入不属于查询范围的背景消息,使目标会话保持高选择性;否则最小表上全表扫描 + // 是优化器的合理代价选择,无法证明现有索引在真实列表形态下可用。 + Long background = conversation(); + jdbcTemplate.batchUpdate( + "INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, 'SENT', CURRENT_TIMESTAMP)", + new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ps.setLong(1, background); + ps.setLong(2, other); + ps.setString(3, "background-" + i); + } + + @Override + public int getBatchSize() { + return 10_000; + } + }); + + Long cid = conversation(); + for (int i = 0; i < 200; i++) message(cid, other, i, "SENT"); + jdbcTemplate.execute("ANALYZE TABLE messages"); + + List> explain = jdbcTemplate.queryForList( + "EXPLAIN FORMAT=JSON " + String.format(UNREAD_SQL, inClause(1)), cid, me); + assertThat(explain).isNotEmpty(); + String json = String.valueOf(explain.get(0).get("EXPLAIN")); + JsonNode root = JSON.readTree(json); + + StringBuilder summary = new StringBuilder(); + summary.append("EXPLAIN_JSON_LENGTH=").append(json.length()).append('\n'); + for (String key : List.of("using_temporary_table", "using_filesort", "access_type", + "key\"", "possible_keys", "table_name", "rows_examined_per_scan", "cost_info")) { + summary.append(key).append(" -> ").append(json.contains(key) ? "present" : "absent").append('\n'); + } + // 提取关键片段(截断,避免把整份 JSON 写入报告) + int idx = json.indexOf("\"access_type\""); + if (idx > 0) { + summary.append("SNIPPET=").append(json, Math.max(0, idx - 200), Math.min(json.length(), idx + 400)).append('\n'); + } + try { + java.nio.file.Files.writeString( + java.nio.file.Path.of("target", "r01e-mysql-explain.txt"), summary.toString()); + } catch (Exception ignored) { + // 诊断输出 + } + System.out.println("[R01E-MYSQL-EXPLAIN] " + summary); + + assertExplainPlan(root); + } + + @Test + @DisplayName("EXPLAIN 结构断言可识别错误索引、基础表全扫与缺失节点") + void explainAssertionsRejectMutatedPlans() throws Exception { + String valid = """ + {"query_block":{"using_temporary_table":true,"using_filesort":true,"nested_loop":[ + {"table":{"table_name":"ranked","access_type":"ALL"}}, + {"table":{"table_name":"m","access_type":"ref","key":"idx_messages_conversation_created"}}, + {"table":{"table_name":"mr","access_type":"eq_ref","key":"uk_message_reads"}} + ]}} + """; + assertExplainPlan(JSON.readTree(valid)); + assertThatThrownBy(() -> assertExplainPlan(JSON.readTree( + valid.replace("idx_messages_conversation_created", "wrong_index")))) + .isInstanceOf(AssertionError.class); + assertThatThrownBy(() -> assertExplainPlan(JSON.readTree( + valid.replace("\"access_type\":\"ref\"", "\"access_type\":\"ALL\"")))) + .isInstanceOf(AssertionError.class); + String missingReads = """ + {"query_block":{"using_temporary_table":true,"using_filesort":true,"nested_loop":[ + {"table":{"table_name":"ranked","access_type":"ALL"}}, + {"table":{"table_name":"m","access_type":"ref","key":"idx_messages_conversation_created"}} + ]}} + """; + assertThatThrownBy(() -> assertExplainPlan(JSON.readTree(missingReads))) + .isInstanceOf(AssertionError.class); + } + + private static void assertExplainPlan(JsonNode root) { + List nodes = new ArrayList<>(); + collectPlanNodes(root, nodes); + PlanNode messages = onlyNode(nodes, "m"); + PlanNode reads = onlyNode(nodes, "mr"); + PlanNode ranked = onlyNode(nodes, "ranked"); + + assertThat(messages.accessType()).isIn("ref", "range"); + assertThat(messages.key()).isEqualTo("idx_messages_conversation_created"); + assertThat(messages.accessType()).isNotEqualTo("ALL"); + assertThat(reads.accessType()).isEqualTo("eq_ref"); + assertThat(reads.key()).isEqualTo("uk_message_reads"); + assertThat(ranked.accessType()).isEqualTo("ALL"); + assertThat(findBooleanValue(root, "using_temporary_table")) + .as("必须显式读取 using_temporary_table") + .isPresent(); + assertThat(findBooleanValue(root, "using_filesort")) + .as("必须显式读取 using_filesort") + .isPresent(); + } + + private static PlanNode onlyNode(List nodes, String alias) { + List matches = nodes.stream().filter(node -> alias.equals(node.tableName())).toList(); + assertThat(matches).as("EXPLAIN 节点 %s 必须存在且唯一", alias).hasSize(1); + return matches.get(0); + } + + private static void collectPlanNodes(JsonNode node, List out) { + if (node == null) return; + if (node.isObject()) { + JsonNode table = node.get("table"); + if (table != null && table.isObject() && table.has("table_name")) { + out.add(new PlanNode( + table.path("table_name").asText(), + table.path("access_type").asText(), + table.has("key") ? table.path("key").asText() : null)); + } + node.fields().forEachRemaining(entry -> collectPlanNodes(entry.getValue(), out)); + } else if (node.isArray()) { + node.forEach(child -> collectPlanNodes(child, out)); + } + } + + private static Optional findBooleanValue(JsonNode node, String field) { + if (node == null) return Optional.empty(); + if (node.isObject()) { + if (node.has(field) && node.get(field).isBoolean()) { + return Optional.of(node.get(field).asBoolean()); + } + for (JsonNode child : node) { + Optional found = findBooleanValue(child, field); + if (found.isPresent()) return found; + } + } else if (node.isArray()) { + for (JsonNode child : node) { + Optional found = findBooleanValue(child, field); + if (found.isPresent()) return found; + } + } + return Optional.empty(); + } + + private record PlanNode(String tableName, String accessType, String key) {} +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java new file mode 100644 index 0000000..cf4e77d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java @@ -0,0 +1,418 @@ +package com.mydreamjob.common.r01e; + +import com.mydreamjob.dataexport.PersonalDataCollector; +import com.mydreamjob.messaging.MessagingService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * R01-E 验收:E1 批量查询与 E2 会话未读数聚合的**查询预算**特征。 + * + *

    本轮不改生产代码(E1/E2 已由 R01-D2/D3 交付并处于目标终态), + * 本类用于**锁定**其查询次数形态:SQL 数不得随记录数/会话数线性增长。

    + * + *

    复用项目既有做法({@code MessageListQueryBudgetIntegrationTest} 的 CountingDataSource 模式): + * 通过只包一层的 DataSource 统计**真实 JDBC 语句**,而非 Mockito verify Repository。 + * 该包装器仅存在于测试源,不新增生产依赖。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +@ContextConfiguration(classes = {R01EQueryBudgetAcceptanceTest.CountingDataSourceConfiguration.class}) +class R01EQueryBudgetAcceptanceTest { + + @Autowired private MessagingService messagingService; + @Autowired private PersonalDataCollector personalDataCollector; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private CountingDataSource countingDataSource; + @MockBean private RedisTemplate redisTemplate; + + private Long me; + private Long other; + private Long companyId; + + @BeforeEach + void setUp() { + cleanup(); + me = user("r01e-self"); + other = user("r01e-other"); + companyId = null; + countingDataSource.reset(); + } + + /** 退场清理:本类会创建用户/公司/岗位/会话/消息,必须按精确条件清干净, + * 否则会污染后续使用 userRepository.deleteAll() 的测试类与审计计数断言。 */ + @AfterEach + void tearDown() { + cleanup(); + companyId = null; + } + + private void cleanup() { + jdbcTemplate.update("DELETE FROM message_reads WHERE message_id IN " + + "(SELECT id FROM messages WHERE sender_user_id IN " + + "(SELECT id FROM users WHERE phone LIKE 'r01e-%'))"); + jdbcTemplate.update("DELETE FROM messages WHERE sender_user_id IN " + + "(SELECT id FROM users WHERE phone LIKE 'r01e-%')"); + jdbcTemplate.update("DELETE FROM conversation_members WHERE user_id IN " + + "(SELECT id FROM users WHERE phone LIKE 'r01e-%')"); + jdbcTemplate.update("DELETE FROM conversations WHERE id NOT IN (SELECT DISTINCT conversation_id FROM conversation_members)"); + jdbcTemplate.update("DELETE FROM applications WHERE student_user_id IN " + + "(SELECT id FROM users WHERE phone LIKE 'r01e-%')"); + jdbcTemplate.update("DELETE FROM jobs WHERE title = 'r01e-job'"); + jdbcTemplate.update("DELETE FROM companies WHERE name = 'r01e-co'"); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone LIKE 'r01e-%')"); + jdbcTemplate.update("DELETE FROM users WHERE phone LIKE 'r01e-%'"); + } + + private Long user(String phone) { + jdbcTemplate.update("INSERT INTO users (phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, 'STUDENT', 1, 1)", phone, phone); + Long id = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone); + jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, 'STUDENT')", id); + return id; + } + + private Long conversation(int index) { + jdbcTemplate.update("INSERT INTO conversations (type, created_by, created_at, updated_at) " + + "VALUES ('DIRECT', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", me); + Long cid = jdbcTemplate.queryForObject("SELECT MAX(id) FROM conversations", Long.class); + jdbcTemplate.update("INSERT INTO conversation_members (conversation_id, user_id) VALUES (?, ?)", cid, me); + jdbcTemplate.update("INSERT INTO conversation_members (conversation_id, user_id) VALUES (?, ?)", cid, other); + return cid; + } + + /** + * 新建一个岗位供 applications 外键使用。 + * + *

    {@code companies.owner_user_id} 有唯一约束,故公司**只建一次**并复用,岗位可多次新建 + * ({@code applications(job_id, student_user_id)} 唯一,故每行投递需独立岗位)。

    + */ + private Long createJob() { + if (companyId == null) { + jdbcTemplate.update("INSERT INTO companies (name, owner_user_id, verified, created_at, updated_at) " + + "VALUES ('r01e-co', ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", other); + companyId = jdbcTemplate.queryForObject("SELECT MAX(id) FROM companies", Long.class); + } + jdbcTemplate.update("INSERT INTO jobs (company_id, title, description, city, employment_type, status, " + + "created_at, updated_at) VALUES (?, 'r01e-job', 'd', '北京', 'FULL_TIME', 'PUBLISHED', " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", companyId); + return jdbcTemplate.queryForObject("SELECT MAX(id) FROM jobs", Long.class); + } + + private void message(Long cid, Long sender, int i) { + jdbcTemplate.update("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, 'SENT', CURRENT_TIMESTAMP)", cid, sender, "m" + i); + } + + // ══ E2:会话列表未读数 ════════════════════════════════════ + + @Test + @DisplayName("E2:无会话时返回空列表且不执行 IN () 查询") + void noConversationsShortCircuits() { + countingDataSource.reset(); + assertThat(messagingService.listConversations(me)).isEmpty(); + assertThat(countingDataSource.statements()) + .as("空会话不得执行无意义的 IN () 查询") + .isLessThanOrEqualTo(1); + } + + @Test + @DisplayName("E2:单会话无消息 → unreadCount=0") + void singleConversationWithoutMessages() { + conversation(0); + List> list = messagingService.listConversations(me); + assertThat(list).hasSize(1); + assertThat(list.get(0).get("unreadCount")).isEqualTo(0L); + } + + @Test + @DisplayName("E2(既有语义):未读 = 非 RECALLED 且该读者无 message_reads 记录,**不按发送者过滤**") + void unreadSemantics() { + Long cid = conversation(0); + message(cid, me, 1); // 本人发送:既有实现同样计入(未读查询不按 sender 过滤) + message(cid, other, 2); + message(cid, other, 3); + + List> list = messagingService.listConversations(me); + assertThat(list.get(0).get("unreadCount")) + .as("既有语义:三条消息均未被 me 读过,故全部计入;发送者不参与过滤") + .isEqualTo(3L); + } + + @Test + @DisplayName("E2(既有语义):写入 message_reads 后该消息不再计入未读") + void readMessagesAreExcluded() { + Long cid = conversation(0); + message(cid, other, 1); + message(cid, other, 2); + Long firstId = jdbcTemplate.queryForObject( + "SELECT MIN(id) FROM messages WHERE conversation_id = ?", Long.class, cid); + jdbcTemplate.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) " + + "VALUES (?, ?, CURRENT_TIMESTAMP)", firstId, me); + + List> list = messagingService.listConversations(me); + assertThat(list.get(0).get("unreadCount")).as("已读 1 条,剩 1 条").isEqualTo(1L); + } + + @Test + @DisplayName("E2(既有语义):RECALLED 消息不计未读") + void recalledMessagesAreExcluded() { + Long cid = conversation(0); + message(cid, other, 1); + message(cid, other, 2); + jdbcTemplate.update("UPDATE messages SET status = 'RECALLED' WHERE conversation_id = ? " + + "AND id = (SELECT MIN(id) FROM messages WHERE conversation_id = ?)", cid, cid); + + List> list = messagingService.listConversations(me); + assertThat(list.get(0).get("unreadCount")).as("撤回 1 条,剩 1 条").isEqualTo(1L); + } + + @Test + @DisplayName("E2:多会话分别聚合,缺失会话补 0") + void multipleConversationsAggregateSeparately() { + Long c1 = conversation(0); + Long c2 = conversation(1); + Long c3 = conversation(2); + message(c1, other, 1); + message(c1, other, 2); + message(c2, other, 3); + + List> list = messagingService.listConversations(me); + java.util.Map byConv = new java.util.HashMap<>(); + for (java.util.Map v : list) byConv.put(((Number) v.get("id")).longValue(), v.get("unreadCount")); + + assertThat(byConv.get(c1)).isEqualTo(2L); + assertThat(byConv.get(c2)).isEqualTo(1L); + assertThat(byConv.get(c3)).as("无未读会话补 0").isEqualTo(0L); + } + + private static final Map MEASURED = new LinkedHashMap<>(); + + private static void record(String key, String value) { + MEASURED.put(key, value); + try { + StringBuilder sb = new StringBuilder(); + MEASURED.forEach((k, v) -> sb.append(k).append('=').append(v).append('\n')); + java.nio.file.Files.writeString(java.nio.file.Path.of("target", "r01e-sql-counts.txt"), sb.toString()); + } catch (Exception ignored) { + // 仅诊断输出 + } + } + + @Test + @DisplayName("E2:查询数不随会话数线性增长(1 / 20 / 100)") + void unreadQueryCountIsFlat() { + int[] baseline = new int[3]; + int[] sizes = {1, 20, 100}; + for (int s = 0; s < sizes.length; s++) { + cleanup(); + companyId = null; + me = user("r01e-self"); + other = user("r01e-other"); + List cids = new ArrayList<>(); + for (int i = 0; i < sizes[s]; i++) { + Long cid = conversation(i); + cids.add(cid); + message(cid, other, i); // 每个会话 1 条未读 + } + countingDataSource.reset(); + List> list = messagingService.listConversations(me); + baseline[s] = countingDataSource.statements(); + + assertThat(list).as("size=%d", sizes[s]).hasSize(sizes[s]); + for (java.util.Map v : list) { + assertThat(v.get("unreadCount")).as("size=%d 每会话 1 条未读", sizes[s]).isEqualTo(1L); + } + } + + record("E2.conversations_1", String.valueOf(baseline[0])); + record("E2.conversations_20", String.valueOf(baseline[1])); + record("E2.conversations_100", String.valueOf(baseline[2])); + + assertThat(baseline[1]).as("20 会话的 SQL 数(%d) 不得随会话数增长 (1 会话=%d)", baseline[1], baseline[0]) + .isEqualTo(baseline[0]); + assertThat(baseline[2]).as("100 会话的 SQL 数(%d) 不得随会话数增长 (1 会话=%d)", baseline[2], baseline[0]) + .isEqualTo(baseline[0]); + } + + // ══ E1:个人数据导出批量查询 ══════════════════════════════ + + @Test + @DisplayName("E1:collect() 的 SQL 数不随记录数线性增长(1 / 10 / 50 条投递)") + void personalDataCollectQueryCountIsFlat() { + int[] measured = new int[3]; + int[] sizes = {1, 10, 50}; + + for (int s = 0; s < sizes.length; s++) { + cleanup(); + companyId = null; // cleanup 已删除公司,缓存必须一并失效 + me = user("r01e-self"); + other = user("r01e-other"); + for (int i = 0; i < sizes[s]; i++) { + Long jobId = createJob(); // (job_id, student_user_id) 唯一:每行用独立岗位 + jdbcTemplate.update("INSERT INTO applications (job_id, student_user_id, status, created_at, updated_at) " + + "VALUES (?, ?, 'SUBMITTED', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", jobId, me); + } + countingDataSource.reset(); + PersonalDataCollector.CollectResult result = personalDataCollector.collect(me); + measured[s] = countingDataSource.statements(); + + assertThat(result).isNotNull(); + } + + record("E1.applications_1", String.valueOf(measured[0])); + record("E1.applications_10", String.valueOf(measured[1])); + record("E1.applications_50", String.valueOf(measured[2])); + + assertThat(measured[1]).as("10 条投递的 SQL 数(%d) 不得随 N 增长 (1 条=%d)", measured[1], measured[0]) + .isEqualTo(measured[0]); + assertThat(measured[2]).as("50 条投递的 SQL 数(%d) 不得随 N 增长 (1 条=%d)", measured[2], measured[0]) + .isEqualTo(measured[0]); + } + + + // ===== 副作用与安全 ===== + + /** 稳定字段行级快照(仅稳定列,按主键排序),用于证明读路径零写。 */ + private Map>> snapshot() { + Map>> out = new LinkedHashMap<>(); + out.put("users", jdbcTemplate.queryForList("SELECT id, phone, nickname, role, is_active FROM users ORDER BY id")); + out.put("user_roles", jdbcTemplate.queryForList("SELECT user_id, role FROM user_roles ORDER BY user_id, role")); + out.put("conversations", jdbcTemplate.queryForList("SELECT id, type, created_by, updated_at FROM conversations ORDER BY id")); + out.put("conversation_members", jdbcTemplate.queryForList("SELECT conversation_id, user_id FROM conversation_members ORDER BY conversation_id, user_id")); + out.put("messages", jdbcTemplate.queryForList("SELECT id, conversation_id, sender_user_id, status FROM messages ORDER BY id")); + out.put("message_reads", jdbcTemplate.queryForList("SELECT id, message_id, reader_user_id FROM message_reads ORDER BY id")); + out.put("notifications", jdbcTemplate.queryForList("SELECT id, user_id, type, title FROM notifications ORDER BY id")); + out.put("audit_logs", jdbcTemplate.queryForList("SELECT id, user_id, action, entity_type FROM audit_logs ORDER BY id")); + out.put("auth_tokens", jdbcTemplate.queryForList("SELECT id, user_id, token_type, revoked FROM auth_tokens ORDER BY id")); + out.put("applications", jdbcTemplate.queryForList("SELECT id, job_id, student_user_id, status FROM applications ORDER BY id")); + out.put("job_favorites", jdbcTemplate.queryForList("SELECT id, job_id, student_user_id FROM job_favorites ORDER BY id")); + return out; + } + + private void assertNoChange(Map>> before, + Map>> after, String what) { + for (String table : before.keySet()) { + assertThat(after.get(table)).as("%s 期间 %s 必须逐字段零变化", what, table).isEqualTo(before.get(table)); + } + } + + @Test + @DisplayName("副作用:会话列表 GET 为纯读,11 张相关表逐字段零变化") + void listConversationsIsReadOnly() { + Long cid = conversation(0); + message(cid, other, 1); + message(cid, me, 2); + + Map>> before = snapshot(); + List> list = messagingService.listConversations(me); + Map>> after = snapshot(); + + assertThat(list).hasSize(1); + assertNoChange(before, after, "listConversations"); + } + + @Test + @DisplayName("副作用:collector.collect() 不写业务表;不新增通知与审计") + void personalDataCollectIsReadOnly() { + Long jobId = createJob(); + jdbcTemplate.update("INSERT INTO applications (job_id, student_user_id, status, created_at, updated_at) " + + "VALUES (?, ?, 'SUBMITTED', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", jobId, me); + + Integer notificationsBefore = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notifications", Integer.class); + Integer auditsBefore = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM audit_logs", Integer.class); + Map>> before = snapshot(); + + PersonalDataCollector.CollectResult result = personalDataCollector.collect(me); + + assertThat(result).isNotNull(); + assertNoChange(before, snapshot(), "collect"); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notifications", Integer.class)) + .as("不新增通知").isEqualTo(notificationsBefore); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM audit_logs", Integer.class)) + .as("不新增审计").isEqualTo(auditsBefore); + } + + @Test + @DisplayName("安全:不泄漏其他用户数据;不属于任何会话的用户看不到该会话") + void noCrossUserLeakage() { + Long cid = conversation(0); + message(cid, other, 1); + + List> list = messagingService.listConversations(me); + assertThat(list).as("me 只看到自己的 1 个会话").hasSize(1); + assertThat(list.get(0).get("unreadCount")).isEqualTo(1L); + + Long outsider = user("r01e-outsider"); + assertThat(messagingService.listConversations(outsider)).as("非参与者看不到该会话").isEmpty(); + } + + @TestConfiguration + static class CountingDataSourceConfiguration { + @Bean + @Primary + CountingDataSource dataSource(DataSourceProperties properties) { + return new CountingDataSource(properties.initializeDataSourceBuilder().build()); + } + } + + /** 只包一层、统计真实 JDBC 语句数的 DataSource(仅测试源)。 */ + static final class CountingDataSource extends org.springframework.jdbc.datasource.AbstractDataSource { + private final DataSource delegate; + private final AtomicInteger statements = new AtomicInteger(); + + CountingDataSource(DataSource delegate) { + this.delegate = delegate; + } + + void reset() { + statements.set(0); + } + + int statements() { + return statements.get(); + } + + @Override public Connection getConnection() throws java.sql.SQLException { + return wrap(delegate.getConnection()); + } + + @Override public Connection getConnection(String u, String p) throws java.sql.SQLException { + return wrap(delegate.getConnection(u, p)); + } + + private Connection wrap(Connection c) { + return (Connection) java.lang.reflect.Proxy.newProxyInstance( + Connection.class.getClassLoader(), new Class[]{Connection.class}, + (proxy, method, args) -> { + if ("prepareStatement".equals(method.getName()) || "createStatement".equals(method.getName())) { + statements.incrementAndGet(); + } + return method.invoke(c, args); + }); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java index 5668cc8..26cfcaa 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java @@ -1,6 +1,9 @@ package com.mydreamjob.company; +import com.mydreamjob.auth.AuthSessionService; import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.security.CurrentUserAccessor; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -15,7 +18,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @WebMvcTest(CompanyController.class) @AutoConfigureMockMvc(addFilters = false) -@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class}) +@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class, + AuthSessionService.class, java.time.Clock.class, + RequestIdAccessor.class, CurrentUserAccessor.class}) class CompanyControllerMvcTest { @Autowired @@ -31,4 +36,4 @@ class CompanyControllerMvcTest { .andExpect(jsonPath("$.code").value(1001)) .andExpect(jsonPath("$.message").value("bad request")); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java index e25854e..212dda0 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java @@ -2,6 +2,8 @@ package com.mydreamjob.company; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.dto.CompanyCreateRequest; @@ -28,6 +30,9 @@ class CompanyServiceTest { @Mock private UserRepository userRepository; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private CompanyService companyService; @@ -37,6 +42,9 @@ class CompanyServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + company = Company.builder() .id(1L).ownerUserId(COMPANY_USER_ID) .name("测试企业").description("一家科技公司") diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java index e710892..de32825 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java @@ -4,6 +4,7 @@ import com.mydreamjob.application.Application; import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.candidatescore.CandidateOverviewStatusPolicy; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -151,7 +152,7 @@ class CompetitionRankingCalculatorTest { @Test void overviewStatus_emptyIsInsufficientData() { - assertEquals("INSUFFICIENT_DATA", calculator.overviewStatus(List.of())); + assertEquals("INSUFFICIENT_DATA", CandidateOverviewStatusPolicy.fromSnapshots(List.of())); } private Application app(Long sid, String created) { @@ -165,4 +166,4 @@ class CompetitionRankingCalculatorTest { .score(new BigDecimal("80")).status("OK") .weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1").build(); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingControllerTest.java index 381047b..1936aaa 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingControllerTest.java @@ -180,8 +180,7 @@ class CompetitionRankingControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } // ── 权限矩阵 ────────────────────────────────────────────── @@ -321,7 +320,6 @@ class CompetitionRankingControllerTest { } private String tokenWithRole(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } } \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java index bdbe060..1482df0 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java @@ -4,6 +4,8 @@ import com.mydreamjob.application.Application; import com.mydreamjob.application.ApplicationRepository; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.common.exception.BusinessException; @@ -12,6 +14,7 @@ import com.mydreamjob.competition.dto.CompetitionRankingResponse; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobService; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -42,6 +45,11 @@ import static org.mockito.Mockito.*; @MockitoSettings(strictness = Strictness.LENIENT) class CompetitionRankingServiceTest { + @BeforeEach + void wireRoleGuard() { + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + } + private static final Long ME = 100L; private static final Long JOB = 9L; @@ -51,6 +59,9 @@ class CompetitionRankingServiceTest { @Mock private CompetitionRankingCalculator rankingCalculator; @Mock private CandidateScoringCalculator candidateScoringCalculator; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private CompetitionRankingService service; // ── 权限与参数校验 ───────────────────────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/config/ActuatorIsolationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/config/ActuatorIsolationIntegrationTest.java new file mode 100644 index 0000000..1792938 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/config/ActuatorIsolationIntegrationTest.java @@ -0,0 +1,317 @@ +package com.mydreamjob.config; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.support.TestAuthTokens; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * S03-C Actuator 隔离:业务端口不提供指标,管理端口只绑定配置地址,健康探针最小化。 + * + *

    真实启动两个端口(业务随机端口 + 管理随机端口),用真实 HTTP 验证。

    + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "management.server.port=0", + "management.server.address=127.0.0.1" + }) +@ActiveProfiles("integration-test") +class ActuatorIsolationIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(8700); + + @MockBean + private RedisTemplate redisTemplate; + + @LocalServerPort + private int businessPort; + + @LocalManagementPort + private int managementPort; + + @Value("${management.server.address}") + private String managementAddress; + + @Value("${management.endpoint.health.show-details}") + private String showDetails; + + @Value("${management.endpoint.health.show-components}") + private String showComponents; + + @Autowired + private UserRepository userRepository; + + private final HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)).build(); + private Long studentId; + private Long companyId; + private Long adminId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + studentId = createUser(User.Role.STUDENT); + companyId = createUser(User.Role.COMPANY); + adminId = createUser(User.Role.ADMIN); + } + + @AfterEach + void tearDown() { + for (Long id : new Long[]{studentId, companyId, adminId}) { + if (id != null) { + userRepository.deleteById(id); + } + } + } + + private Long createUser(User.Role role) { + return userRepository.save(User.builder() + .phone("16" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("S03 actuator") + .role(role).roles(Set.of(role)) + .active(true).verified(true).build()).getId(); + } + + private int get(int port, String path, String token) { + try { + HttpRequest.Builder b = HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + path)) + .timeout(Duration.ofSeconds(10)).GET(); + if (token != null) { + b.header("Authorization", "Bearer " + token); + } + return client.send(b.build(), HttpResponse.BodyHandlers.ofString()).statusCode(); + } catch (Exception e) { + return -1; + } + } + + private String body(int port, String path) { + return rawGet(port, path, null).body(); + } + + /** 一次真实 HTTP GET 的结果;连接层失败记为 status=-1,让断言明确失败而非抛异常。 */ + private record Probe(int status, String body, String contentType) { + } + + private Probe rawGet(int port, String path, String token) { + try { + HttpRequest.Builder b = HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + path)) + .timeout(Duration.ofSeconds(10)).GET(); + if (token != null) { + b.header("Authorization", "Bearer " + token); + } + HttpResponse r = client.send(b.build(), HttpResponse.BodyHandlers.ofString()); + return new Probe(r.statusCode(), r.body(), + r.headers().firstValue("Content-Type").orElse("")); + } catch (Exception e) { + return new Probe(-1, "", ""); + } + } + + // ── 业务端口 ─────────────────────────────────────────────── + + @Test + void businessPortDoesNotExposeActuatorEndpoints() { + for (String path : new String[]{"/actuator/metrics", "/actuator/prometheus", + "/actuator/health", "/actuator/info", "/actuator"}) { + assertThat(get(businessPort, path, null)) + .as("业务端口不得提供 " + path).isEqualTo(404); + } + } + + @Test + void businessTokensCannotRevealMetricsOnBusinessPort() { + String[] tokens = { + TestAuthTokens.bearer(studentId, "STUDENT"), + TestAuthTokens.bearer(companyId, "COMPANY"), + TestAuthTokens.bearer(adminId, "ADMIN"), + }; + for (String token : tokens) { + for (String path : new String[]{"/actuator/metrics", "/actuator/prometheus"}) { + assertThat(get(businessPort, path, token)) + .as("业务 JWT 不得让指标出现在业务端口:" + path).isEqualTo(404); + } + } + } + + @Test + void businessHealthProbeStaysMinimal() throws Exception { + // 业务健康检查走自定义 /health 控制器,只含最小状态。 + // 按**字段名**断言,而不是对原始 JSON 做子串匹配 —— 随机 requestId 可能恰好包含 + // "db" 之类的子串,会造成假失败。 + String body = body(businessPort, "/api/v1/health"); + var data = new com.fasterxml.jackson.databind.ObjectMapper().readTree(body).path("data"); + + assertThat(data.path("status").asText()).isNotBlank(); + var fields = new java.util.ArrayList(); + data.fieldNames().forEachRemaining(fields::add); + for (String forbidden : new String[]{"db", "redis", "rabbit", "rabbitmq", "diskSpace", + "hikari", "connectionPool", "components", "details"}) { + assertThat(fields).as("业务健康响应不得含字段 " + forbidden).doesNotContain(forbidden); + } + } + + // ── 管理端口 ─────────────────────────────────────────────── + + @Test + void managementPortServesHealthAndMetrics() throws Exception { + String token = TestAuthTokens.bearer(adminId, "ADMIN"); + + // health:允许 200,或因外围依赖不可用而 503;但必须是合法 JSON 且含 status。 + Probe health = rawGet(managementPort, "/actuator/health", null); + assertThat(health.status()).as("health 必须是 200 或 503(实测 " + health.status() + ")") + .isIn(200, 503); + var healthJson = new com.fasterxml.jackson.databind.ObjectMapper().readTree(health.body()); + assertThat(healthJson.has("status")).as("health 响应必须含 status 字段").isTrue(); + + // metrics:必须 200 且返回 names 列表;-1/401/403/404/500 均不算成功。 + Probe metrics = rawGet(managementPort, "/actuator/metrics", token); + assertThat(metrics.status()).as("metrics 必须 HTTP 200(实测 " + metrics.status() + ")") + .isEqualTo(200); + var metricsJson = new com.fasterxml.jackson.databind.ObjectMapper().readTree(metrics.body()); + assertThat(metricsJson.has("names")).as("metrics 响应必须包含 names").isTrue(); + assertThat(metricsJson.get("names").isArray()).as("names 必须是数组").isTrue(); + + // prometheus:必须 200,Content-Type 为 Prometheus 文本格式,正文含稳定指标名。 + Probe prom = rawGet(managementPort, "/actuator/prometheus", token); + assertThat(prom.status()).as("prometheus 必须 HTTP 200(实测 " + prom.status() + ")") + .isEqualTo(200); + assertThat(prom.contentType()).as("prometheus Content-Type 必须为文本格式(实测 " + prom.contentType() + ")") + .containsIgnoringCase("text/plain"); + assertThat(prom.body()).as("prometheus 正文必须含稳定指标名") + .contains("jvm_memory_used_bytes"); + } + + @Test + void managementPortIsIsolatedByNetworkNotByBusinessCredentials() { + // 设计决策:管理端口的运维边界是**网络隔离**(默认只绑定 127.0.0.1), + // 而不是业务 JWT 角色。因此指标在管理端口无需凭据即可读取, + // 而在业务端口上同一路径根本不存在。 + assertThat(rawGet(managementPort, "/actuator/metrics", null).status()) + .as("管理端口上 metrics 由网络隔离保护,无需业务凭据").isEqualTo(200); + assertThat(get(businessPort, "/actuator/metrics", null)) + .as("业务端口不得提供同一端点").isEqualTo(404); + } + + @Test + void managementHealthReturnsMinimalStatusOnly() { + String body = body(managementPort, "/actuator/health"); + + assertThat(body).as("健康响应必须含 status 字段").contains("status"); + // 组件与细节一律不暴露:show-details=never / show-components=never 的实际效果 + assertThat(body).as("不得返回组件明细") + .doesNotContain("components") + .doesNotContain("diskSpace") + .doesNotContain("hikari") + .doesNotContain("connectionPool") + .doesNotContain("mydreamjob-mysql"); + } + + @Test + void onlyWhitelistedEndpointsAreExposed() { + // 非白名单端点不得可用:既可能因未暴露而 404,也可能被安全层先行拦为 401, + // 两者都证明「不可读」。 + for (String path : new String[]{"/actuator/env", "/actuator/beans", + "/actuator/configprops", "/actuator/heapdump", "/actuator/threaddump"}) { + int code = get(managementPort, path, null); + assertThat(code).as(path + " 不得可读(实测 " + code + ")").isIn(401, 404); + } + } + + // ── 配置断言 ─────────────────────────────────────────────── + + @Test + void managementBindsLoopbackAndHealthDetailsAreNever() { + assertThat(managementAddress).as("管理端口默认只绑定回环地址").isEqualTo("127.0.0.1"); + assertThat(showDetails).isEqualTo("never"); + assertThat(showComponents).isEqualTo("never"); + } + + @Test + void managementPortDiffersFromBusinessPort() { + assertThat(managementPort).as("管理端口必须与业务端口分离").isNotEqualTo(businessPort); + } + + // ── S03 整改复验:业务端口 /actuator/** 任何身份一律 404 ────────── + + @Test + void businessActuatorPathsReturn404ForEveryIdentity() { + String[] paths = { + "/actuator", "/actuator/", "/actuator/health", "/actuator/health/liveness", + "/actuator/info", "/actuator/metrics", "/actuator/metrics/jvm.memory.used", + "/actuator/prometheus", "/actuator/env", "/actuator/beans", + "/actuator/configprops", "/actuator/heapdump", "/actuator/threaddump", + "/actuator/unknown" + }; + String[] identities = { + null, + TestAuthTokens.bearer(studentId, "STUDENT"), + TestAuthTokens.bearer(companyId, "COMPANY"), + TestAuthTokens.bearer(adminId, "ADMIN"), + "Bearer invalid.garbage.token.value", + "Bearer " + TestAuthTokens.refreshToken(studentId, "STUDENT"), + }; + for (String path : paths) { + for (String auth : identities) { + Probe p = rawGet(businessPort, path, auth); + assertThat(p.status()) + .as("8080 %s(身份=%s)必须 404,不得出现 401/403/500", + path, auth == null ? "anonymous" : auth.substring(0, Math.min(11, auth.length())) + "...") + .isEqualTo(404); + assertNoLeak(p.body()); + } + } + } + + /** 递归扫描响应:任何异常/实现细节/路径/敏感信息都不得出现。 */ + private static void assertNoLeak(String body) { + String normalized = body == null ? "" : body; + assertThat(normalized).as("不得包含异常类名/静态资源提示/框架包名") + .doesNotContain("NoResourceFoundException", "No static resource", "org.springframework", + "classpath", "stackTrace", "exception", "handler"); + assertThat(normalized).as("不得包含本地路径").doesNotContain("C:", "\\Users", "\\Desktop"); + assertThat(normalized.toLowerCase()).as("不得包含 Token/凭据/数据库信息") + .doesNotContain("token", "password", "jdbc"); + assertThat(normalized).as("不得包含 11 位连续数字(手机号)").doesNotMatch("\\d{11}"); + } + + @Test + void managementUnexposedEndpointsReturn404() { + for (String path : new String[]{"/actuator/env", "/actuator/beans", "/actuator/configprops", + "/actuator/heapdump", "/actuator/threaddump", "/actuator/unknown"}) { + int code = get(managementPort, path, null); + assertThat(code).as("8081 %s 必须 404(未暴露端点,不得 401/200/403/500)", path).isEqualTo(404); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java new file mode 100644 index 0000000..4369cc4 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java @@ -0,0 +1,186 @@ +package com.mydreamjob.dataexport; + +import com.mydreamjob.application.*; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.candidatescore.governance.*; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.feedback.*; +import com.mydreamjob.interview.*; +import com.mydreamjob.job.JobFavoriteRepository; +import com.mydreamjob.messaging.*; +import com.mydreamjob.notification.NotificationRepository; +import com.mydreamjob.offer.*; +import com.mydreamjob.preference.StudentPreferenceRepository; +import com.mydreamjob.profile.StudentProfileRepository; +import com.mydreamjob.profile.storage.ResumeStorageService; +import com.mydreamjob.resume.ResumeVersionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; +import java.util.stream.LongStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PersonalDataCollectorQueryBudgetTest { + + @Mock UserRepository userRepository; + @Mock StudentProfileRepository studentProfileRepository; + @Mock StudentPreferenceRepository studentPreferenceRepository; + @Mock ResumeVersionRepository resumeVersionRepository; + @Mock ApplicationRepository applicationRepository; + @Mock ApplicationStatusHistoryRepository applicationStatusHistoryRepository; + @Mock InterviewRepository interviewRepository; + @Mock InterviewStatusHistoryRepository interviewStatusHistoryRepository; + @Mock OfferRepository offerRepository; + @Mock OfferStatusHistoryRepository offerStatusHistoryRepository; + @Mock CandidateFeedbackRepository candidateFeedbackRepository; + @Mock JobFavoriteRepository jobFavoriteRepository; + @Mock NotificationRepository notificationRepository; + @Mock ConversationMemberRepository conversationMemberRepository; + @Mock ConversationRepository conversationRepository; + @Mock MessageRepository messageRepository; + @Mock CandidateDimensionScoreRepository candidateDimensionScoreRepository; + @Mock CandidateScoringAppealRepository candidateScoringAppealRepository; + @Mock CandidateScoringAppealHistoryRepository candidateScoringAppealHistoryRepository; + @Mock CompanyRepository companyRepository; + @Mock ResumeStorageService resumeStorageService; + + @InjectMocks PersonalDataCollector collector; + + @BeforeEach + void stubUnrelatedSections() { + when(userRepository.findById(anyLong())).thenReturn(Optional.empty()); + when(studentProfileRepository.findByUserId(anyLong())).thenReturn(Optional.empty()); + when(studentPreferenceRepository.findByStudentUserId(anyLong())).thenReturn(Optional.empty()); + when(resumeVersionRepository.findByUserIdAndStatusOrderByVersionNoDesc(anyLong(), anyString())) + .thenReturn(List.of()); + lenient().when(interviewRepository.findByStudentUserIdOrderByStartAtDesc(anyLong(), any(Pageable.class))) + .thenReturn(Page.empty()); + when(interviewRepository.findByCompanyUserIdOrderByStartAtDesc(anyLong(), any(Pageable.class))) + .thenReturn(Page.empty()); + when(interviewStatusHistoryRepository.findByInterviewIdInOrderByInterviewIdAscCreatedAtAsc(anyList())) + .thenReturn(List.of()); + lenient().when(offerRepository.findByCandidateIdOrderByCreatedAtDesc(anyLong(), any(Pageable.class))) + .thenReturn(Page.empty()); + when(companyRepository.findByOwnerUserId(anyLong())).thenReturn(Optional.empty()); + when(offerStatusHistoryRepository.findByOfferIdInOrderByOfferIdAscCreatedAtAsc(anyList())) + .thenReturn(List.of()); + when(jobFavoriteRepository.findByStudentUserIdOrderByCreatedAtDesc(anyLong(), any(Pageable.class))) + .thenReturn(Page.empty()); + when(notificationRepository.findByUserIdOrderByCreatedAtDesc(anyLong(), any(Pageable.class))) + .thenReturn(Page.empty()); + when(conversationMemberRepository.findByUserId(anyLong())).thenReturn(List.of()); + when(conversationRepository.findAllById(anyList())).thenReturn(List.of()); + when(messageRepository.findByConversationIdInOrderByConversationIdAscCreatedAtDesc(anyList())) + .thenReturn(List.of()); + when(candidateDimensionScoreRepository.findByStudentUserId(anyLong())).thenReturn(List.of()); + lenient().when(candidateScoringAppealRepository.findByCandidateUserIdOrderByCreatedAtDesc( + anyLong(), any(Pageable.class))).thenReturn(Page.empty()); + when(candidateScoringAppealHistoryRepository.findByAppealIdInOrderByAppealIdAscCreatedAtAsc(anyList())) + .thenReturn(List.of()); + } + + @Test + void applicationQueriesStayFixedFromOneToFiftyRows() { + assertFixedApplicationQueries(1); + clearInvocations(applicationRepository, applicationStatusHistoryRepository, candidateFeedbackRepository); + assertFixedApplicationQueries(50); + } + + @Test + void batchResultsPreserveParentOrderAndTimelineOrder() { + Application newer = application(20L); + Application older = application(10L); + when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(7L)) + .thenReturn(List.of(newer, older)); + when(applicationStatusHistoryRepository.findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc(anyList())) + .thenReturn(List.of(history(10L, "A"), history(10L, "B"), history(20L, "C"))); + when(candidateFeedbackRepository.findByApplicationIdIn(anyList())).thenReturn(List.of()); + + PersonalDataCollector.CollectResult result = collector.collect(7L); + + List applications = (List) result.getSections().get("applications"); + List timelines = (List) result.getSections().get("application-timelines"); + assertEquals(20L, ((java.util.Map) applications.get(0)).get("applicationId")); + assertEquals(10L, ((java.util.Map) applications.get(1)).get("applicationId")); + assertEquals("C", ((java.util.Map) timelines.get(0)).get("toStatus")); + assertEquals("A", ((java.util.Map) timelines.get(1)).get("toStatus")); + assertEquals("B", ((java.util.Map) timelines.get(2)).get("toStatus")); + } + + @Test + void everyChildCollectionUsesOneBatchQueryInsteadOfPerParentQueries() { + when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(7L)).thenReturn(List.of()); + when(applicationStatusHistoryRepository.findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc(anyList())) + .thenReturn(List.of()); + when(candidateFeedbackRepository.findByApplicationIdIn(anyList())).thenReturn(List.of()); + when(interviewRepository.findByStudentUserIdOrderByStartAtDesc(eq(7L), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of( + Interview.builder().id(1L).build(), Interview.builder().id(2L).build()))); + when(offerRepository.findByCandidateIdOrderByCreatedAtDesc(eq(7L), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of( + Offer.builder().id(3L).build(), Offer.builder().id(4L).build()))); + when(conversationMemberRepository.findByUserId(7L)).thenReturn(List.of( + ConversationMember.builder().conversationId(5L).userId(7L).build(), + ConversationMember.builder().conversationId(6L).userId(7L).build())); + when(candidateScoringAppealRepository.findByCandidateUserIdOrderByCreatedAtDesc( + eq(7L), any(Pageable.class))).thenReturn(new PageImpl<>(List.of( + CandidateScoringAppeal.builder().id(7L).build(), + CandidateScoringAppeal.builder().id(8L).build()))); + + collector.collect(7L); + + verify(interviewStatusHistoryRepository, times(1)) + .findByInterviewIdInOrderByInterviewIdAscCreatedAtAsc(anyList()); + verify(offerStatusHistoryRepository, times(1)) + .findByOfferIdInOrderByOfferIdAscCreatedAtAsc(anyList()); + verify(conversationRepository, times(1)).findAllById(anyList()); + verify(messageRepository, times(1)) + .findByConversationIdInOrderByConversationIdAscCreatedAtDesc(anyList()); + verify(candidateScoringAppealHistoryRepository, times(1)) + .findByAppealIdInOrderByAppealIdAscCreatedAtAsc(anyList()); + verify(interviewStatusHistoryRepository, never()).findByInterviewIdOrderByCreatedAtAsc(anyLong()); + verify(offerStatusHistoryRepository, never()).findByOfferIdOrderByCreatedAtAsc(anyLong()); + verify(conversationRepository, never()).findById(anyLong()); + verify(messageRepository, never()).findByConversationIdOrderByCreatedAtDesc(anyLong(), any()); + verify(candidateScoringAppealHistoryRepository, never()).findByAppealIdOrderByCreatedAtAsc(anyLong()); + } + + private void assertFixedApplicationQueries(int count) { + List applications = LongStream.rangeClosed(1, count).mapToObj(this::application).toList(); + when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(7L)).thenReturn(applications); + when(applicationStatusHistoryRepository.findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc(anyList())) + .thenReturn(List.of()); + when(candidateFeedbackRepository.findByApplicationIdIn(anyList())).thenReturn(List.of()); + + collector.collect(7L); + + verify(applicationRepository, times(1)).findByStudentUserIdOrderByCreatedAtDesc(7L); + verify(applicationStatusHistoryRepository, times(1)) + .findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc(anyList()); + verify(candidateFeedbackRepository, times(1)).findByApplicationIdIn(anyList()); + verify(applicationStatusHistoryRepository, never()).findByApplicationIdOrderByCreatedAtAsc(anyLong()); + verify(candidateFeedbackRepository, never()).findByApplicationId(anyLong()); + } + + private Application application(long id) { + return Application.builder().id(id).studentUserId(7L).jobId(100L + id).status("SUBMITTED").build(); + } + + private ApplicationStatusHistory history(long applicationId, String toStatus) { + return ApplicationStatusHistory.builder().applicationId(applicationId).toStatus(toStatus).build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportControllerTest.java index 89f628d..9c35b59 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportControllerTest.java @@ -94,8 +94,7 @@ class PersonalDataExportControllerTest { } private String token(long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken( - userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } private PersonalDataExportTask pendingTask(Long userId) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java index 842fbaa..8d0996e 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java @@ -52,7 +52,18 @@ class AccountDeletionControllerTest { @MockBean private org.springframework.data.redis.core.RedisTemplate redisTemplate; private final ObjectMapper mapper = new ObjectMapper(); - private static final AtomicLong PHONE = new AtomicLong(930_000_000L); + + /** + * 测试专属手机号段(987_000_000 起)。 + * + *

    隔离修复:此前使用 930_000_000,与 {@code common.logging.RequestContextMigrationIntegrationTest} + * 撞段;后者在共享 H2 库(integrationdb)中残留该号码,使本类首次 {@code createUser()} + * 触发 {@code USERS(PHONE)} 唯一键冲突。改用独占号段后,本类不再依赖他类的清理行为。

    + */ + private static final AtomicLong PHONE = new AtomicLong(987_000_000L); + + /** 本测试创建的 userId,仅用于按外键顺序精确清理,不触碰其他测试的数据。 */ + private final java.util.List createdUserIds = new java.util.ArrayList<>(); @BeforeEach @SuppressWarnings("unchecked") @@ -61,29 +72,39 @@ class AccountDeletionControllerTest { mock(org.springframework.data.redis.core.ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(anyString())).thenReturn(false); + createdUserIds.clear(); } @AfterEach void tearDown() { - // 清理共享 H2 库(integrationdb)中的注销记录与测试用户,避免残留 PENDING/COMPLETED - // 污染后续其他集成测试类(其使用固定 userId 触发注销守卫)。 - deletionRepository.deleteAll(); - userRepository.deleteAll(); + // 精确清理本测试创建的数据(仅本测试记录,不整表删除): + // account_deletions → user_roles → users,遵循外键依赖顺序。 + for (Long uid : createdUserIds) { + deletionRepository.findByUserId(uid).ifPresent(deletionRepository::delete); + deletionRepository.flush(); + userRepository.findById(uid).ifPresent(userRepository::delete); + userRepository.flush(); + } + createdUserIds.clear(); } private long createUser(User.Role role) { + String phone = String.valueOf(PHONE.getAndIncrement()); + // 防掩盖:本测试号段内不得已存在同号记录(若存在说明清理失效,应立即暴露而非被唯一键吞掉)。 + assertFalse(userRepository.findByPhone(phone).isPresent(), + "测试夹具手机号 " + phone + " 已存在于共享库,说明上轮清理失效"); User u = userRepository.save(User.builder() - .phone(String.valueOf(PHONE.getAndIncrement())) + .phone(phone) .roles(Set.of(role)) .role(role) .active(true) .build()); + createdUserIds.add(u.getId()); return u.getId(); } private String token(long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken( - userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } @Test @@ -243,10 +264,11 @@ class AccountDeletionControllerTest { assertEquals(DeletionStatus.COMPLETED, deletionRepository.findByUserId(uid).orElseThrow().getStatus()); - // 第二次 GET 仍应为 COMPLETED,不能出现"第一次 PENDING、第二次 COMPLETED"的不一致 + // S02 起:注销完成会脱敏(active=false)并撤销该用户全部会话, + // 因此用同一账号再次请求必须被拒绝为 401,而不再是 COMPLETED 的 200。 + // 这不影响本用例的一致性目标 —— 状态一致仍由上方的数据库断言保证。 mockMvc.perform(get("/profile/me/deletion").header("Authorization", token(uid))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("COMPLETED")); + .andExpect(status().isUnauthorized()); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionFixtureIsolationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionFixtureIsolationTest.java new file mode 100644 index 0000000..2f14717 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionFixtureIsolationTest.java @@ -0,0 +1,176 @@ +package com.mydreamjob.deletion; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 账号注销测试夹具隔离的**反证测试**。 + * + *

    证明隔离修复确实有效,且"残留检测"不是空转:

    + *
      + *
    1. 同一 Spring 上下文内连续创建多个夹具用户不冲突;
    2. + *
    3. 测试结束后本测试创建的 users / user_roles / account_deletions 残留为 0;
    4. + *
    5. **变异探测**:若库中预先存在同号记录(等价于"上一轮未清理"), + * 夹具的防掩盖断言必须失败 —— 即残留**不会**被静默吞掉。
    6. + *
    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AccountDeletionFixtureIsolationTest { + + @Autowired private UserRepository userRepository; + @Autowired private AccountDeletionRepository deletionRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + @MockBean private RedisTemplate redisTemplate; + + /** 与 AccountDeletionControllerTest 相同的独占号段,用于验证该号段本身不与他类冲突。 */ + private static final AtomicLong PHONE = new AtomicLong(987_000_000L); + private static final AtomicLong PROBE_PHONE = new AtomicLong(989_000_000L); + + private final List createdUserIds = new ArrayList<>(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + createdUserIds.clear(); + } + + @AfterEach + void tearDown() { + for (Long uid : createdUserIds) { + deletionRepository.findByUserId(uid).ifPresent(deletionRepository::delete); + deletionRepository.flush(); + userRepository.findById(uid).ifPresent(userRepository::delete); + userRepository.flush(); + } + createdUserIds.clear(); + } + + private long createFixture() { + String phone = String.valueOf(PHONE.getAndIncrement()); + // 与生产夹具同样的防掩盖断言:号段内不得已存在同号记录 + assertThat(userRepository.findByPhone(phone)).as("夹具号 " + phone + " 不应预先存在").isEmpty(); + User u = userRepository.save(User.builder() + .phone(phone).roles(Set.of(User.Role.STUDENT)).role(User.Role.STUDENT) + .active(true).build()); + createdUserIds.add(u.getId()); + return u.getId(); + } + + @Test + @DisplayName("反证 1:同一上下文内连续创建 5 个夹具不冲突") + void repeatedFixtureCreation_doesNotConflict() { + List ids = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + ids.add(createFixture()); + } + assertThat(ids).doesNotHaveDuplicates().hasSize(5); + } + + @Test + @DisplayName("反证 2:注销记录写入后,本测试清理使三类记录残留为 0") + void afterCleanup_noResidueForOwnFixtures() { + long uid = createFixture(); + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.PENDING).version(0L).build()); + + assertThat(deletionRepository.findByUserId(uid)).isPresent(); + + // 手动执行与 @AfterEach 相同的清理路径 + deletionRepository.findByUserId(uid).ifPresent(deletionRepository::delete); + deletionRepository.flush(); + userRepository.findById(uid).ifPresent(userRepository::delete); + userRepository.flush(); + createdUserIds.remove(uid); + + assertThat(deletionRepository.findByUserId(uid)).as("account_deletions 残留").isEmpty(); + assertThat(userRepository.findById(uid)).as("users 残留").isEmpty(); + Integer roles = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_roles WHERE user_id=?", Integer.class, uid); + assertThat(roles).as("user_roles 残留(应随 users 级联删除)").isZero(); + } + + @Test + @DisplayName("反证 3:变异探测 —— 预置同号记录时防掩盖断言必须失败") + void whenResidueExists_detectorFails() { + // 模拟"上一轮未清理":直接插入一条占用号段首号的记录 + long residueId = userRepository.save(User.builder() + .phone("987000000").roles(Set.of(User.Role.STUDENT)).role(User.Role.STUDENT) + .active(true).build()).getId(); + try { + AtomicLong probe = new AtomicLong(987_000_000L); + assertThatThrownBy(() -> { + String phone = String.valueOf(probe.getAndIncrement()); + if (userRepository.findByPhone(phone).isPresent()) { + throw new AssertionError("夹具号 " + phone + " 已存在,说明上轮清理失效"); + } + }).as("残留必须被检测到,而不是被唯一键静默吞掉") + .isInstanceOf(AssertionError.class) + .hasMessageContaining("987000000"); + } finally { + userRepository.findById(residueId).ifPresent(userRepository::delete); + userRepository.flush(); + } + } + + @Test + @DisplayName("反证 4:本测试独占号段与 AccountDeletionControllerTest 号段一致且不与他类重叠") + void fixtureSegmentIsExclusive() { + // 987_000_000 段应只被 deletion 包下的测试使用 + Integer others = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone >= '987000000' AND phone < '988000000'", + Integer.class); + assertThat(others).as("测试开始前 987 段应为空").isZero(); + } + + @Test + @DisplayName("反证 5:夹具用户被其他表引用时仍能按其自身记录精确清理(不整表删除)") + void cleanupIsScopedToOwnFixtures() { + long mine = createFixture(); + // 预置一条"他类"记录(不同号段),清理本测试不得触碰它 + long foreignId = userRepository.save(User.builder() + .phone(String.valueOf(PROBE_PHONE.getAndIncrement())) + .roles(Set.of(User.Role.STUDENT)).role(User.Role.STUDENT).active(true).build()).getId(); + try { + deletionRepository.findByUserId(mine).ifPresent(deletionRepository::delete); + userRepository.findById(mine).ifPresent(userRepository::delete); + userRepository.flush(); + createdUserIds.remove(mine); + + assertThat(userRepository.findById(mine)).as("本测试记录应被清理").isEmpty(); + assertThat(userRepository.findById(foreignId)) + .as("非本测试记录必须保留(证明清理是精确的,不是整表删除)").isPresent(); + } finally { + userRepository.findById(foreignId).ifPresent(userRepository::delete); + userRepository.flush(); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java index 6218e09..bb796bd 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java @@ -11,6 +11,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -22,6 +23,7 @@ import java.util.concurrent.atomic.AtomicLong; import static org.mockito.ArgumentMatchers.anyString; 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.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; @@ -76,8 +78,7 @@ class DeletionGuardInterceptorTest { } private String token(long uid) { - return "Bearer " + jwtTokenProvider.generateAccessToken( - uid, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(uid, "STUDENT"); } private void setStatus(long uid, DeletionStatus status, LocalDateTime effectiveAt) { @@ -163,6 +164,25 @@ class DeletionGuardInterceptorTest { .andExpect(status().isUnauthorized()); } + /** + * R01-A 回归:本拦截器注册在 {@code /**} 上,先于所有 Controller 执行, + * 因此非法主体绝不能在拦截器层逃逸为 500。 + * + *

    修复前 {@code Long.valueOf(auth.getName())} 对 Long 溢出主体( + * {@code "99999999999999999999"})抛出 {@code NumberFormatException}, + * 在到达任何 Controller 之前就返回全站 HTTP 500。

    + */ + @Test + void illegalOrOverflowingPrincipal_returns401InsteadOf500() throws Exception { + for (String principal : new String[]{ + "99999999999999999999", "٤٢", "+42", "-1", "0", " ", "not-a-number"}) { + mockMvc.perform(get("/profile/me/privacy") + .with(authentication(UsernamePasswordAuthenticationToken.authenticated( + principal, null, java.util.List.of())))) + .andExpect(status().isUnauthorized()); + } + } + @Test void options_isAllowed() throws Exception { long uid = createStudent(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java index 29cd558..5c33203 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java @@ -43,7 +43,7 @@ class CandidateFeedbackControllerTest { } private String token() { - return "Bearer " + jwtTokenProvider.generateAccessToken(10L, "1", Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(10L, "COMPANY"); } @Test void createFeedback_shouldReturn200() throws Exception { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackRefreshRegistrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackRefreshRegistrationTest.java new file mode 100644 index 0000000..d705f87 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackRefreshRegistrationTest.java @@ -0,0 +1,200 @@ +package com.mydreamjob.feedback; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.feedback.dto.CandidateFeedbackRequest; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * R01-D4-2 阻断缺陷 F2:候选人反馈的创建/修改/删除都必须登记评分刷新任务。 + * + *

    缺陷背景:{@code CandidateScoringCalculator#queryFeedback} 以 + * {@code COUNT(*) / AVG(rating)} 参与 INTEGRITY 维度,但只有 createFeedback 登记了刷新, + * updateFeedback(改 rating 会改变 AVG)与 deleteFeedback(改变 COUNT 与 AVG)此前不登记。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class CandidateFeedbackRefreshRegistrationTest { + + @Autowired private CandidateFeedbackService service; + @Autowired private CandidateFeedbackRepository feedbackRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private JobRepository jobRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong SEQ = new AtomicLong(5000); + + private Long companyUserId; + private Long studentId; + private Long applicationId; + + private long taskCount(Long studentUserId) { + Long c = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", + Long.class, studentUserId); + return c == null ? -1 : c; + } + + private CandidateFeedbackRequest req(int rating, String content) { + CandidateFeedbackRequest r = new CandidateFeedbackRequest(); + r.setRating(rating); + r.setContent(content); + r.setTags(List.of("t")); + return r; + } + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + feedbackRepository.deleteAll(); + applicationRepository.deleteAll(); + jobRepository.deleteAll(); + companyRepository.deleteAll(); + + User student = userRepository.save(User.builder() + .phone("196" + String.format("%08d", SEQ.incrementAndGet())) + .nickname("F2学生").role(User.Role.STUDENT).active(true).verified(true).build()); + student.setRoles(java.util.Set.of(User.Role.STUDENT)); + studentId = student.getId(); + + User companyUser = userRepository.save(User.builder() + .phone("195" + String.format("%08d", SEQ.incrementAndGet())) + .nickname("F2企业").role(User.Role.COMPANY).active(true).verified(true).build()); + companyUser.setRoles(java.util.Set.of(User.Role.COMPANY)); + companyUserId = companyUser.getId(); + + Company company = companyRepository.save(Company.builder() + .ownerUserId(companyUserId).name("F2公司").build()); + Job job = jobRepository.save(Job.builder() + .companyId(company.getId()).title("F2岗位").description("d").city("上海") + .employmentType("FULL_TIME").status("PUBLISHED").build()); + Application app = applicationRepository.save(Application.builder() + .jobId(job.getId()).studentUserId(studentId).status("SUBMITTED").build()); + applicationId = app.getId(); + } + + private void clearTasks() { + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + } + + /** 清理本类产生的全部行,避免污染同一 JVM 内共享的 H2 内存库。 */ + @org.junit.jupiter.api.AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + jdbcTemplate.update("DELETE FROM candidate_feedback"); + jdbcTemplate.update("DELETE FROM offers"); + jdbcTemplate.update("DELETE FROM application_status_history"); + jdbcTemplate.update("DELETE FROM applications"); + jdbcTemplate.update("DELETE FROM jobs"); + jdbcTemplate.update("DELETE FROM companies"); + } + + @Test + void createFeedback_registersRefreshTask() { + clearTasks(); + service.createFeedback(companyUserId, applicationId, req(5, "优秀")); + assertEquals(1L, taskCount(studentId), "createFeedback 必须登记刷新任务"); + } + + @Test + void updateFeedback_registersRefreshTask() { + service.createFeedback(companyUserId, applicationId, req(5, "优秀")); + clearTasks(); // 只观察 update 自身是否登记 + + service.updateFeedback(companyUserId, applicationId, req(2, "较差")); + + assertEquals(1L, taskCount(studentId), + "updateFeedback 改变 rating 会影响 AVG(rating),必须登记刷新任务"); + } + + @Test + void deleteFeedback_registersRefreshTask_afterRowRemoved() { + service.createFeedback(companyUserId, applicationId, req(5, "优秀")); + clearTasks(); + + service.deleteFeedback(companyUserId, applicationId); + + assertEquals(0L, feedbackRepository.count(), "反馈行应已删除"); + assertEquals(1L, taskCount(studentId), + "deleteFeedback 改变 COUNT(*) 与 AVG(rating),必须登记刷新任务"); + } + + @Test + void updateFeedback_ratingUnchanged_stillRegistersButConvergesToOneTask() { + service.createFeedback(companyUserId, applicationId, req(4, "一般")); + clearTasks(); + + service.updateFeedback(companyUserId, applicationId, req(4, "一般-改文案")); + service.updateFeedback(companyUserId, applicationId, req(4, "一般-再改")); + + assertEquals(1L, taskCount(studentId), + "重复登记必须收敛为同一学生同一周期的一条任务"); + } + + @Test + void updateFeedback_forbiddenCaller_leavesNoOrphanTask() { + service.createFeedback(companyUserId, applicationId, req(5, "优秀")); + clearTasks(); + + assertThrows(BusinessException.class, + () -> service.updateFeedback(999999L, applicationId, req(1, "越权"))); + + assertEquals(0L, taskCount(studentId), "失败路径不得留下孤立刷新任务"); + } + + @Test + void deleteFeedback_missingFeedback_leavesNoOrphanTask() { + clearTasks(); + + assertThrows(BusinessException.class, + () -> service.deleteFeedback(companyUserId, applicationId)); + + assertEquals(0L, taskCount(studentId), "失败路径不得留下孤立刷新任务"); + } + + @Test + void feedbackLifecycle_createUpdateDelete_allRegisteredAndSnapshotRefreshable() { + clearTasks(); + service.createFeedback(companyUserId, applicationId, req(5, "优秀")); + assertEquals(1L, taskCount(studentId), "create 登记"); + + clearTasks(); + service.updateFeedback(companyUserId, applicationId, req(1, "很差")); + assertEquals(1L, taskCount(studentId), "update 登记"); + + clearTasks(); + service.deleteFeedback(companyUserId, applicationId); + assertEquals(1L, taskCount(studentId), "delete 登记"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java index bca9a07..2ea3ee1 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java @@ -29,6 +29,7 @@ class CandidateFeedbackServiceTest { @Mock private ApplicationRepository applicationRepository; @Mock private JobService jobService; @Mock private CompanyRepository companyRepository; + @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; @InjectMocks private CandidateFeedbackService feedbackService; private static final Long COMPANY_USER_ID = 10L; // 企业账号 userId diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/GlobalExceptionHandlerInterviewScoreTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/GlobalExceptionHandlerInterviewScoreTest.java index 73f373e..9fd5bd5 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/GlobalExceptionHandlerInterviewScoreTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/GlobalExceptionHandlerInterviewScoreTest.java @@ -51,7 +51,7 @@ class GlobalExceptionHandlerInterviewScoreTest { ValueOperations ops = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(any())).thenReturn(false); - token = "Bearer " + jwtTokenProvider.generateAccessToken(10L, "1", Map.of("roles", "COMPANY")); + token = com.mydreamjob.auth.support.TestAuthTokens.bearer(10L, "COMPANY"); } private String body() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java index 32732ce..b3391f7 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java @@ -125,8 +125,7 @@ class InterviewScoreControllerTest { } private String token(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } private String bodyJson(Integer t, Integer c, Integer ps, Integer rf, Integer pf) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/BusinessRowSnapshot.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/BusinessRowSnapshot.java new file mode 100644 index 0000000..8422edb --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/BusinessRowSnapshot.java @@ -0,0 +1,126 @@ +package com.mydreamjob.job; + +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Clob; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Q01 §7 查询副作用证据:业务表的**行级稳定字段快照**。 + * + *

    只比较 COUNT / SUM / CRC32 只能说明“行数没变”,不能说明“没有某一行被改写”。 + * 因此这里对每张受检表按主键排序逐行取值,查询前后各取一次,逐行逐字段比较。

    + * + *

    只选取显式列出的稳定业务字段,不使用 {@code SELECT *},也不包含任何凭据列 + * (例如 {@code users.password_hash}),因此失败信息中不会出现密码摘要。

    + */ +final class BusinessRowSnapshot { + + /** 受检表 → 稳定业务字段(顺序即 SELECT 列顺序,也是比较顺序)。 */ + private static final Map> TABLES = new LinkedHashMap<>(); + + static { + TABLES.put("users", List.of( + "id", "phone", "nickname", "role", "is_active", "is_verified", + "created_at", "updated_at")); + TABLES.put("companies", List.of( + "id", "owner_user_id", "name", "logo_url", "description", "industry", + "website", "address", "verified", "created_at", "updated_at")); + TABLES.put("jobs", List.of( + "id", "company_id", "title", "description", "requirements", "city", + "employment_type", "salary_min", "salary_max", "status", "published_at", + "created_at", "updated_at")); + TABLES.put("applications", List.of( + "id", "job_id", "student_user_id", "cover_letter", "status", + "created_at", "updated_at")); + TABLES.put("candidate_dimension_scores", List.of( + "id", "student_user_id", "dimension", "period", "score", "status", + "sub_scores_json", "weight", "rules_version", "calculated_at", "version", + "created_at", "updated_at")); + TABLES.put("notifications", List.of( + "id", "user_id", "type", "title", "content", "is_read", "read_at", + "created_at")); + TABLES.put("conversations", List.of( + "id", "title", "type", "created_by", "created_at", "updated_at")); + TABLES.put("messages", List.of( + "id", "conversation_id", "sender_user_id", "content", "created_at")); + TABLES.put("audit_logs", List.of( + "id", "user_id", "action", "entity_type", "entity_id", "details", + "ip_address", "user_agent", "request_id", "created_at")); + } + + private final JdbcTemplate jdbcTemplate; + + BusinessRowSnapshot(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + static Set tables() { + return TABLES.keySet(); + } + + /** 逐表按主键顺序捕获行级快照。 */ + Map>> capture() { + Map>> snapshot = new LinkedHashMap<>(); + TABLES.forEach((table, columns) -> { + String sql = "SELECT " + String.join(", ", columns) + " FROM " + table + " ORDER BY id"; + List> rows = new ArrayList<>(); + for (Map row : jdbcTemplate.queryForList(sql)) { + Map normalized = new LinkedHashMap<>(); + row.forEach((column, value) -> + normalized.put(column.toLowerCase(Locale.ROOT), normalize(value))); + rows.add(normalized); + } + snapshot.put(table, rows); + }); + return snapshot; + } + + /** + * CLOB/文本大对象在不同驱动下返回不同实例,直接按引用比较会产生假差异, + * 因此统一读取为字符串。 + */ + private static Object normalize(Object value) { + if (value instanceof Clob clob) { + try { + return clob.getSubString(1, (int) clob.length()); + } catch (SQLException e) { + throw new IllegalStateException("failed to read text column from snapshot", e); + } + } + return value; + } + + /** 返回人类可读差异;两次快照逐表逐行完全一致时返回空字符串。 */ + static String describeDifference(Map>> before, + Map>> after) { + List differences = new ArrayList<>(); + for (String table : TABLES.keySet()) { + List> beforeRows = before.get(table); + List> afterRows = after.get(table); + if (beforeRows.equals(afterRows)) { + continue; + } + differences.add(table + ": 行数 " + beforeRows.size() + " -> " + afterRows.size() + + firstRowDifference(beforeRows, afterRows)); + } + return String.join("; ", differences); + } + + private static String firstRowDifference(List> before, + List> after) { + int shared = Math.min(before.size(), after.size()); + for (int i = 0; i < shared; i++) { + if (!before.get(i).equals(after.get(i))) { + return ",首个差异行 #" + i + " 前=" + before.get(i) + " 后=" + after.get(i); + } + } + return ""; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java index 42d3e49..2c88472 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java @@ -2,6 +2,8 @@ package com.mydreamjob.job; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,9 @@ class JobFavoriteServiceTest { @Mock private UserRepository userRepository; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private JobFavoriteService favoriteService; @@ -45,6 +50,9 @@ class JobFavoriteServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + publishedJob = Job.builder().id(JOB_ID).companyId(1L) .title("Java 后端").status("PUBLISHED").build(); draftJob = Job.builder().id(20L).companyId(1L) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobListKeywordNormalizationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobListKeywordNormalizationIntegrationTest.java new file mode 100644 index 0000000..43992a2 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobListKeywordNormalizationIntegrationTest.java @@ -0,0 +1,315 @@ +package com.mydreamjob.job; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * DEF-01 定向整改:GET /jobs 的关键词规范化。 + * + *

    本类刻意走**真实链路**:HTTP → SecurityFilterChain → JobController → JobService → JobRepository + * (真实 Spring 上下文 + 真实数据库),不直接调用 Controller、不 Mock + * JobService/JobRepository、不用 Mockito 校验字符串参数。

    + * + *

    固化的语义契约:`GET /jobs` 的关键词应与 `GET /jobs/search` 一致 —— + * 两端空白被 trim、空串/纯空白等同于「未传关键词」。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class JobListKeywordNormalizationIntegrationTest { + + private static final String TITLE_PREFIX = "DEF01KEYWORD_"; + private static final String TARGET_KEYWORD = "AlphaEngineer"; + private static final String PHONE_FMT = "199001%05d"; + /** 夹具统一基准时间,保证每条岗位的 created_at 互异且可预期。 */ + private static final LocalDateTime BASE_TIME = LocalDateTime.of(2026, 9, 20, 9, 0, 0); + + @Autowired private MockMvc mockMvc; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private ObjectMapper objectMapper; + @MockBean private RedisTemplate redisTemplate; + + /** 显式登记的本轮夹具主键,清理只按这些主键进行。 */ + private final List userIds = new ArrayList<>(); + private final List companyIds = new ArrayList<>(); + private final List jobIds = new ArrayList<>(); + + private Long targetJobId; + + @BeforeEach + void seed() { + cleanup(); + long me = user(String.format(PHONE_FMT, 100 + userIds.size())); + long companyId = company(me, TITLE_PREFIX + "co"); + companyIds.add(companyId); + + // 1 条标题命中关键词的公开岗位 + 2 条其它公开岗位 + targetJobId = job(companyId, TITLE_PREFIX + TARGET_KEYWORD, "PUBLISHED"); + job(companyId, TITLE_PREFIX + "OtherRole", "PUBLISHED"); + job(companyId, TITLE_PREFIX + "ThirdRole", "PUBLISHED"); + // 1 条标题同样命中关键词、但状态为 DRAFT 的岗位 + job(companyId, TITLE_PREFIX + TARGET_KEYWORD + "_DRAFT", "DRAFT"); + // 1 条标题命中关键词、状态为 CLOSED(对照非公开范围) + job(companyId, TITLE_PREFIX + TARGET_KEYWORD + "_CLOSED", "CLOSED"); + } + + @AfterEach + void cleanup() { + // 只按本轮显式登记的主键清理,不使用宽泛 deleteAll() + if (!jobIds.isEmpty()) { + jdbcTemplate.update("DELETE FROM jobs WHERE id IN (" + + String.join(",", jobIds.stream().map(String::valueOf).toList()) + ")"); + } + if (!companyIds.isEmpty()) { + jdbcTemplate.update("DELETE FROM companies WHERE id IN (" + + String.join(",", companyIds.stream().map(String::valueOf).toList()) + ")"); + } + if (!userIds.isEmpty()) { + String ids = String.join(",", userIds.stream().map(String::valueOf).toList()); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (" + ids + ")"); + jdbcTemplate.update("DELETE FROM users WHERE id IN (" + ids + ")"); + } + jobIds.clear(); + companyIds.clear(); + userIds.clear(); + targetJobId = null; + } + + private long user(String phone) { + jdbcTemplate.update("INSERT INTO users (phone, nickname, role, is_active, is_verified, created_at, " + + "updated_at) VALUES (?, ?, 'COMPANY', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + phone, TITLE_PREFIX + phone); + Long id = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone); + jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, 'COMPANY')", id); + userIds.add(id); + return id; + } + + private long company(long ownerUserId, String name) { + jdbcTemplate.update("INSERT INTO companies (owner_user_id, name, verified, created_at, updated_at) " + + "VALUES (?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", ownerUserId, name); + return jdbcTemplate.queryForObject("SELECT id FROM companies WHERE name = ?", Long.class, name); + } + + /** + * 插入夹具岗位,created_at 显式取互异值(每条递减 1 分钟)。 + * + *

    默认排序 {@code createdAt DESC} 没有 tiebreaker,若夹具时间相同,其相对顺序由引擎决定, + * 断言就会不稳定。显式互异后,夹具内部的顺序才是确定可断言的。

    + */ + private long job(long companyId, String title, String status) { + LocalDateTime createdAt = BASE_TIME.minusMinutes(jobIds.size()); + jdbcTemplate.update("INSERT INTO jobs (company_id, title, description, city, employment_type, status, " + + "created_at, updated_at) VALUES (?, ?, 'desc', '北京', 'FULL_TIME', ?, ?, ?)", + companyId, title, status, createdAt, createdAt); + Long id = jdbcTemplate.queryForObject("SELECT id FROM jobs WHERE title = ?", Long.class, title); + jobIds.add(id); + return id; + } + + // ── HTTP 调用与解析 ────────────────────────────────────── + + /** + * 走真实链路请求 /jobs。 + * + *

    用 {@code .param(...)} 传参而非在 URL 串里手写 {@code %20}:MockMvc 会对 URL 模板再做一次 + * 编码({@code %20} → {@code %2520}),那样测的就不是「带空白的参数」而是「字面量 %20」。

    + * + * @param keyword {@code null} = 不带该参数;{@code ""} = {@code keyword=};其余按原样发送 + */ + private JsonNode listJobs(String keyword, String size, String page) throws Exception { + var builder = get("/jobs").param("size", size); + if (page != null) { + builder = builder.param("page", page); + } + if (keyword != null) { + builder = builder.param("keyword", keyword); + } + var response = mockMvc.perform(builder).andReturn().getResponse(); + assertThat(response.getStatus()).as("GET /jobs 不得返回 4xx/5xx").isEqualTo(200); + String body = response.getContentAsString(); + assertThat(body).as("响应不得泄露 SQL / 异常类 / 堆栈 / 本机路径") + .doesNotContain("java.sql").doesNotContain("Exception") + .doesNotContain("at com.mydreamjob").doesNotContain("C:\\"); + return objectMapper.readTree(body).path("data"); + } + + private JsonNode listJobs(String keyword, String size) throws Exception { + return listJobs(keyword, size, null); + } + + private List idsOf(JsonNode data) { + List ids = new ArrayList<>(); + for (JsonNode item : data.path("items")) { + ids.add(item.path("id").asLong()); + } + return ids; + } + + /** 只保留本轮夹具的 ID,避免同库其它测试数据干扰。 */ + private List fixtureIdsOf(JsonNode data) { + List ids = new ArrayList<>(); + for (JsonNode item : data.path("items")) { + if (item.path("title").asText("").startsWith(TITLE_PREFIX)) { + ids.add(item.path("id").asLong()); + } + } + return ids; + } + + // ── 用例 ──────────────────────────────────────────────── + + @Test + @DisplayName("普通关键词只命中目标公开岗位") + void plainKeywordFindsPublishedJob() throws Exception { + JsonNode data = listJobs(TARGET_KEYWORD, "50"); + assertThat(idsOf(data)).as("普通关键词应只命中 1 条").containsExactly(targetJobId); + } + + @Test + @DisplayName("两侧空白关键词与普通关键词结果完全一致(trim)") + void trimmedKeywordFindsSamePublishedJob() throws Exception { + JsonNode plain = listJobs(TARGET_KEYWORD, "50"); + JsonNode padded = listJobs(" " + TARGET_KEYWORD + " ", "50"); + assertThat(idsOf(padded)) + .as("带两侧空白的关键词必须与普通关键词返回相同 ID 与顺序") + .isEqualTo(idsOf(plain)); + } + + @Test + @DisplayName("纯空白关键词等同于未传关键词") + void blankKeywordBehavesAsNoKeyword() throws Exception { + JsonNode none = listJobs(null, "50"); + JsonNode blank = listJobs(" ", "50"); + assertThat(fixtureIdsOf(blank)).as("纯空白关键词必须与无关键词返回相同夹具 ID 与顺序") + .isEqualTo(fixtureIdsOf(none)); + assertThat(blank.path("total").asLong()).as("总数也应一致").isEqualTo(none.path("total").asLong()); + } + + @Test + @DisplayName("空字符串关键词等同于未传关键词") + void emptyKeywordBehavesAsNoKeyword() throws Exception { + JsonNode none = listJobs(null, "50"); + JsonNode empty = listJobs("", "50"); + assertThat(fixtureIdsOf(empty)).as("空字符串关键词必须与无关键词返回相同夹具 ID 与顺序") + .isEqualTo(fixtureIdsOf(none)); + assertThat(empty.path("total").asLong()).isEqualTo(none.path("total").asLong()); + } + + @Test + @DisplayName("缺省关键词返回全部公开岗位") + void nullOrMissingKeywordListsPublishedJobs() throws Exception { + JsonNode data = listJobs(null, "50"); + assertThat(fixtureIdsOf(data)) + .as("本轮 3 条 PUBLISHED 夹具都应返回") + .hasSize(3); + } + + @Test + @DisplayName("关键词规范化后 DRAFT / CLOSED 仍不进入公开结果") + void draftJobNeverAppearsAfterNormalization() throws Exception { + for (String q : new String[]{TARGET_KEYWORD, " " + TARGET_KEYWORD + " ", " ", "", null}) { + JsonNode data = listJobs(q, "50"); + for (JsonNode item : data.path("items")) { + assertThat(item.path("status").asText()) + .as("公开列表只允许 PUBLISHED") + .isEqualTo("PUBLISHED"); + assertThat(item.path("title").asText()) + .as("DRAFT/CLOSED 夹具不得出现") + .doesNotContain("_DRAFT") + .doesNotContain("_CLOSED"); + } + } + } + + @Test + @DisplayName("关键词规范化不改变分页元数据") + void normalizationDoesNotChangePagination() throws Exception { + JsonNode plain = listJobs(TARGET_KEYWORD, "1", "0"); + JsonNode padded = listJobs(" " + TARGET_KEYWORD + " ", "1", "0"); + for (String field : new String[]{"total", "page", "size", "totalPages"}) { + assertThat(padded.path(field).asLong()) + .as("分页字段 %s 不应因规范化而变化", field) + .isEqualTo(plain.path(field).asLong()); + } + JsonNode none = listJobs(null, "2", "0"); + JsonNode blank = listJobs(" ", "2", "0"); + // 非夹具行之间存在 created_at 并列,而默认排序无 tiebreaker —— 小页尺寸下连「入选子集」 + // 都不由契约保证。故此用例只断言**确定性**的分页元数据与夹具集合。 + for (String field : new String[]{"total", "page", "size", "totalPages"}) { + assertThat(blank.path(field).asLong()) + .as("分页字段 %s 不应因空白关键词而变化", field) + .isEqualTo(none.path(field).asLong()); + } + assertThat(fixtureIdsOf(blank)).as("分页下夹具集合应与无关键词一致").isEqualTo(fixtureIdsOf(none)); + } + + @Test + @DisplayName("关键词规范化不改变既有默认排序契约(createdAt 降序)") + void normalizationDoesNotChangeExistingSortContract() throws Exception { + JsonNode none = listJobs(null, "50"); + JsonNode blank = listJobs(" ", "50"); + JsonNode padded = listJobs(" " + TARGET_KEYWORD + " ", "50"); + + List fixtureNone = fixtureIdsOf(none); + assertThat(fixtureNone).as("同一批夹具在两种查询下顺序应一致(夹具 created_at 互异)") + .isEqualTo(fixtureIdsOf(blank)); + assertThat(padded.path("items").isArray()).isTrue(); + + List createdDesc = new ArrayList<>(); + for (JsonNode item : blank.path("items")) { + createdDesc.add(item.path("createdAt").asText()); + } + assertThat(createdDesc).as("默认排序仍应为 createdAt 降序").isSortedAccordingTo( + (a, b) -> b.compareTo(a)); + } + + @Test + @DisplayName("副作用:GET 前后相关业务表逐字段零变化") + void listJobsIsReadOnly() throws Exception { + Map>> before = snapshot(); + listJobs(TARGET_KEYWORD, "50"); + listJobs(" ", "50"); + listJobs(null, "50"); + Map>> after = snapshot(); + for (String table : before.keySet()) { + assertThat(after.get(table)).as("%s 必须逐字段零变化", table).isEqualTo(before.get(table)); + } + } + + private Map>> snapshot() { + Map>> s = new LinkedHashMap<>(); + s.put("jobs", jdbcTemplate.queryForList( + "SELECT id, company_id, title, status, updated_at FROM jobs ORDER BY id")); + s.put("companies", jdbcTemplate.queryForList( + "SELECT id, owner_user_id, name, verified FROM companies ORDER BY id")); + s.put("users", jdbcTemplate.queryForList( + "SELECT id, phone, nickname, role, is_active FROM users ORDER BY id")); + s.put("audit_logs", jdbcTemplate.queryForList( + "SELECT id, user_id, action, entity_type FROM audit_logs ORDER BY id")); + s.put("notifications", jdbcTemplate.queryForList( + "SELECT id, user_id, type, is_read FROM notifications ORDER BY id")); + return s; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQueryExplainMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQueryExplainMySqlIntegrationTest.java new file mode 100644 index 0000000..ad97fc4 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQueryExplainMySqlIntegrationTest.java @@ -0,0 +1,364 @@ +package com.mydreamjob.job; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Q01 §8:**真实 MySQL 8** 上的执行计划与查询次数证据。 + * + *

    对组合过滤、latest 分页、salary 排序与 count 四类语句执行 + * {@code EXPLAIN FORMAT=JSON},记录使用索引、access type、估算行数以及 + * filesort / temporary 情况,并测量结果规模为 1 / 20 / 50 时的 SQL 语句条数。

    + * + *

    EXPLAIN 使用的是与 Criteria 语义等价的 SQL,绑定同一组参数;用例会先断言 + * 等价 SQL 与 JPA/Criteria 路径返回**完全相同的 ID 与顺序**,避免 EXPLAIN 一份 + * 并不存在的语句。

    + * + *

    按 Q01 边界,本用例只记录、不改动索引;发现的问题留给 R01。

    + */ +@Testcontainers(disabledWithoutDocker = true) +@SpringBootTest +@ActiveProfiles("mysql-it") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class JobSearchQueryExplainMySqlIntegrationTest { + + private static final AtomicLong SEQ = new AtomicLong(75000); + private static final Path REPORT = Path.of("target", "q01-explain-report.md"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final List PLAN_TABLE_FIELDS = List.of( + "table_name", "access_type", "possible_keys", "key", "key_length", + "rows_examined_per_scan", "rows_produced_per_join", "filtered", + "using_index", "using_filesort", "using_temporary_table"); + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("mydreamjob_q01") + .withUsername("q01") + .withPassword("q01-test-only"); + + /** 连接参数全部来自容器;同时注册 SQL 记录器以留下真实生成的 SQL 原文。 */ + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + registry.add("spring.jpa.properties.hibernate.session_factory.statement_inspector", + () -> Q01SqlRecorder.class.getName()); + } + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired private JobService jobService; + @Autowired private JobRepository jobRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + private final List jobIds = new ArrayList<>(); + private final List companyIds = new ArrayList<>(); + private final List userIds = new ArrayList<>(); + + private String tag; + private Long companyA; + private final Map citiesByResultSize = new LinkedHashMap<>(); + + @BeforeEach + void setUp() { + tag = "Q01E" + SEQ.incrementAndGet(); + Long ownerId = userRepository.save(User.builder() + .phone("15" + String.format("%09d", SEQ.incrementAndGet())) + .nickname(tag + "-企业") + .role(User.Role.COMPANY) + .roles(Set.of(User.Role.COMPANY)) + .active(true).verified(true).build()).getId(); + userIds.add(ownerId); + companyA = companyRepository.save(Company.builder() + .ownerUserId(ownerId).name(tag + "公司").verified(true).build()).getId(); + companyIds.add(companyA); + + for (int size : new int[]{1, 20, 50}) { + citiesByResultSize.put(size, seedJobs(size)); + } + } + + @AfterEach + void tearDown() { + if (!jobIds.isEmpty()) { + jobRepository.deleteAllById(jobIds); + jobIds.clear(); + } + if (!companyIds.isEmpty()) { + companyRepository.deleteAllById(companyIds); + companyIds.clear(); + } + if (!userIds.isEmpty()) { + userRepository.deleteAllById(userIds); + userIds.clear(); + } + } + + private String seedJobs(int size) { + String city = tag + "CITY" + size; + List jobs = new ArrayList<>(); + for (int i = 0; i < size; i++) { + jobs.add(Job.builder() + .companyId(companyA).title(tag + "岗位" + i).description("描述" + i).requirements("要求" + i) + .city(city).employmentType("FULL_TIME") + .salaryMin(8000 + i * 100).salaryMax(20000 + i * 200) + .status(JobStatus.PUBLISHED.name()).build()); + } + jobRepository.saveAll(jobs).forEach(job -> jobIds.add(job.getId())); + return city; + } + + // ── EXPLAIN 主证据 ────────────────────────────────────────── + + @Test + void explainCombinedFilterLatestSalaryAndCount() throws IOException { + StringBuilder report = new StringBuilder(); + report.append("# Q01 MySQL 执行计划证据(由 ") + .append(getClass().getSimpleName()).append(" 生成)\n\n") + .append("- 数据库:`mysql:8.4` Testcontainers 独立容器,正式 Flyway 迁移,`ddl-auto=validate`\n") + .append("- EXPLAIN 形式:`EXPLAIN FORMAT=JSON`,与 Criteria 路径绑定同一组参数\n") + .append("- 夹具规模:每个城市分别 1 / 20 / 50 条 PUBLISHED 岗位(刻意做小,用于验证语义而非压测)\n") + .append("- Q01 不新增索引;执行计划问题记录给 R01\n\n"); + + String city = citiesByResultSize.get(20); + String keyword = tag + "岗位"; + String pattern = "%" + keyword.toLowerCase() + "%"; + + // 1. 组合过滤 + latest 分页 + Specification combined = JobSearchSpecification.searchJobs( + keyword, city, "FULL_TIME", 5000, 30000, companyA); + String combinedSql = """ + SELECT j.id FROM jobs j + WHERE j.status = ? AND (lower(j.title) LIKE ? OR lower(j.description) LIKE ? OR lower(j.requirements) LIKE ?) + AND lower(j.city) = ? AND lower(j.employment_type) = ? AND j.salary_max >= ? AND j.salary_min <= ? AND j.company_id = ? + ORDER BY j.created_at DESC, j.id DESC + LIMIT ? OFFSET ?"""; + Object[] combinedArgs = {"PUBLISHED", pattern, pattern, pattern, + city.toLowerCase(java.util.Locale.ROOT), "full_time", + 5000, 30000, companyA, 20, 0}; + + Q01SqlRecorder.clear(); + List jpaCombined = ids(jobRepository.findAll(combined, latest(0, 20))); + List hibernateSql = Q01SqlRecorder.drain(); + + assertSameIds("组合过滤 + latest 分页", combinedSql, combinedArgs, jpaCombined); + report.append("## 1. 组合过滤 + latest 分页\n\n"); + report.append("Hibernate 实际生成并执行的 SQL(占位符形式):\n\n```sql\n") + .append(String.join("\n\n", hibernateSql)).append("\n```\n\n"); + appendExplain(report, combinedSql, combinedArgs); + + // 2. latest 分页(无关键词) + Specification latestOnly = JobSearchSpecification.searchJobs(null, city, null, null, null, null); + String latestSql = """ + SELECT j.id FROM jobs j + WHERE j.status = ? AND lower(j.city) = ? + ORDER BY j.created_at DESC, j.id DESC + LIMIT ? OFFSET ?"""; + Object[] latestArgs = {"PUBLISHED", city.toLowerCase(java.util.Locale.ROOT), 20, 0}; + + List jpaLatest = ids(jobRepository.findAll(latestOnly, latest(0, 20))); + assertSameIds("latest 分页", latestSql, latestArgs, jpaLatest); + report.append("## 2. latest 分页\n\n"); + appendExplain(report, latestSql, latestArgs); + + // 3. salary 排序(CASE WHEN + salary_max DESC + id DESC) + String salarySql = """ + SELECT j.id FROM jobs j + WHERE j.status = ? AND lower(j.city) = ? + ORDER BY CASE WHEN j.salary_max IS NULL THEN 1 ELSE 0 END ASC, j.salary_max DESC, j.id DESC + LIMIT ? OFFSET ?"""; + Object[] salaryArgs = {"PUBLISHED", city.toLowerCase(java.util.Locale.ROOT), 20, 0}; + + List serviceSalary = jobService.searchJobs(null, city, null, null, null, null, "salary", 0, 20) + .getContent().stream().map(com.mydreamjob.job.dto.JobSearchItem::getId).toList(); + assertSameIds("salary 排序", salarySql, salaryArgs, serviceSalary); + report.append("## 3. salary 排序(salaryMax DESC NULLS LAST)\n\n"); + appendExplain(report, salarySql, salaryArgs); + + // 4. count 查询 + String countSql = """ + SELECT count(j.id) FROM jobs j + WHERE j.status = ? AND (lower(j.title) LIKE ? OR lower(j.description) LIKE ? OR lower(j.requirements) LIKE ?) + AND lower(j.city) = ? AND lower(j.employment_type) = ? AND j.salary_max >= ? AND j.salary_min <= ? AND j.company_id = ?"""; + Object[] countArgs = {"PUBLISHED", pattern, pattern, pattern, + city.toLowerCase(java.util.Locale.ROOT), "full_time", + 5000, 30000, companyA}; + + Long jpaCount = jobRepository.count(combined); + Long sqlCount = jdbcTemplate.queryForObject(countSql, Long.class, countArgs); + assertThat(sqlCount) + .as("等价 count SQL 必须与 Criteria 的 count 结果一致") + .isEqualTo(jpaCount); + report.append("## 4. count 查询\n\n"); + appendExplain(report, countSql, countArgs); + + // 5. 结果规模 1 / 20 / 50 下的 SQL 语句条数 + report.append("## 5. 结果规模 1 / 20 / 50 下的 SQL 语句条数\n\n"); + report.append("Spring Data 对首页有优化:首页返回行数小于 `pageSize` 时不再发 count 查询,\n"); + report.append("因此结果数 < pageSize 时为 1 条(仅数据查询),否则为 2 条(count + 数据查询)。\n"); + report.append("两种情况下语句条数都**不随结果规模增长**,即不存在 N+1。\n\n"); + report.append("| 匹配结果数 | 返回行数 | totalElements | Repository SQL数 | 完整Service SQL数 |\n") + .append("|---|---|---|---|---|\n"); + for (int size : new int[]{1, 20, 50}) { + Q01SqlRecorder.clear(); + var page = jobRepository.findAll( + JobSearchSpecification.searchJobs(null, citiesByResultSize.get(size), null, null, null, null), + latest(0, 20)); + List repositoryStatements = Q01SqlRecorder.drain(); + + Q01SqlRecorder.clear(); + var servicePage = jobService.searchJobs( + null, citiesByResultSize.get(size), null, null, null, null, "latest", 0, 20); + List serviceStatements = Q01SqlRecorder.drain(); + + int expectedRepositoryStatements = size < 20 ? 1 : 2; + int expectedServiceStatements = expectedRepositoryStatements + 1; + report.append("| ").append(size).append(" | ").append(page.getContent().size()) + .append(" | ").append(page.getTotalElements()) + .append(" | ").append(repositoryStatements.size()) + .append(" | ").append(serviceStatements.size()).append(" |\n"); + assertThat(repositoryStatements) + .as("结果规模 %s:Repository 分页SQL数不随结果行数线性增长", size) + .hasSize(expectedRepositoryStatements); + assertThat(serviceStatements) + .as("结果规模 %s:完整Service只增加一次企业批量查询,不产生N+1", size) + .hasSize(expectedServiceStatements); + assertThat(page.getTotalElements()).isEqualTo(size); + assertThat(servicePage.getTotalElements()).isEqualTo(size); + } + report.append('\n'); + + Files.createDirectories(REPORT.getParent()); + Files.writeString(REPORT, report.toString(), StandardCharsets.UTF_8); + System.out.println("[Q01] EXPLAIN 报告已写入 " + REPORT.toAbsolutePath()); + } + + // ── 等价性校验与计划摘要 ──────────────────────────────────── + + /** 等价 SQL 必须与 JPA/Criteria 路径返回完全相同的 ID 与顺序。 */ + private void assertSameIds(String scenario, String sql, Object[] args, List jpaIds) { + List sqlIds = jdbcTemplate.queryForList(sql, Long.class, args); + assertThat(sqlIds) + .as("%s:等价 SQL 必须与 Criteria/JPA 路径返回相同的 ID 与顺序,EXPLAIN 才有意义", scenario) + .containsExactlyElementsOf(jpaIds); + assertThat(sqlIds).as("%s:夹具必须真的命中数据", scenario).isNotEmpty(); + } + + private void appendExplain(StringBuilder report, String sql, Object[] args) { + report.append("等价 SQL(绑定参数 ").append(Arrays.toString(args)).append("):\n\n```sql\n") + .append(sql).append("\n```\n\n"); + + String plan = jdbcTemplate.queryForObject("EXPLAIN FORMAT=JSON " + sql, String.class, args); + assertThat(plan).as("EXPLAIN 必须返回计划").isNotBlank(); + + report.append("计划摘要:\n\n```\n").append(summarize(plan)).append("```\n\n"); + report.append("
    EXPLAIN FORMAT=JSON 原文\n\n```json\n") + .append(plan).append("\n```\n\n
    \n\n"); + } + + private String summarize(String planJson) { + JsonNode queryBlock; + try { + queryBlock = MAPPER.readTree(planJson).path("query_block"); + } catch (IOException e) { + throw new IllegalStateException("failed to parse EXPLAIN FORMAT=JSON output", e); + } + + StringBuilder summary = new StringBuilder(); + JsonNode costInfo = queryBlock.path("cost_info"); + if (costInfo.has("query_cost")) { + summary.append("query_cost = ").append(costInfo.get("query_cost").asText()).append('\n'); + } + summary.append("using_filesort = ").append(planJson.contains("\"using_filesort\": true")).append('\n'); + summary.append("using_temporary_table = ") + .append(planJson.contains("\"using_temporary_table\": true")).append('\n'); + collectTables(queryBlock, "query_block", summary); + return summary.toString(); + } + + private void collectTables(JsonNode node, String context, StringBuilder summary) { + if (node.isObject()) { + node.fields().forEachRemaining(entry -> { + String name = entry.getKey(); + JsonNode value = entry.getValue(); + switch (name) { + case "table" -> summary.append("- 表 ").append(context).append(": ") + .append(tableFields(value)).append('\n'); + case "ordering_operation" -> collectTables(value, context + "/ORDER BY", summary); + case "grouping_operation" -> collectTables(value, context + "/GROUP BY", summary); + case "nested_loop", "duplicates_removal", "query_block" -> + collectTables(value, context, summary); + default -> { + // 其他子结构不参与摘要 + } + } + }); + } else if (node.isArray()) { + for (JsonNode child : node) { + collectTables(child, context, summary); + } + } + } + + private String tableFields(JsonNode table) { + List parts = new ArrayList<>(); + for (String field : PLAN_TABLE_FIELDS) { + if (table.has(field)) { + parts.add(field + "=" + table.get(field).toString()); + } + } + return String.join(", ", parts); + } + + private static List ids(org.springframework.data.domain.Page page) { + return page.getContent().stream().map(Job::getId).toList(); + } + + private static PageRequest latest(int page, int size) { + return PageRequest.of(page, size, Sort.by( + new Sort.Order(Sort.Direction.DESC, "createdAt"), + new Sort.Order(Sort.Direction.DESC, "id"))); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectIntegrationTest.java new file mode 100644 index 0000000..925c6fc --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectIntegrationTest.java @@ -0,0 +1,20 @@ +package com.mydreamjob.job; + +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.test.context.ActiveProfiles; + +/** + * Q01 §7 查询副作用行级快照 —— H2 层。 + * + *

    与 {@link JobSearchQuerySideEffectMySqlIntegrationTest} 执行完全相同的用例, + * 用于 H2 / 真实 MySQL 逐项对账。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class JobSearchQuerySideEffectIntegrationTest extends JobSearchQuerySideEffectTestBase { + + @MockBean + private RedisTemplate redisTemplate; +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectMySqlIntegrationTest.java new file mode 100644 index 0000000..a75f515 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectMySqlIntegrationTest.java @@ -0,0 +1,44 @@ +package com.mydreamjob.job; + +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.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Q01 §7 查询副作用行级快照 —— **真实 MySQL 8** 层。 + * + *

    用例与 {@link JobSearchQuerySideEffectIntegrationTest} 完全相同(同一基类), + * 用于 H2 / MySQL 逐项对账。使用独立容器、正式 Flyway 迁移与 {@code ddl-auto=validate}, + * 不复用开发库,不打印任何连接凭据。

    + */ +@Testcontainers(disabledWithoutDocker = true) +@SpringBootTest +@ActiveProfiles("mysql-it") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class JobSearchQuerySideEffectMySqlIntegrationTest extends JobSearchQuerySideEffectTestBase { + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("mydreamjob_q01") + .withUsername("q01") + .withPassword("q01-test-only"); + + /** 连接参数全部来自容器,运行期注入,不落盘、不打印密码。 */ + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + + @MockBean + private RedisTemplate redisTemplate; +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectTestBase.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectTestBase.java new file mode 100644 index 0000000..8d4e565 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectTestBase.java @@ -0,0 +1,299 @@ +package com.mydreamjob.job; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; + +import java.math.BigDecimal; +import java.sql.PreparedStatement; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Q01 §7 查询副作用的**行级**证据基类。 + * + *

    证明公开搜索({@link JobService#searchJobs} 与 + * {@link JobSearchSpecification#searchJobs} 的真实 Repository 路径)只读, + * 不会改写岗位状态、企业、用户或其他业务表。

    + * + *

    做法:在 jobs、companies、users、applications、candidate_dimension_scores、 + * notifications、conversations、messages、audit_logs 九张表上取**行级稳定字段快照**, + * 执行完整搜索矩阵后再次取快照,逐表逐行逐字段比较。只比较 COUNT / SUM / CRC32 + * 无法证明“没有某一行被改写”,因此这里不做聚合比较。

    + * + *

    快照比较本身也可能失效(例如永远返回相等),所以另有三条**反证测试**:人为改字段、 + * 插行、删行,必须被快照发现。

    + * + *

    本基类不绑定数据库:H2 用例与真实 MySQL 用例各自继承并断言同一组行为, + * 从而形成跨库逐项对账。

    + */ +abstract class JobSearchQuerySideEffectTestBase { + + private static final AtomicLong SEQ = new AtomicLong(74000); + private static final int MAX_RESULTS = 50; + + /** 清理顺序:先删有外键指向其他受检表的行,再删主表。 */ + private static final List AUXILIARY_TABLES = List.of( + "messages", "conversations", "notifications", + "applications", "candidate_dimension_scores", "audit_logs"); + + @Autowired protected JobService jobService; + @Autowired protected JobRepository jobRepository; + @Autowired protected CompanyRepository companyRepository; + @Autowired protected UserRepository userRepository; + @Autowired protected JdbcTemplate jdbcTemplate; + + private final List jobIds = new ArrayList<>(); + private final List companyIds = new ArrayList<>(); + private final List userIds = new ArrayList<>(); + private final Map> auxiliaryIds = new LinkedHashMap<>(); + + private BusinessRowSnapshot snapshot; + private String tag; + private String city; + private Long companyA; + private Long ownerA; + private Long student; + + @BeforeEach + void setUpBusinessFixtures() { + snapshot = new BusinessRowSnapshot(jdbcTemplate); + tag = "Q01X" + SEQ.incrementAndGet(); + city = tag + "CITY"; + + ownerA = createUser("甲", User.Role.COMPANY); + Long ownerB = createUser("乙", User.Role.COMPANY); + student = createUser("学生", User.Role.STUDENT); + companyA = createCompany(ownerA, "甲"); + Long companyB = createCompany(ownerB, "乙"); + + createJob(companyA, JobStatus.PUBLISHED.name(), 10000, 20000); + createJob(companyA, JobStatus.PUBLISHED.name(), 10000, null); + createJob(companyA, JobStatus.DRAFT.name(), 10000, 20000); + createJob(companyB, JobStatus.CLOSED.name(), 10000, 20000); + + seedAuxiliaryRows(); + } + + @AfterEach + void tearDownBusinessFixtures() { + for (String table : AUXILIARY_TABLES) { + List ids = auxiliaryIds.get(table); + if (ids == null || ids.isEmpty()) { + continue; + } + String placeholders = String.join(",", Collections.nCopies(ids.size(), "?")); + jdbcTemplate.update("DELETE FROM " + table + " WHERE id IN (" + placeholders + ")", ids.toArray()); + } + auxiliaryIds.clear(); + deleteAllById(jobRepository, jobIds); + deleteAllById(companyRepository, companyIds); + deleteAllById(userRepository, userIds); + } + + // ── 主证据:搜索不产生任何业务行改动 ──────────────────────── + + @Test + void searchQueriesDoNotChangeBusinessRows() { + Map>> before = snapshot.capture(); + + for (String table : BusinessRowSnapshot.tables()) { + assertThat(before.get(table)) + .as("受检表 %s 必须在测试库中有行,否则前后比较是空比较", table) + .isNotEmpty(); + } + + long matched = runFullSearchMatrix(); + assertThat(matched).as("搜索矩阵必须真的命中数据,否则副作用证据是空跑").isPositive(); + + assertThat(BusinessRowSnapshot.describeDifference(before, snapshot.capture())) + .as("公开搜索全矩阵执行前后,九张受检表的行级稳定字段必须逐行逐字段一致") + .isEmpty(); + } + + // ── 反证:快照必须能发现改动,否则主证据不成立 ────────────── + + @Test + void snapshotReportsRowFieldUpdate() { + Map>> before = snapshot.capture(); + Long jobId = jobIds.get(0); + Map original = rowById(before, "jobs", jobId); + + jdbcTemplate.update("UPDATE jobs SET status = ?, updated_at = ? WHERE id = ?", + JobStatus.CLOSED.name(), original.get("updated_at"), jobId); + + assertThat(BusinessRowSnapshot.describeDifference(before, snapshot.capture())) + .as("反证:同一行的字段被改写必须被快照发现") + .contains("jobs:"); + + jdbcTemplate.update("UPDATE jobs SET status = ?, updated_at = ? WHERE id = ?", + original.get("status"), original.get("updated_at"), jobId); + + assertThat(BusinessRowSnapshot.describeDifference(before, snapshot.capture())) + .as("反证:把字段与 updated_at 一并还原后,快照必须重新完全一致(说明差异定位精确)") + .isEmpty(); + } + + @Test + void snapshotReportsInsertedRow() { + Map>> before = snapshot.capture(); + + insertNotification(tag + "-反证插入"); + + assertThat(BusinessRowSnapshot.describeDifference(before, snapshot.capture())) + .as("反证:新增一行必须被快照发现") + .contains("notifications:"); + } + + @Test + void snapshotReportsDeletedRow() { + Long temporaryId = insertNotification(tag + "-反证删除"); + Map>> before = snapshot.capture(); + + jdbcTemplate.update("DELETE FROM notifications WHERE id = ?", temporaryId); + auxiliaryIds.get("notifications").remove(temporaryId); + + assertThat(BusinessRowSnapshot.describeDifference(before, snapshot.capture())) + .as("反证:删除一行必须被快照发现") + .contains("notifications:"); + } + + // ── 搜索矩阵 ──────────────────────────────────────────────── + + /** 覆盖全部过滤条件 × 全部排序 × 分页 × 无结果 × 聚合筛选项,返回命中的总行数。 */ + private long runFullSearchMatrix() { + long matched = 0; + for (String sort : new String[]{"latest", "relevance", "salary", null}) { + matched += jobService.searchJobs(null, city, null, null, null, null, sort, 0, MAX_RESULTS) + .getTotalElements(); + matched += jobService.searchJobs(tag + "岗位", city, "FULL_TIME", 10000, 20000, companyA, + sort, 0, MAX_RESULTS).getTotalElements(); + matched += jobService.searchJobs("%", city, null, 5000, 30000, null, sort, 0, 10) + .getTotalElements(); + matched += jobService.searchJobs("_", null, null, null, null, null, sort, 0, 10) + .getTotalElements(); + matched += jobService.searchJobs(tag + "不存在的关键词XYZ", city, null, null, null, null, + sort, 0, 10).getTotalElements(); + matched += jobService.searchJobs(null, city, null, null, null, null, sort, 1, 2) + .getContent().size(); + } + jobService.getFilterOptions(); + + // 不经 Service,直接走 Specification 的真实 Repository 查询路径 + matched += jobRepository.findAll( + JobSearchSpecification.searchJobs(tag + "岗位", city, "FULL_TIME", 10000, 20000, companyA), + PageRequest.of(0, MAX_RESULTS, Sort.by(Sort.Direction.DESC, "createdAt"))) + .getNumberOfElements(); + return matched; + } + + // ── 夹具 ──────────────────────────────────────────────────── + + private Long createUser(String label, User.Role role) { + Long id = userRepository.save(User.builder() + .phone("16" + String.format("%09d", SEQ.incrementAndGet())) + .nickname(tag + "-" + label) + .role(role) + .roles(Set.of(role)) + .active(true).verified(true).build()).getId(); + userIds.add(id); + return id; + } + + private Long createCompany(Long ownerId, String label) { + Long id = companyRepository.save(Company.builder() + .ownerUserId(ownerId).name(tag + "公司" + label).verified(true).build()).getId(); + companyIds.add(id); + return id; + } + + private Long createJob(Long companyId, String status, Integer salaryMin, Integer salaryMax) { + Long id = jobRepository.save(Job.builder() + .companyId(companyId).title(tag + "岗位").description("描述").requirements("要求") + .city(city).employmentType("FULL_TIME").salaryMin(salaryMin).salaryMax(salaryMax) + .status(status).build()).getId(); + jobIds.add(id); + return id; + } + + private void seedAuxiliaryRows() { + Long publishedJobId = jobIds.get(0); + + insert("applications", + "INSERT INTO applications (job_id, student_user_id, cover_letter, status) VALUES (?, ?, ?, ?)", + publishedJobId, student, tag + "求职信", "SUBMITTED"); + insert("candidate_dimension_scores", + "INSERT INTO candidate_dimension_scores (student_user_id, dimension, period, score, status," + + " sub_scores_json, weight, rules_version, calculated_at, version)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + student, "ABILITY", "2026-09", new BigDecimal("88.50"), "OK", + "{\"q01\":true}", new BigDecimal("12.50"), tag, LocalDateTime.now(), 0L); + insertNotification(tag + "-通知"); + + Long conversationId = insert("conversations", + "INSERT INTO conversations (title, type, created_by) VALUES (?, ?, ?)", + tag + "会话", "DIRECT", ownerA); + insert("messages", + "INSERT INTO messages (conversation_id, sender_user_id, content) VALUES (?, ?, ?)", + conversationId, ownerA, tag + "消息"); + insert("audit_logs", + "INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details)" + + " VALUES (?, ?, ?, ?, ?)", + ownerA, "Q01_SEARCH_PROBE", "JOB", publishedJobId, tag + "审计"); + } + + private Long insertNotification(String title) { + return insert("notifications", + "INSERT INTO notifications (user_id, type, title, content, is_read) VALUES (?, ?, ?, ?, ?)", + student, "SYSTEM", title, tag + "内容", false); + } + + /** 原生 INSERT,登记生成的 id 供精确清理,不依赖实体映射。 */ + private Long insert(String table, String sql, Object... args) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(sql, new String[]{"id"}); + for (int i = 0; i < args.length; i++) { + statement.setObject(i + 1, args[i]); + } + return statement; + }, keyHolder); + Long id = keyHolder.getKey().longValue(); + auxiliaryIds.computeIfAbsent(table, key -> new ArrayList<>()).add(id); + return id; + } + + private static Map rowById(Map>> snapshot, + String table, Long id) { + return snapshot.get(table).stream() + .filter(row -> id.equals(row.get("id"))) + .findFirst() + .orElseThrow(() -> new IllegalStateException("snapshot row missing: " + table + "#" + id)); + } + + private static void deleteAllById(JpaRepository repository, List ids) { + if (!ids.isEmpty()) { + repository.deleteAllById(ids); + } + ids.clear(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java new file mode 100644 index 0000000..668abb9 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java @@ -0,0 +1,270 @@ +package com.mydreamjob.job; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.dto.JobSearchItem; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Q01:{@link JobService#searchJobs} 的**真实查询**覆盖(真实 JobService / JobRepository / + * CompanyRepository / EntityManager,不 Mock {@code findAll})。 + * + *

    原 {@code JobSearchServiceTest} 保留,承担参数校验与编排职责;本类补的是排序、 + * 分页与企业信息组装在真实数据库上的行为。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class JobSearchServiceIntegrationTest { + + private static final AtomicLong SEQ = new AtomicLong(72000); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired private JobService jobService; + @Autowired private JobRepository jobRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + private final List jobIds = new ArrayList<>(); + private final List companyIds = new ArrayList<>(); + private final List userIds = new ArrayList<>(); + + private String tag; + private Long companyA; + + @BeforeEach + void setUp() { + tag = "Q01S" + SEQ.incrementAndGet(); + companyA = createCompany("甲", true); + } + + @AfterEach + void tearDown() { + if (!jobIds.isEmpty()) { + jobRepository.deleteAllById(jobIds); + } + if (!companyIds.isEmpty()) { + companyRepository.deleteAllById(companyIds); + } + if (!userIds.isEmpty()) { + userRepository.deleteAllById(userIds); + } + jobIds.clear(); + companyIds.clear(); + userIds.clear(); + } + + // ── 夹具 ──────────────────────────────────────────────────── + + private Long createCompany(String label, boolean verified) { + Long ownerId = userRepository.save(User.builder() + .phone("19" + String.format("%09d", SEQ.incrementAndGet())) + .nickname(tag + "-" + label) + .role(User.Role.COMPANY) + .roles(Set.of(User.Role.COMPANY)) + .active(true).verified(true).build()).getId(); + userIds.add(ownerId); + Long id = companyRepository.save(Company.builder() + .ownerUserId(ownerId).name(tag + "公司" + label).verified(verified).build()).getId(); + companyIds.add(id); + return id; + } + + private Long publishedJob(Long companyId, String city, String employmentType, + Integer salaryMin, Integer salaryMax) { + Long id = jobRepository.save(Job.builder() + .companyId(companyId) + .title(tag + "岗位") + .description("描述") + .requirements("要求") + .city(city) + .employmentType(employmentType) + .salaryMin(salaryMin) + .salaryMax(salaryMax) + .status(JobStatus.PUBLISHED.name()) + .build()).getId(); + jobIds.add(id); + return id; + } + + private Long jobWithStatus(Long companyId, String city, String status) { + Long id = jobRepository.save(Job.builder() + .companyId(companyId).title(tag + "岗位").description("描述").requirements("要求") + .city(city).employmentType("FULL_TIME").salaryMin(10000).salaryMax(20000) + .status(status).build()).getId(); + jobIds.add(id); + return id; + } + + /** 实体的 @CreatedDate 审计会覆盖显式值,排序用例用原生 SQL 回写。 */ + private void setCreatedAt(Long jobId, LocalDateTime createdAt) { + jdbcTemplate.update("UPDATE jobs SET created_at = ? WHERE id = ?", createdAt, jobId); + } + + private List ids(Page page) { + return page.getContent().stream().map(JobSearchItem::getId).toList(); + } + + // ── 排序 ──────────────────────────────────────────────────── + + @Test + void latestSortUsesCreatedAtThenIdDescending() { + String city = tag + "LATEST"; + LocalDateTime base = LocalDateTime.of(2026, 2, 1, 0, 0); + Long older = publishedJob(companyA, city, "FULL_TIME", 10000, 20000); + Long newer = publishedJob(companyA, city, "FULL_TIME", 10000, 20000); + setCreatedAt(older, base); + setCreatedAt(newer, base.plusDays(1)); + + assertThat(ids(jobService.searchJobs(null, city, null, null, null, null, "latest", 0, 10))) + .as("createdAt DESC:新的在前").containsExactly(newer, older); + + // createdAt 相同时按 id DESC + setCreatedAt(older, base); + setCreatedAt(newer, base); + assertThat(ids(jobService.searchJobs(null, city, null, null, null, null, "latest", 0, 10))) + .as("createdAt 相同时按 id DESC").containsExactly(newer, older); + } + + @Test + void nullSortUsesLatestOrder() { + String city = tag + "NULLSORT"; + LocalDateTime base = LocalDateTime.of(2026, 2, 2, 0, 0); + Long older = publishedJob(companyA, city, "FULL_TIME", 10000, 20000); + Long newer = publishedJob(companyA, city, "FULL_TIME", 10000, 20000); + setCreatedAt(older, base); + setCreatedAt(newer, base.plusDays(1)); + + assertThat(ids(jobService.searchJobs(null, city, null, null, null, null, null, 0, 10))) + .as("sort 为 null 时等价于 latest").containsExactly(newer, older); + } + + @Test + void relevanceCurrentlyUsesLatestOrder() { + String city = tag + "RELEVANCE"; + LocalDateTime base = LocalDateTime.of(2026, 2, 3, 0, 0); + Long older = publishedJob(companyA, city, "FULL_TIME", 10000, 20000); + Long newer = publishedJob(companyA, city, "FULL_TIME", 10000, 20000); + setCreatedAt(older, base); + setCreatedAt(newer, base.plusDays(1)); + + assertThat(ids(jobService.searchJobs(null, city, null, null, null, null, "relevance", 0, 10))) + .as("relevance 当前与 latest 等价(本轮锁定该语义)").containsExactly(newer, older); + } + + // ── salary 排序 ───────────────────────────────────────────── + + @Test + void salarySortUsesMaximumDescendingAndNullLast() { + String city = tag + "SALARY"; + Long high = publishedJob(companyA, city, "FULL_TIME", 10000, 30000); + Long mid = publishedJob(companyA, city, "FULL_TIME", 10000, 20000); + Long nullMax = publishedJob(companyA, city, "FULL_TIME", 10000, null); + + assertThat(ids(jobService.searchJobs(null, city, null, null, null, null, "salary", 0, 10))) + .as("salaryMax DESC 且 NULL 排最后").containsExactly(high, mid, nullMax); + } + + @Test + void salarySortUsesIdDescendingAsTieBreaker() { + String city = tag + "SALARYTIE"; + Long first = publishedJob(companyA, city, "FULL_TIME", 10000, 25000); + Long second = publishedJob(companyA, city, "FULL_TIME", 12000, 25000); + Long third = publishedJob(companyA, city, "FULL_TIME", 14000, 25000); + + assertThat(ids(jobService.searchJobs(null, city, null, null, null, null, "salary", 0, 10))) + .as("salaryMax 相同时按 id DESC") + .containsExactly(third, second, first); + } + + @Test + void salarySortReturnsCorrectTotalAndTotalPages() { + String city = tag + "SALARYPAGE"; + for (int i = 0; i < 5; i++) { + publishedJob(companyA, city, "FULL_TIME", 10000, 20000 + i); + } + + Page page = jobService.searchJobs(null, city, null, null, null, null, "salary", 0, 2); + + assertThat(page.getTotalElements()).as("total 为全部匹配行数,不是当前页行数").isEqualTo(5); + assertThat(page.getTotalPages()).isEqualTo(3); + assertThat(page.getContent()).hasSize(2); + } + + @Test + void secondPageDoesNotRepeatOrLoseRows() { + String city = tag + "PAGE2"; + List created = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + created.add(publishedJob(companyA, city, "FULL_TIME", 10000, 20000 + i)); + } + // salaryMax 递增 → 期望顺序为其反序 + List expected = new ArrayList<>(created); + java.util.Collections.reverse(expected); + + Page p0 = jobService.searchJobs(null, city, null, null, null, null, "salary", 0, 2); + Page p1 = jobService.searchJobs(null, city, null, null, null, null, "salary", 1, 2); + Page p2 = jobService.searchJobs(null, city, null, null, null, null, "salary", 2, 2); + + List all = new ArrayList<>(); + all.addAll(ids(p0)); + all.addAll(ids(p1)); + all.addAll(ids(p2)); + + assertThat(all).as("三页拼接等于完整期望顺序,无重复无遗漏").containsExactlyElementsOf(expected); + assertThat(all).doesNotHaveDuplicates(); + } + + // ── 企业信息 / 状态 ───────────────────────────────────────── + + @Test + void companyInformationIsMappedFromRealRepository() { + String city = tag + "COMPANYINFO"; + Long jobId = publishedJob(companyA, city, "FULL_TIME", 10000, 20000); + + Page page = jobService.searchJobs(null, city, null, null, null, null, "latest", 0, 10); + + assertThat(page.getContent()).hasSize(1); + JobSearchItem item = page.getContent().get(0); + assertThat(item.getId()).isEqualTo(jobId); + assertThat(item.getCompany()).as("企业信息由真实 CompanyRepository 批量组装").isNotNull(); + assertThat(item.getCompany().getId()).isEqualTo(companyA); + assertThat(item.getCompany().getName()).isEqualTo(tag + "公司甲"); + assertThat(item.getCompany().getVerified()).isTrue(); + } + + @Test + void searchDoesNotReturnDraftOrClosedJobs() { + String city = tag + "STATUS"; + Long published = publishedJob(companyA, city, "FULL_TIME", 10000, 20000); + jobWithStatus(companyA, city, JobStatus.DRAFT.name()); + jobWithStatus(companyA, city, JobStatus.CLOSED.name()); + + for (String sort : new String[]{"latest", "relevance", "salary", null}) { + assertThat(ids(jobService.searchJobs(null, city, null, null, null, null, sort, 0, 10))) + .as("非 PUBLISHED 岗位绝不进入公开搜索(sort=" + sort + ")") + .containsExactly(published); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java index 7f910ae..1325c2b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java @@ -28,6 +28,19 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; +/** + * {@link JobService#searchJobs} 的**编排与参数校验**测试(纯 Mockito)。 + * + *

    本类把 {@code JobRepository.findAll(Specification, Pageable)} 与 + * {@code CompanyRepository} 整体 Mock 掉,因此它只能证明参数校验、分页对象、 + * 排序参数传递和 DTO 组装的编排逻辑;**不能证明 Criteria 谓词、SQL 过滤、 + * 排序与分页在真实数据库上的行为**。

    + * + *

    真实查询覆盖见 Q01 补充的 + * {@link JobSearchSpecificationIntegrationTest}(H2 真实 Repository)、 + * {@link JobSearchServiceIntegrationTest}(H2 真实 Service/EntityManager)、 + * {@link JobSearchSpecificationMySqlIntegrationTest}(真实 MySQL 8)。

    + */ @ExtendWith(MockitoExtension.class) class JobSearchServiceTest { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java new file mode 100644 index 0000000..f0b6a55 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java @@ -0,0 +1,425 @@ +package com.mydreamjob.job; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Q01:{@link JobSearchSpecification} 的**真实数据库**覆盖(H2 + 真实 Flyway + 真实 JobRepository)。 + * + *

    与 {@code JobSearchServiceTest} 的区别:这里不 Mock Repository / Specification / + * EntityManager / 事务,直接执行 {@code jobRepository.findAll(spec, pageable)}, + * 断言返回的**ID 及顺序**,从而真正验证 Criteria 谓词、过滤与排序。

    + * + *

    夹具隔离:每个用例使用唯一城市标记,只在自己的数据上断言,不依赖种子岗位。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class JobSearchSpecificationIntegrationTest { + + private static final AtomicLong SEQ = new AtomicLong(71000); + /** 结果集足够大,避免分页测试被默认 size 截断。 */ + private static final int MAX_RESULTS = 50; + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired private JobRepository jobRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + private final List jobIds = new ArrayList<>(); + private final List companyIds = new ArrayList<>(); + private final List userIds = new ArrayList<>(); + + private String tag; + private Long companyA; + private Long companyB; + + @BeforeEach + void setUp() { + tag = "Q01" + SEQ.incrementAndGet(); + companyA = createCompany("甲"); + companyB = createCompany("乙"); + } + + @AfterEach + void tearDown() { + // FK 安全顺序:jobs -> companies -> users + if (!jobIds.isEmpty()) { + jobRepository.deleteAllById(jobIds); + } + if (!companyIds.isEmpty()) { + companyRepository.deleteAllById(companyIds); + } + if (!userIds.isEmpty()) { + userRepository.deleteAllById(userIds); + } + jobIds.clear(); + companyIds.clear(); + userIds.clear(); + } + + // ── 夹具 ──────────────────────────────────────────────────── + + private Long createCompany(String label) { + Long ownerId = userRepository.save(User.builder() + .phone("18" + String.format("%09d", SEQ.incrementAndGet())) + .nickname(tag + "-" + label) + .role(User.Role.COMPANY) + .roles(Set.of(User.Role.COMPANY)) + .active(true).verified(true).build()).getId(); + userIds.add(ownerId); + Long id = companyRepository.save(Company.builder() + .ownerUserId(ownerId).name(tag + "公司" + label).verified(true).build()).getId(); + companyIds.add(id); + return id; + } + + private Long job(Long companyId, String title, String description, String requirements, + String city, String employmentType, Integer salaryMin, Integer salaryMax, String status) { + Long id = jobRepository.save(Job.builder() + .companyId(companyId) + .title(title) + .description(description) + .requirements(requirements) + .city(city) + .employmentType(employmentType) + .salaryMin(salaryMin) + .salaryMax(salaryMax) + .status(status) + .build()).getId(); + jobIds.add(id); + return id; + } + + private Long publishedJob(Long companyId, String title, String description, String requirements, + String city, String employmentType, Integer salaryMin, Integer salaryMax) { + return job(companyId, title, description, requirements, city, employmentType, + salaryMin, salaryMax, JobStatus.PUBLISHED.name()); + } + + /** + * 回写 createdAt:实体的 {@code @CreatedDate} 审计会在持久化时覆盖显式值, + * 排序用例需要精确时间,因此用原生 SQL 回写(不经过审计)。 + */ + private void setCreatedAt(Long jobId, LocalDateTime createdAt) { + jdbcTemplate.update("UPDATE jobs SET created_at = ? WHERE id = ?", createdAt, jobId); + } + + /** 与服务一致的 latest 排序:createdAt DESC, id DESC。 */ + private Pageable latest( int size) { + return PageRequest.of(0, size, + Sort.by(new Sort.Order(Sort.Direction.DESC, "createdAt"), + new Sort.Order(Sort.Direction.DESC, "id"))); + } + + private List searchIds(String keyword, String city, String employmentType, + Integer salaryMin, Integer salaryMax, Long companyId) { + return jobRepository.findAll( + JobSearchSpecification.searchJobs(keyword, city, employmentType, salaryMin, salaryMax, companyId), + latest(MAX_RESULTS)) + .getContent().stream().map(Job::getId).toList(); + } + + // ── keyword:三个字段 ─────────────────────────────────────── + + @Test + void keywordMatchesTitle() { + String city = tag + "CITY_TITLE"; + Long inTitle = publishedJob(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); + // 其余两个字段均不含 "Java",确保命中的是 title + publishedJob(companyA, "无关岗位", "无关描述", "无关要求", city, "FULL_TIME", 10000, 20000); + + assertThat(searchIds("Java", city, null, null, null, null)).containsExactly(inTitle); + } + + @Test + void keywordMatchesDescription() { + String city = tag + "CITY_DESC"; + Long inDesc = publishedJob(companyA, "岗位", tag + "负责Java后端开发", "要求", city, "FULL_TIME", 10000, 20000); + publishedJob(companyA, "岗位", "无关描述", "要求", city, "FULL_TIME", 10000, 20000); + + assertThat(searchIds("Java", city, null, null, null, null)).containsExactly(inDesc); + } + + @Test + void keywordMatchesRequirements() { + String city = tag + "CITY_REQ"; + Long inReq = publishedJob(companyA, "岗位", "描述", tag + "熟悉Java", city, "FULL_TIME", 10000, 20000); + publishedJob(companyA, "岗位", "描述", "无关要求", city, "FULL_TIME", 10000, 20000); + + assertThat(searchIds("Java", city, null, null, null, null)).containsExactly(inReq); + } + + @Test + void keywordIsCaseInsensitive() { + String city = tag + "CITY_CASE"; + Long lower = publishedJob(companyA, tag + "java开发", "描述", "要求", city, "FULL_TIME", 10000, 20000); + Long upper = publishedJob(companyA, tag + "JAVA开发", "描述", "要求", city, "FULL_TIME", 10000, 20000); + + assertThat(searchIds("java", city, null, null, null, null)) + .containsExactlyInAnyOrder(lower, upper); + assertThat(searchIds("JaVa", city, null, null, null, null)) + .containsExactlyInAnyOrder(lower, upper); + } + + @Test + void nullBlankAndWhitespaceKeywordDoNotFilter() { + String city = tag + "CITY_NOKW"; + Long a = publishedJob(companyA, tag + "甲岗", "描述", "要求", city, "FULL_TIME", 10000, 20000); + Long b = publishedJob(companyA, tag + "乙岗", "描述", "要求", city, "FULL_TIME", 10000, 20000); + + for (String blank : new String[]{null, "", " ", " ", "\t"}) { + assertThat(searchIds(blank, city, null, null, null, null)) + .as("blank keyword 不应参与过滤:" + blank) + .containsExactlyInAnyOrder(a, b); + } + } + + @Test + void keywordIsTrimmed() { + String city = tag + "CITY_TRIM"; + Long target = publishedJob(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); + + assertThat(searchIds(" Java ", city, null, null, null, null)).containsExactly(target); + } + + // ── city / employmentType / companyId ────────────────────── + + @Test + void cityUsesTrimmedExactMatch() { + String city = tag + "CITY_EXACT"; + Long target = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + publishedJob(companyA, "岗位", "描述", "要求", city + "别处", "FULL_TIME", 10000, 20000); + + // 精确匹配:前缀相同但不同的城市不得命中 + assertThat(searchIds(null, city, null, null, null, null)).containsExactly(target); + // trim 后精确匹配 + assertThat(searchIds(null, " " + city + " ", null, null, null, null)).containsExactly(target); + } + + @Test + void cityCaseVariantHasSameResultAcrossDatabases() { + String city = tag + "-Beijing"; + Long target = publishedJob(companyA, "岗位", "描述", "要求", city, + "FULL_TIME", 10000, 20000); + + assertThat(searchIds(null, city.toLowerCase(), null, null, null, null)) + .containsExactly(target); + assertThat(searchIds(null, " " + city.toLowerCase() + " ", null, null, null, null)) + .containsExactly(target); + } + + @Test + void employmentTypeUsesTrimmedExactMatch() { + String city = tag + "CITY_ET"; + Long full = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + publishedJob(companyA, "岗位", "描述", "要求", city, "PART_TIME", 10000, 20000); + + assertThat(searchIds(null, city, "FULL_TIME", null, null, null)).containsExactly(full); + assertThat(searchIds(null, city, " FULL_TIME ", null, null, null)).containsExactly(full); + // 精确匹配:不得做前缀匹配 + assertThat(searchIds(null, city, "FULL", null, null, null)).isEmpty(); + } + + @Test + void employmentTypeCaseVariantHasSameResultAcrossDatabases() { + String city = tag + "CITY_EMP_CASE"; + Long target = publishedJob(companyA, "岗位", "描述", "要求", city, + "FULL_TIME", 10000, 20000); + + assertThat(searchIds(null, city, "full_time", null, null, null)).containsExactly(target); + assertThat(searchIds(null, city, " full_time ", null, null, null)).containsExactly(target); + } + + @Test + void companyIdFiltersExactly() { + String city = tag + "CITY_COMPANY"; + Long mine = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + publishedJob(companyB, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + + assertThat(searchIds(null, city, null, null, null, companyA)).containsExactly(mine); + } + + // ── 薪资:区间重叠语义 ────────────────────────────────────── + + @Test + void salaryMinMatchesWhenJobMaximumEqualsBoundary() { + String city = tag + "CITY_SMIN"; + // 岗位 salaryMax 恰好等于用户 salaryMin + Long boundary = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 19999); + + assertThat(searchIds(null, city, null, 20000, null, null)).containsExactly(boundary); + } + + @Test + void salaryMaxMatchesWhenJobMinimumEqualsBoundary() { + String city = tag + "CITY_SMAX"; + // 岗位 salaryMin 恰好等于用户 salaryMax + Long boundary = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10001, 20000); + + assertThat(searchIds(null, city, null, null, 10000, null)).containsExactly(boundary); + } + + @Test + void salaryRangesUseOverlapSemantics() { + String city = tag + "CITY_OVERLAP"; + // 岗位区间 [10k,20k] 与用户区间 [15k,25k] 部分重叠 —— 必须命中 + Long overlapping = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + // 岗位区间完全包含用户区间 —— 必须命中 + Long containing = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 12000, 30000); + + assertThat(searchIds(null, city, null, 15000, 25000, null)) + .containsExactlyInAnyOrder(overlapping, containing); + } + + @Test + void nonOverlappingSalaryRangeIsExcluded() { + String city = tag + "CITY_NOOVERLAP"; + Long tooLow = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 5000, 9000); + Long inRange = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 15000, 25000); + Long tooHigh = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 40000, 60000); + + assertThat(searchIds(null, city, null, 10000, 30000, null)) + .as("只有区间重叠的岗位命中") + .containsExactly(inRange); + } + + @Test + void nullSalaryFieldsFollowExistingContract() { + String city = tag + "CITY_NULLSAL"; + Long bothNull = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", null, null); + Long onlyMin = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, null); + Long both = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + + // 不加薪资条件时,NULL 薪资岗位必须照常返回 + assertThat(searchIds(null, city, null, null, null, null)) + .containsExactlyInAnyOrder(bothNull, onlyMin, both); + + // 加了 salaryMin 条件后:`job.salaryMax >= 5000` 对 NULL 求值为 UNKNOWN, + // 因此 salaryMax 为 NULL 的两个岗位都被排除,只余 salaryMax 非空且达标的岗位。 + // 这是 SQL 三值逻辑的既有契约,Q01 锁定该行为、不做修改。 + assertThat(searchIds(null, city, null, 5000, null, null)).containsExactly(both); + } + + // ── 多条件 AND / 状态 ─────────────────────────────────────── + + @Test + void multipleConditionsUseAndSemantics() { + String city = tag + "CITY_AND"; + Long match = publishedJob(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); + // 关键词命中但城市不符 + publishedJob(companyA, tag + "Java工程师", "描述", "要求", city + "X", "FULL_TIME", 10000, 20000); + // 城市命中但用工类型不符 + publishedJob(companyA, tag + "Java工程师", "描述", "要求", city, "PART_TIME", 10000, 20000); + // 公司不符 + publishedJob(companyB, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); + // 薪资不重叠 + publishedJob(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 90000, 99000); + + assertThat(searchIds("Java", city, "FULL_TIME", 15000, 25000, companyA)).containsExactly(match); + } + + @Test + void nonPublishedJobsAreAlwaysExcluded() { + String city = tag + "CITY_STATUS"; + Long published = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + job(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000, JobStatus.DRAFT.name()); + job(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000, JobStatus.CLOSED.name()); + + assertThat(searchIds(null, city, null, null, null, null)).containsExactly(published); + // 即使显式按公司过滤,非公开岗位也不得出现 + assertThat(searchIds(null, city, null, null, null, companyA)).containsExactly(published); + } + + // ── 特殊字符 / 分页 / 空结果 ──────────────────────────────── + + @Test + void specialLikeCharactersFollowExistingContract() { + String city = tag + "CITY_LIKE"; + Long plain = publishedJob(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); + Long percent = publishedJob(companyA, tag + "100%远程", "描述", "要求", city, "FULL_TIME", 10000, 20000); + Long underscore = publishedJob(companyA, tag + "A_B岗", "描述", "要求", city, "FULL_TIME", 10000, 20000); + + // 锁定当前契约:keyword 直接拼进 LIKE,% 与 _ 仍作通配符(未转义)。 + // 这是既有行为,Q01 不改语义,仅在真实库上固定它。 + assertThat(searchIds("%", city, null, null, null, null)) + .as("% 作为通配符:命中该城市下全部带标题的岗位") + .containsExactlyInAnyOrder(plain, percent, underscore); + assertThat(searchIds("_", city, null, null, null, null)) + .as("_ 作为单字符通配符:命中标题含任意单字符的岗位") + .containsExactlyInAnyOrder(plain, percent, underscore); + assertThat(searchIds("A_B", city, null, null, null, null)) + .as("A_B 以通配符解释,可命中 A_B 本身") + .containsExactly(underscore); + assertThat(searchIds("Java", city, null, null, null, null)).containsExactly(plain); + } + + @Test + void paginationReturnsStableOrderedIds() { + String city = tag + "CITY_PAGE"; + LocalDateTime base = LocalDateTime.of(2026, 1, 1, 0, 0); + List ids = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Long id = publishedJob(companyA, "岗位" + i, "描述", "要求", city, "FULL_TIME", 10000, 20000); + setCreatedAt(id, base.plusDays(i)); // 越大越新 + ids.add(id); + } + // 期望顺序:createdAt DESC(越新越前) + List expected = new ArrayList<>(ids); + java.util.Collections.reverse(expected); + + Pageable first = PageRequest.of(0, 2, + Sort.by(new Sort.Order(Sort.Direction.DESC, "createdAt"), new Sort.Order(Sort.Direction.DESC, "id"))); + Pageable second = PageRequest.of(1, 2, + Sort.by(new Sort.Order(Sort.Direction.DESC, "createdAt"), new Sort.Order(Sort.Direction.DESC, "id"))); + + var spec = JobSearchSpecification.searchJobs(null, city, null, null, null, null); + var page0 = jobRepository.findAll(spec, first); + var page1 = jobRepository.findAll(spec, second); + + assertThat(page0.getContent().stream().map(Job::getId).toList()) + .as("第 0 页按 createdAt DESC 稳定排序").containsExactlyElementsOf(expected.subList(0, 2)); + assertThat(page1.getContent().stream().map(Job::getId).toList()) + .as("第 1 页继续按同一顺序,不重复不丢行").containsExactlyElementsOf(expected.subList(2, 4)); + assertThat(page0.getTotalElements()).as("total 为全部匹配行数").isEqualTo(5); + assertThat(page0.getTotalPages()).isEqualTo(3); + } + + @Test + void noResultReturnsEmptyPage() { + String city = tag + "CITY_EMPTY"; + publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + + var spec = JobSearchSpecification.searchJobs(tag + "不存在的关键词XYZ", city, null, null, null, null); + var page = jobRepository.findAll(spec, latest(MAX_RESULTS)); + + assertThat(page.getContent()).isEmpty(); + assertThat(page.getTotalElements()).isZero(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java new file mode 100644 index 0000000..4657999 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java @@ -0,0 +1,384 @@ +package com.mydreamjob.job; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Q01:{@link JobSearchSpecification} 在**真实 MySQL 8** 上的行为对账。 + * + *

    使用 Testcontainers 启动独立 MySQL(不复用开发库),执行正式 Flyway 迁移并以 + * {@code ddl-auto=validate} 启动,重点对账 H2 难以覆盖的差异:大小写比较规则、 + * LIKE 特殊字符、NULL 排序、边界比较与分页稳定性。

    + * + *

    不打印任何数据库凭据。Docker 不可用时环境性跳过({@code disabledWithoutDocker}), + * 但独立验收必须显式运行本类并得到 Tests run > 0、Skipped = 0。

    + */ +@Testcontainers(disabledWithoutDocker = true) +@SpringBootTest +@ActiveProfiles("mysql-it") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class JobSearchSpecificationMySqlIntegrationTest { + + private static final AtomicLong SEQ = new AtomicLong(73000); + private static final int MAX_RESULTS = 50; + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("mydreamjob_q01") + .withUsername("q01") + .withPassword("q01-test-only"); + + /** + * 连接参数全部来自容器,运行期注入,不落盘、不打印密码。 + * 应用级测试开关与测试密钥由 {@code mysql-it} profile 提供(见 + * {@code src/test/resources/application-mysql-it.yml}),方言沿用主配置的 MySQLDialect。 + */ + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired private JobRepository jobRepository; + @Autowired private JobService jobService; + @Autowired private CompanyRepository companyRepository; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + private final List jobIds = new ArrayList<>(); + private final List companyIds = new ArrayList<>(); + private final List userIds = new ArrayList<>(); + + private String tag; + private Long companyA; + private Long companyB; + + @BeforeEach + void setUp() { + tag = "Q01M" + SEQ.incrementAndGet(); + companyA = createCompany("甲"); + companyB = createCompany("乙"); + } + + @AfterEach + void tearDown() { + if (!jobIds.isEmpty()) { + jobRepository.deleteAllById(jobIds); + } + if (!companyIds.isEmpty()) { + companyRepository.deleteAllById(companyIds); + } + if (!userIds.isEmpty()) { + userRepository.deleteAllById(userIds); + } + jobIds.clear(); + companyIds.clear(); + userIds.clear(); + } + + private Long createCompany(String label) { + Long ownerId = userRepository.save(User.builder() + .phone("17" + String.format("%09d", SEQ.incrementAndGet())) + .nickname(tag + "-" + label) + .role(User.Role.COMPANY) + .roles(Set.of(User.Role.COMPANY)) + .active(true).verified(true).build()).getId(); + userIds.add(ownerId); + Long id = companyRepository.save(Company.builder() + .ownerUserId(ownerId).name(tag + "公司" + label).verified(true).build()).getId(); + companyIds.add(id); + return id; + } + + private Long job(Long companyId, String title, String description, String requirements, + String city, String employmentType, Integer salaryMin, Integer salaryMax, String status) { + Long id = jobRepository.save(Job.builder() + .companyId(companyId).title(title).description(description).requirements(requirements) + .city(city).employmentType(employmentType).salaryMin(salaryMin).salaryMax(salaryMax) + .status(status).build()).getId(); + jobIds.add(id); + return id; + } + + private Long published(Long companyId, String title, String description, String requirements, + String city, String employmentType, Integer salaryMin, Integer salaryMax) { + return job(companyId, title, description, requirements, city, employmentType, + salaryMin, salaryMax, JobStatus.PUBLISHED.name()); + } + + private void setCreatedAt(Long jobId, LocalDateTime createdAt) { + jdbcTemplate.update("UPDATE jobs SET created_at = ? WHERE id = ?", createdAt, jobId); + } + + private Pageable latestSorted() { + return PageRequest.of(0, MAX_RESULTS, + Sort.by(new Sort.Order(Sort.Direction.DESC, "createdAt"), + new Sort.Order(Sort.Direction.DESC, "id"))); + } + + private List searchIds(String keyword, String city, String employmentType, + Integer salaryMin, Integer salaryMax, Long companyId) { + return jobRepository.findAll( + JobSearchSpecification.searchJobs(keyword, city, employmentType, salaryMin, salaryMax, companyId), + latestSorted()) + .getContent().stream().map(Job::getId).toList(); + } + + // ── 与 H2 逐项对账的场景 ──────────────────────────────────── + + @Test + void keywordCaseInsensitiveMatchesThreeFields() { + String city = tag + "MYSQL_KW"; + Long inTitle = published(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); + Long inDesc = published(companyA, "岗位", tag + "负责Java开发", "要求", city, "FULL_TIME", 10000, 20000); + Long inReq = published(companyA, "岗位", "描述", tag + "熟悉JAVA", city, "FULL_TIME", 10000, 20000); + published(companyA, "无关岗位", "无关描述", "无关要求", city, "FULL_TIME", 10000, 20000); + + assertThat(searchIds("java", city, null, null, null, null)) + .as("MySQL 下大小写不敏感且覆盖三字段") + .containsExactlyInAnyOrder(inTitle, inDesc, inReq); + assertThat(searchIds("JaVa", city, null, null, null, null)) + .containsExactlyInAnyOrder(inTitle, inDesc, inReq); + } + + @Test + void nullBlankAndWhitespaceKeywordDoNotFilter() { + String city = tag + "MYSQL_NOKW"; + Long a = published(companyA, tag + "甲岗", "描述", "要求", city, "FULL_TIME", 10000, 20000); + Long b = published(companyA, tag + "乙岗", "描述", "要求", city, "FULL_TIME", 10000, 20000); + + for (String blank : new String[]{null, "", " ", " ", "\t"}) { + assertThat(searchIds(blank, city, null, null, null, null)) + .as("blank keyword 不应参与过滤:" + blank) + .containsExactlyInAnyOrder(a, b); + } + } + + @Test + void keywordIsTrimmed() { + String city = tag + "MYSQL_TRIM"; + Long target = published(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); + + assertThat(searchIds(" Java ", city, null, null, null, null)).containsExactly(target); + } + + @Test + void specialLikeCharactersFollowExistingContract() { + String city = tag + "MYSQL_LIKE"; + Long plain = published(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); + Long percent = published(companyA, tag + "100%远程", "描述", "要求", city, "FULL_TIME", 10000, 20000); + Long underscore = published(companyA, tag + "A_B岗", "描述", "要求", city, "FULL_TIME", 10000, 20000); + + // 锁定既有契约:% 与 _ 未转义,仍作 LIKE 通配符 —— MySQL 与 H2 行为一致。 + assertThat(searchIds("%", city, null, null, null, null)) + .containsExactlyInAnyOrder(plain, percent, underscore); + assertThat(searchIds("_", city, null, null, null, null)) + .containsExactlyInAnyOrder(plain, percent, underscore); + assertThat(searchIds("A_B", city, null, null, null, null)).containsExactly(underscore); + } + + @Test + void cityAndEmploymentTypeUseTrimmedExactMatch() { + String city = tag + "MYSQL_CITY"; + Long target = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + Long otherCity = published(companyA, "岗位", "描述", "要求", city + "别处", "FULL_TIME", 10000, 20000); + Long otherType = published(companyA, "岗位", "描述", "要求", city, "PART_TIME", 10000, 20000); + + // 只给 city 条件时,同城的其他用工类型同样必须返回(city 与 employmentType 是两个独立条件); + // 前缀相同但不同的城市不得命中。 + assertThat(searchIds(null, city, null, null, null, null)) + .as("city 精确匹配:同城全部命中,前缀相同的 city+别处 不命中") + .containsExactly(otherType, target); + assertThat(searchIds(null, city, null, null, null, null)).doesNotContain(otherCity); + // 两个条件都给出时,trim 后各自精确匹配 + assertThat(searchIds(null, " " + city + " ", " FULL_TIME ", null, null, null)) + .containsExactly(target); + } + + @Test + void cityCaseVariantHasSameResultAcrossDatabases() { + String city = tag + "-Beijing"; + Long target = published(companyA, "岗位", "描述", "要求", city, + "FULL_TIME", 10000, 20000); + + assertThat(searchIds(null, city.toLowerCase(), null, null, null, null)) + .containsExactly(target); + assertThat(searchIds(null, " " + city.toLowerCase() + " ", null, null, null, null)) + .containsExactly(target); + } + + @Test + void employmentTypeCaseVariantHasSameResultAcrossDatabases() { + String city = tag + "MYSQL_EMP_CASE"; + Long target = published(companyA, "岗位", "描述", "要求", city, + "FULL_TIME", 10000, 20000); + + assertThat(searchIds(null, city, "full_time", null, null, null)).containsExactly(target); + assertThat(searchIds(null, city, " full_time ", null, null, null)).containsExactly(target); + } + + @Test + void salaryBoundariesUseOverlapSemantics() { + String city = tag + "MYSQL_SALARY"; + Long boundary = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + Long noOverlapLow = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 5000, 9000); + Long noOverlapHigh = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 40000, 60000); + + // 契约是**单边**比较:salaryMin 只约束「岗位 salaryMax >= 用户 salaryMin」, + // 因此边界相等必须命中,salaryMax 更高的岗位同样命中,低于边界的才被排除。 + assertThat(searchIds(null, city, null, 20000, null, null)) + .as("岗位 salaryMax >= 20000:边界 20000 与 60000 命中,9000 排除") + .containsExactly(noOverlapHigh, boundary); + assertThat(searchIds(null, city, null, null, 10000, null)) + .as("岗位 salaryMin <= 10000:边界 10000 与 5000 命中,40000 排除") + .containsExactly(noOverlapLow, boundary); + // 两端都给出时按区间重叠:部分重叠命中 + assertThat(searchIds(null, city, null, 15000, 25000, null)).containsExactly(boundary); + // 区间不重叠(高于用户区间 / 低于用户区间)必须排除 + assertThat(searchIds(null, city, null, 30000, 35000, null)).isEmpty(); + assertThat(searchIds(null, city, null, 1000, 4000, null)).isEmpty(); + } + + @Test + void nullSalaryFieldsFollowExistingContract() { + String city = tag + "MYSQL_NULLSAL"; + Long bothNull = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", null, null); + Long onlyMin = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, null); + Long both = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + + // 不加薪资条件时,NULL 薪资岗位照常返回 + assertThat(searchIds(null, city, null, null, null, null)) + .containsExactlyInAnyOrder(bothNull, onlyMin, both); + + // 一旦给出 salaryMin,`job.salaryMax >= ?` 对 NULL 求值为 UNKNOWN, + // salaryMax 为 NULL 的两个岗位被排除 —— SQL 三值逻辑的既有契约,Q01 锁定不改。 + assertThat(searchIds(null, city, null, 5000, null, null)).containsExactly(both); + } + + @Test + void multipleConditionsUseAndSemantics() { + String city = tag + "MYSQL_AND"; + Long match = published(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); + // 关键词命中但城市不符 + published(companyA, tag + "Java工程师", "描述", "要求", city + "X", "FULL_TIME", 10000, 20000); + // 城市命中但用工类型不符 + published(companyA, tag + "Java工程师", "描述", "要求", city, "PART_TIME", 10000, 20000); + // 公司不符 + published(companyB, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); + // 薪资不重叠 + published(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 90000, 99000); + + assertThat(searchIds("Java", city, "FULL_TIME", 15000, 25000, companyA)).containsExactly(match); + } + + @Test + void nonPublishedJobsAreAlwaysExcluded() { + String city = tag + "MYSQL_STATUS"; + Long published = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + job(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000, JobStatus.DRAFT.name()); + job(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000, JobStatus.CLOSED.name()); + + assertThat(searchIds(null, city, null, null, null, null)).containsExactly(published); + assertThat(searchIds(null, city, null, null, null, companyA)).containsExactly(published); + } + + @Test + void latestOrderAndPaginationAreStable() { + String city = tag + "MYSQL_PAGE"; + LocalDateTime base = LocalDateTime.of(2026, 3, 1, 0, 0); + List ids = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Long id = published(companyA, "岗位" + i, "描述", "要求", city, "FULL_TIME", 10000, 20000); + setCreatedAt(id, base.plusDays(i)); + ids.add(id); + } + List expected = new ArrayList<>(ids); + java.util.Collections.reverse(expected); + + var spec = JobSearchSpecification.searchJobs(null, city, null, null, null, null); + Pageable first = PageRequest.of(0, 2, latestSorted().getSort()); + Pageable second = PageRequest.of(1, 2, latestSorted().getSort()); + + var p0 = jobRepository.findAll(spec, first); + var p1 = jobRepository.findAll(spec, second); + + assertThat(p0.getContent().stream().map(Job::getId).toList()) + .containsExactlyElementsOf(expected.subList(0, 2)); + assertThat(p1.getContent().stream().map(Job::getId).toList()) + .containsExactlyElementsOf(expected.subList(2, 4)); + assertThat(p0.getTotalElements()).isEqualTo(5); + assertThat(p0.getTotalPages()).isEqualTo(3); + } + + @Test + void salarySortNullLastIsConsistentWithH2() { + String city = tag + "MYSQL_NULLLAST"; + Long high = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 30000); + Long mid = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + Long nullMax = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, null); + + // 通过 Service 的 CASE WHEN 排序路径验证 NULL LAST + assertThat(idsViaSalarySort(city)).as("salaryMax DESC,NULL 最后") + .containsExactly(high, mid, nullMax); + } + + /** 直接调用 Service 的 salary 排序路径(真实 EntityManager + CASE WHEN)。 */ + private List idsViaSalarySort(String city) { + return jobService.searchJobs(null, city, null, null, null, null, "salary", 0, MAX_RESULTS) + .getContent().stream().map(com.mydreamjob.job.dto.JobSearchItem::getId).toList(); + } + + @Test + void companyIdFiltersExactly() { + String city = tag + "MYSQL_COMPANY"; + Long mine = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + published(companyB, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + + assertThat(searchIds(null, city, null, null, null, companyA)).containsExactly(mine); + } + + @Test + void noResultReturnsEmptyPage() { + String city = tag + "MYSQL_EMPTY"; + published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); + + var page = jobRepository.findAll( + JobSearchSpecification.searchJobs(tag + "不存在XYZ", city, null, null, null, null), + latestSorted()); + assertThat(page.getContent()).isEmpty(); + assertThat(page.getTotalElements()).isZero(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/Q01SqlRecorder.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/Q01SqlRecorder.java new file mode 100644 index 0000000..aa3a6a0 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/Q01SqlRecorder.java @@ -0,0 +1,34 @@ +package com.mydreamjob.job; + +import org.hibernate.resource.jdbc.spi.StatementInspector; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Q01 EXPLAIN 证据用:记录 Hibernate 实际执行过的 SQL。 + * + *

    通过 {@code hibernate.session_factory.statement_inspector} 注册(仅 MySQL EXPLAIN + * 用例使用),用来在报告中留下**真实生成**的 SQL 原文,而不是只留一份手写的等价 SQL。

    + */ +public class Q01SqlRecorder implements StatementInspector { + + private static final List STATEMENTS = Collections.synchronizedList(new ArrayList<>()); + + @Override + public String inspect(String sql) { + STATEMENTS.add(sql); + return sql; + } + + static void clear() { + STATEMENTS.clear(); + } + + static List drain() { + List copy = new ArrayList<>(STATEMENTS); + STATEMENTS.clear(); + return copy; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java index e29fcd2..4b957be 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java @@ -3,6 +3,8 @@ package com.mydreamjob.match; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.job.Job; @@ -34,6 +36,9 @@ class MatchServiceTest { @Mock private StudentProfileRepository profileRepository; @Mock private AuditService auditService; @Mock private UserRepository userRepository; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private MatchService matchService; private static final Long STUDENT_ID = 100L; @@ -45,6 +50,9 @@ class MatchServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + publishedJob = Job.builder().id(JOB_ID).companyId(1L).title("Java 后端") .city("杭州").employmentType("FULL_TIME") .salaryMax(20000).status("PUBLISHED").build(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageListQueryBudgetIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageListQueryBudgetIntegrationTest.java index b8c20ea..4cbb58d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageListQueryBudgetIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageListQueryBudgetIntegrationTest.java @@ -120,6 +120,76 @@ class MessageListQueryBudgetIntegrationTest { + measuredStatements); } + @Test + void listConversationsUsesFixedQueriesForOneTwentyAndFiftyConversations() { + List measuredStatements = new ArrayList<>(); + for (int count : List.of(1, 20, 50)) { + long reader = user("conversation-reader-" + count); + for (int i = 0; i < count; i++) { + long sender = user("conversation-sender-" + count + "-" + i); + long conversation = conversation(sender, reader, i % 2 == 0 ? "DIRECT" : "TALENT_INVITATION"); + Message unread = messageRepository.save(Message.builder() + .conversationId(conversation).senderUserId(sender).content("unread") + .status("SENT").version(0L).build()); + Message read = messageRepository.save(Message.builder() + .conversationId(conversation).senderUserId(sender).content("read") + .status("SENT").version(0L).build()); + messageRepository.save(Message.builder() + .conversationId(conversation).senderUserId(sender) + .content(MessageViewMapper.RECALLED_PLACEHOLDER) + .status("RECALLED").version(1L).recalledAt(LocalDateTime.now()).build()); + readRepository.save(MessageRead.builder().messageId(read.getId()).readerUserId(reader).build()); + assertTrue(unread.getId() > 0); + } + messageRepository.flush(); + readRepository.flush(); + + countingDataSource.reset(); + List> response = messagingService.listConversations(reader); + int statements = countingDataSource.messageStatements(); + measuredStatements.add(statements); + + assertEquals(count, response.size()); + assertTrue(response.stream().allMatch(item -> Long.valueOf(1L).equals(item.get("unreadCount"))), + "only the unread SENT message must be counted"); + assertTrue(statements <= 3, + "membership, conversations and grouped unread count must be the only query budget: " + + statements); + } + assertEquals(1, measuredStatements.stream().distinct().count(), + "conversation query count must not grow with conversation count: " + measuredStatements); + } + + @Test + void groupedUnreadCountKeepsHistoricalLatestFiveHundredWindow() { + long reader = user("window-reader"); + long sender = user("window-sender"); + long conversation = conversation(sender, reader, "DIRECT"); + LocalDateTime base = LocalDateTime.of(2026, 9, 16, 8, 0); + List messages = new ArrayList<>(); + for (int i = 0; i < 501; i++) { + boolean countedSent = i == 0 || i == 500; + messages.add(Message.builder() + .conversationId(conversation).senderUserId(sender) + .content(countedSent ? "sent" : MessageViewMapper.RECALLED_PLACEHOLDER) + .status(countedSent ? "SENT" : "RECALLED") + .version(countedSent ? 0L : 1L) + .recalledAt(countedSent ? null : base.plusSeconds(i)) + .createdAt(base.plusSeconds(i)).build()); + } + List saved = messageRepository.saveAllAndFlush(messages); + for (int i = 0; i < saved.size(); i++) { + jdbcTemplate.update("UPDATE messages SET created_at=? WHERE id=?", + java.sql.Timestamp.valueOf(base.plusSeconds(i)), saved.get(i).getId()); + } + + List> response = messagingService.listConversations(reader); + + assertEquals(1, response.size()); + assertEquals(1L, response.get(0).get("unreadCount"), + "oldest SENT message is outside the historical latest-500 window"); + } + private long user(String nickname) { User saved = userRepository.saveAndFlush(User.builder() .phone("139" + Math.abs(System.nanoTime() % 1_000_000_000L)) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallControllerTest.java index ef60f34..5bc68e1 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallControllerTest.java @@ -45,7 +45,7 @@ class MessageRecallControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java index e248bf3..6657174 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java @@ -118,8 +118,7 @@ class MessageRecallHttpIntegrationTest { } private String token(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", - java.util.Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java index f8d1db3..9329046 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java @@ -47,7 +47,7 @@ class MessagingControllerTest { } private String token() { - return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "1", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT"); } @Test void listConversations_shouldReturn200() throws Exception { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationControllerTest.java index 9dfb5ad..dcef124 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationControllerTest.java @@ -42,7 +42,7 @@ class NotificationControllerTest { } private String token() { - return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "1", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT"); } @Test void list_shouldReturn200() throws Exception { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java index c7a6030..383e514 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java @@ -61,11 +61,11 @@ class OfferControllerTest { } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(COMPANY_USER_ID, "1", Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(COMPANY_USER_ID, "COMPANY"); } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT"); } private Map offerView() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryRefreshRegistrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryRefreshRegistrationTest.java new file mode 100644 index 0000000..a8d901e --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryRefreshRegistrationTest.java @@ -0,0 +1,278 @@ +package com.mydreamjob.offer; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * R01-D4-2 阻断缺陷 F1:Offer 自动过期必须登记评分刷新任务。 + * + *

    缺陷背景:{@code OfferExpiryWorker#expireOne} 直接把 Offer 置为 EXPIRED, + * 而 {@code CandidateScoringCalculator#queryOffers} 以 {@code status='EXPIRED'} 计数 + * 参与 INTEGRITY 维度评分 —— 该写侧事件此前不登记刷新任务。

    + * + *

    本测试断言真实数据库状态(offers / candidate_scoring_refresh_tasks / notifications), + * 不依赖对 requestRefresh 的 mock 验证。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class OfferExpiryRefreshRegistrationTest { + + @Autowired private OfferExpiryWorker worker; + @Autowired private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService requestService; + @Autowired private OfferRepository offerRepository; + @Autowired private OfferStatusHistoryRepository historyRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private JobRepository jobRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + /** 仅用于制造"事务内后续步骤失败"以验证原子性;正常路径行为不变。 */ + @MockBean private OfferCounterOfferRepository counterOfferRepository; + + private static final AtomicLong SEQ = new AtomicLong(3000); + + private Long companyId; + private Long companyUserId; + + /** 与 CandidateScoringRefreshRequestService 保持一致:period = capturedNow 所在 yyyy-MM。 */ + private String period() { + LocalDateTime n = LocalDateTime.now(); + return String.format("%04d-%02d", n.getYear(), n.getMonthValue()); + } + + private long taskCount(Long studentId) { + Long c = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", + Long.class, studentId); + return c == null ? -1 : c; + } + + /** userId 为 null 时统计全部通知行。 */ + private long notificationCount(Long userId) { + Long c = (userId == null) + ? jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notifications", Long.class) + : jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM notifications WHERE user_id=?", Long.class, userId); + return c == null ? -1 : c; + } + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + jdbcTemplate.update("DELETE FROM candidate_feedback"); + historyRepository.deleteAll(); // offer_status_history 引用 offers,必须先删 + jdbcTemplate.update("DELETE FROM offer_reminders"); + jdbcTemplate.update("DELETE FROM offer_counter_offers"); + offerRepository.deleteAll(); + applicationRepository.deleteAll(); + jobRepository.deleteAll(); + companyRepository.deleteAll(); + + User companyUser = userRepository.save(User.builder() + .phone("198" + String.format("%08d", SEQ.incrementAndGet())) + .nickname("F1企业").role(User.Role.COMPANY).active(true).verified(true).build()); + companyUser.setRoles(java.util.Set.of(User.Role.COMPANY)); + companyUserId = companyUser.getId(); + Company company = companyRepository.save(Company.builder() + .ownerUserId(companyUserId).name("F1公司").build()); + companyId = company.getId(); + Job job = jobRepository.save(Job.builder() + .companyId(companyId).title("F1岗位").description("d").city("上海") + .employmentType("FULL_TIME").status("PUBLISHED").build()); + + // 原状态非 ACTIVE,避免 activeKey 唯一约束在多 Offer 场景下互相冲突 + jdbcTemplate.update("UPDATE jobs SET status='PUBLISHED' WHERE id=?", job.getId()); + } + + /** 清理本类产生的全部行,避免污染同一 JVM 内共享的 H2 内存库。 */ + @org.junit.jupiter.api.AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + jdbcTemplate.update("DELETE FROM candidate_feedback"); + historyRepository.deleteAll(); + jdbcTemplate.update("DELETE FROM offer_reminders"); + jdbcTemplate.update("DELETE FROM offer_counter_offers"); + offerRepository.deleteAll(); + jdbcTemplate.update("DELETE FROM application_status_history"); + applicationRepository.deleteAll(); + jobRepository.deleteAll(); + companyRepository.deleteAll(); + } + + /** 建一个属于指定学生的 SENT Offer(activeKey 置空以便同库多 Offer 共存)。 */ + private Offer newSentOffer(Long studentId) { + Job job = jobRepository.findAll().get(0); + Application app = applicationRepository.save(Application.builder() + .jobId(job.getId()).studentUserId(studentId).status("SUBMITTED").build()); + return offerRepository.saveAndFlush(Offer.builder() + .applicationId(app.getId()).jobId(job.getId()).companyId(companyId) + .candidateId(studentId) + .title("F1 Offer").description("d").expireAt(LocalDateTime.now().minusDays(1)) + .status(OfferStatus.SENT.name()).activeKey(null) + .createdBy(companyUserId).updatedBy(companyUserId) + .build()); + } + + private Long newStudent(String tag) { + User u = userRepository.save(User.builder() + .phone("197" + String.format("%08d", SEQ.incrementAndGet())) + .nickname("F1学生" + tag).role(User.Role.STUDENT).active(true).verified(true).build()); + u.setRoles(java.util.Set.of(User.Role.STUDENT)); + return u.getId(); + } + + @Test + void expireOne_marksOfferExpired_andRegistersRefreshTaskForCandidate() { + Long studentId = newStudent("A"); + Offer offer = newSentOffer(studentId); + + worker.expireOne(offer.getId()); + + // 1) Offer 状态确实被改为 EXPIRED(真实数据库) + assertEquals(OfferStatus.EXPIRED.name(), + offerRepository.findById(offer.getId()).orElseThrow().getStatus()); + // 2) 同一业务事务内登记了该学生的刷新任务(真实数据库) + assertEquals(1L, taskCount(studentId), + "Offer 自动过期必须为该候选人登记恰好一条评分刷新任务"); + String taskPeriod = jdbcTemplate.queryForObject( + "SELECT target_period FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", + String.class, studentId); + assertEquals(period(), taskPeriod); + } + + @Test + void expireOne_repeatedExecution_keepsSingleTask() { + Long studentId = newStudent("B"); + Offer offer = newSentOffer(studentId); + + worker.expireOne(offer.getId()); + worker.expireOne(offer.getId()); // 第二次:状态已是 EXPIRED,幂等返回 + worker.expireOne(offer.getId()); + + assertEquals(1L, taskCount(studentId), "重复执行不得产生多条刷新任务"); + } + + @Test + void expireOne_multipleOffersOfDifferentStudents_registerSeparately() { + Long studentA = newStudent("C"); + Long studentB = newStudent("D"); + Offer a = newSentOffer(studentA); + Offer b = newSentOffer(studentB); + + worker.expireOne(a.getId()); + worker.expireOne(b.getId()); + + assertEquals(1L, taskCount(studentA), "学生 A 应各自登记一条"); + assertEquals(1L, taskCount(studentB), "学生 B 应各自登记一条"); + Long total = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM candidate_scoring_refresh_tasks", Long.class); + assertEquals(2L, total, "两个不同学生应产生两条独立任务"); + } + + @Test + void expireOne_rollback_rollsBackOfferStatusAndRefreshTaskTogether() { + Long studentId = newStudent("E"); + Offer offer = newSentOffer(studentId); + + // 制造"同一事务内后续步骤失败" → 整个 expireOne 事务回滚 + doThrow(new org.springframework.dao.DataAccessException("boom") {}) + .when(counterOfferRepository).expirePendingByOfferId(anyLong()); + + assertThrows(RuntimeException.class, () -> worker.expireOne(offer.getId())); + + assertEquals(OfferStatus.SENT.name(), + offerRepository.findById(offer.getId()).orElseThrow().getStatus(), + "事务回滚后 Offer 状态必须保持原值"); + assertEquals(0L, taskCount(studentId), + "事务回滚后不得留下孤立的刷新任务"); + } + + @Test + void expireOne_refreshRegistrationAddsNoNotificationRows() { + Long studentId = newStudent("F"); + Offer offer = newSentOffer(studentId); + + long globalBefore = notificationCount(null); + + worker.expireOne(offer.getId()); + + long globalAfter = notificationCount(null); + // Offer 过期通知由既有 notifyExpired 走通知通道(测试 profile 下经 MQ 开关关闭的路径), + // 本断言只要求:新增的刷新登记不得额外插入通知行。 + assertEquals(globalBefore, globalAfter, + "刷新登记不得引入任何 notifications 行"); + } + + @Test + void requestRefresh_alone_createsNoNotificationsAndNoMessages() { + Long studentId = newStudent("H"); + long nBefore = notificationCount(null); + long mBefore = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM messages", Long.class); + + requestService.requestRefresh(studentId, LocalDateTime.now()); + + assertEquals(nBefore, notificationCount(null), "纯登记不得写通知"); + assertEquals(mBefore, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM messages", Long.class), + "纯登记不得写消息"); + assertEquals(1L, taskCount(studentId), "纯登记只登记任务"); + } + + @Test + void expireOne_refreshTaskCarriesNoSensitiveFields() { + Long studentId = newStudent("G"); + Offer offer = newSentOffer(studentId); + + worker.expireOne(offer.getId()); + + List cols = jdbcTemplate.queryForList( + "SELECT column_name FROM information_schema.columns " + + "WHERE table_name='CANDIDATE_SCORING_REFRESH_TASKS' AND table_schema='PUBLIC'", + String.class); + if (cols.isEmpty()) { // H2 大小写差异兜底 + cols = List.of("id", "student_user_id", "target_period", "status", "requested_at", + "processing_started_at", "processed_at", "next_attempt_at", "attempts", + "last_error_code", "version", "created_at", "updated_at"); + } + // claim_token 是 V45 引入的所有权令牌(随机 UUID),不是用户凭据,属允许列; + // 其余用户凭据/隐私列一律禁止。 + for (String banned : List.of("PHONE", "EMAIL", "REASON", "CONTENT", "SALARY", + "ACCESS_TOKEN", "REFRESH_TOKEN", "PASSWORD", "SECRET")) { + assertTrue(cols.stream().noneMatch(c -> c.toUpperCase().contains(banned)), + "刷新任务表不得含敏感列: " + banned); + } + assertTrue(cols.stream().anyMatch(c -> "CLAIM_TOKEN".equalsIgnoreCase(c)), + "V45 必须提供 claim_token 所有权令牌列"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java index 06ea97c..d6153bc 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java @@ -27,6 +27,9 @@ class OfferExpiryWorkerTest { @Mock private OfferCounterOfferRepository counterOfferRepository; @Mock private CompanyRepository companyRepository; @Mock private NotificationService notificationService; + /** R01-D4-2 F1:Offer 过期必须登记评分刷新任务。 */ + @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService + scoringRefreshRequestService; @InjectMocks private OfferExpiryWorker worker; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java index 14d5421..efebc5a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java @@ -51,6 +51,7 @@ class OfferServiceTest { @Mock private UserRepository userRepository; @Mock private NotificationService notificationService; @Mock private AuditService auditService; + @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; @InjectMocks private OfferService offerService; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java index d59ef99..226b7f2 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java @@ -2,6 +2,8 @@ package com.mydreamjob.preference; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.preference.dto.StudentPreferenceRequest; @@ -28,6 +30,12 @@ class PreferenceServiceTest { @Mock private UserRepository userRepository; + @Mock + private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; + + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private PreferenceService preferenceService; @@ -38,6 +46,9 @@ class PreferenceServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + pref = StudentPreference.builder() .id(1L).studentUserId(STUDENT_ID) .expectedSalaryMin(10000).expectedSalaryMax(20000) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java index 4cf54f1..18a984c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java @@ -75,8 +75,7 @@ class PrivacySettingsControllerTest { } private String studentToken(long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken( - userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } // ── GET:STUDENT 成功 + 无记录默认值 ────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java index cab1c3c..ba0c093 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java @@ -1,14 +1,13 @@ package com.mydreamjob.privacy; -import com.mydreamjob.application.Application; -import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.application.CompanyResumeAccessPolicy; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; -import com.mydreamjob.company.Company; -import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.privacy.dto.PrivacySettingsResponse; import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; import org.junit.jupiter.api.BeforeEach; @@ -32,10 +31,12 @@ class PrivacySettingsServiceTest { @Mock private StudentPrivacySettingsRepository privacyRepository; @Mock private UserRepository userRepository; - @Mock private ApplicationRepository applicationRepository; - @Mock private CompanyRepository companyRepository; + @Mock private CompanyResumeAccessPolicy companyResumeAccessPolicy; @Mock private AuditService auditService; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private PrivacySettingsService service; private static final Long STUDENT_ID = 10L; @@ -44,6 +45,9 @@ class PrivacySettingsServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + lenient().doNothing().when(auditService).log(any(), any(), any(), any(), any(), any()); } @@ -205,84 +209,14 @@ class PrivacySettingsServiceTest { // ── 隐私策略 ───────────────────────────────────────────── @Test - void canCompanyViewResume_private_alwaysFalseEvenWithVerifiedCompany() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.PRIVATE, false))); - assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_defaultPrivate_whenNoRecord() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); - assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_appliedCompanies_validApplication_visible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false))); - Application app = Application.builder().id(APP_ID).studentUserId(STUDENT_ID).status("SUBMITTED").build(); - when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app)); - - assertTrue(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_appliedCompanies_withdrawn_notVisible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false))); - Application app = Application.builder().id(APP_ID).studentUserId(STUDENT_ID).status("WITHDRAWN").build(); - when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app)); - - assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_appliedCompanies_rejected_notVisible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false))); - Application app = Application.builder().id(APP_ID).studentUserId(STUDENT_ID).status("REJECTED").build(); - when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app)); - - assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_appliedCompanies_wrongStudent_notVisible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false))); - Application app = Application.builder().id(APP_ID).studentUserId(999L).status("SUBMITTED").build(); - when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app)); - - assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_verifiedCompanies_verifiedCompany_visible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.VERIFIED_COMPANIES, false))); - when(companyRepository.findById(COMPANY_ID)) - .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).verified(true).build())); - + void canCompanyViewResume_delegatesToAccessPolicy() { + // S03-B:判定逻辑已收束到失败关闭的 CompanyResumeAccessPolicy,本服务只负责委托。 + // 完整决策矩阵(含公司归属)由 CompanyResumeAccessPolicyTest 覆盖; + // 原先在此断言"已验证企业无需拥有岗位即可查看简历"的用例编码的正是被修复的缺陷,故一并移除。 + when(companyResumeAccessPolicy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)).thenReturn(true); assertTrue(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_verifiedCompanies_unverifiedCompany_notVisible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.VERIFIED_COMPANIES, false))); - when(companyRepository.findById(COMPANY_ID)) - .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).verified(false).build())); - - assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_verifiedCompanies_unknownCompany_notVisible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.VERIFIED_COMPANIES, false))); - when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty()); + when(companyResumeAccessPolicy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)).thenReturn(false); assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); } } \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java index 6bdd31d..f568787 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java @@ -82,8 +82,7 @@ class ProfileControllerTest { } private String bearerToken(long userId, String phone) { - return "Bearer " + jwtTokenProvider.generateAccessToken( - userId, phone, Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } private String studentToken(long userId) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java index 8e7a9a4..453495b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java @@ -21,6 +21,9 @@ class ProfileServiceTest { @Mock private StudentProfileRepository profileRepository; + @Mock + private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; + @InjectMocks private ProfileService profileService; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java index 1f4706b..ceca14a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java @@ -4,12 +4,15 @@ import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.profile.storage.ResumeStorageService; +import com.mydreamjob.security.CurrentUserAccessor; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -17,6 +20,7 @@ import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockMultipartFile; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; @@ -37,6 +41,14 @@ class ResumeControllerTest { @Mock private Authentication authentication; + /** R01-A:Controller 身份解析已收束到唯一的 CurrentUserAccessor。 */ + @Spy + private CurrentUserAccessor currentUserAccessor = new CurrentUserAccessor(); + + /** R01-A:requestId 已收束到 RequestIdAccessor。 */ + @Spy + private RequestIdAccessor requestIdAccessor = new RequestIdAccessor(); + @InjectMocks private ProfileController controller; @@ -65,7 +77,10 @@ class ResumeControllerTest { MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", "data".getBytes()); - assertThrows(BusinessException.class, + // R01-A:未登录语义由 CurrentUserAccessor 统一产出 + // (InsufficientAuthenticationException 由 GlobalExceptionHandler 映射为 401), + // 不再由 Controller 自己抛 BusinessException(2001)。 + assertThrows(InsufficientAuthenticationException.class, () -> controller.uploadResume(file, request)); } @@ -221,4 +236,20 @@ class ResumeControllerTest { "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), resp.getHeaders().getContentType()); } + + @Test + void downloadResume_legacyUnsafeFilenameCannotInjectResponseHeader() { + mockAuth(STUDENT_ID); + ProfileService.ResumeFile rf = new ProfileService.ResumeFile( + "..\\resume\r\nX-Injected: secret.pdf", "application/pdf", "data".getBytes()); + when(profileService.getResume(STUDENT_ID)).thenReturn(rf); + + var resp = controller.downloadResume(request); + + String contentDisposition = resp.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION); + assertNotNull(contentDisposition); + assertFalse(contentDisposition.contains("\r")); + assertFalse(contentDisposition.contains("\n")); + assertFalse(contentDisposition.contains("X-Injected:")); + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java index e8bd16b..e5b5c36 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java @@ -3,13 +3,17 @@ package com.mydreamjob.profile; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.profile.storage.ResumeStorageService; +import com.mydreamjob.profile.storage.ResumeFileValidator; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; @@ -32,6 +36,12 @@ class ResumeServiceTest { @Mock private UserRepository userRepository; + @Spy + private ResumeFileValidator resumeFileValidator = new ResumeFileValidator(); + + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private ProfileService profileService; @@ -41,6 +51,9 @@ class ResumeServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + profile = StudentProfile.builder() .id(1L).userId(STUDENT_ID).realName("张三") .resumeUrl("old_resume.pdf") @@ -70,7 +83,7 @@ class ResumeServiceTest { void uploadResume_asStudent_shouldSucceed() { mockStudentRole(); MultipartFile file = new MockMultipartFile("file", "resume.pdf", - "application/pdf", "content".getBytes()); + "application/pdf", validPdf("content")); when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); when(storageService.store(eq(STUDENT_ID), any())).thenReturn("new_resume.pdf"); @@ -88,7 +101,7 @@ class ResumeServiceTest { void uploadResume_noExistingProfile_shouldCreateProfile() { mockStudentRole(); MultipartFile file = new MockMultipartFile("file", "r.pdf", - "application/pdf", "content".getBytes()); + "application/pdf", validPdf("content")); when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.empty()); when(storageService.store(eq(STUDENT_ID), any())).thenReturn("r.pdf"); @@ -159,6 +172,58 @@ class ResumeServiceTest { () -> profileService.uploadResume(STUDENT_ID, file)); } + @Test + void uploadResume_fakePdfContent_shouldReturn400AndNotStore() { + mockStudentRole(); + MultipartFile file = new MockMultipartFile("file", "resume.pdf", + "application/pdf", "".getBytes()); + + assertThrows(BusinessException.class, + () -> profileService.uploadResume(STUDENT_ID, file)); + + verify(storageService, never()).store(anyLong(), any()); + } + + @Test + void uploadResume_storageFailure_keepsOldFile() { + mockStudentRole(); + MultipartFile file = new MockMultipartFile("file", "resume.pdf", + "application/pdf", validPdf("content")); + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(storageService.store(eq(STUDENT_ID), any())).thenThrow(new RuntimeException("disk full")); + + assertThrows(RuntimeException.class, () -> profileService.uploadResume(STUDENT_ID, file)); + + verify(storageService, never()).delete("old_resume.pdf"); + verify(profileRepository, never()).save(any()); + } + + @Test + void uploadResume_databaseFailure_cleansNewFileAndKeepsOldFile() { + mockStudentRole(); + MultipartFile file = new MockMultipartFile("file", "resume.pdf", + "application/pdf", validPdf("content")); + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(storageService.store(eq(STUDENT_ID), any())).thenReturn("new_resume.pdf"); + when(profileRepository.save(any())).thenThrow(new RuntimeException("database unavailable")); + + assertThrows(RuntimeException.class, () -> profileService.uploadResume(STUDENT_ID, file)); + + verify(storageService).delete("new_resume.pdf"); + verify(storageService, never()).delete("old_resume.pdf"); + } + + @Test + void deleteResume_databaseFailure_keepsStoredFile() { + mockStudentRole(); + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(profileRepository.save(any())).thenThrow(new RuntimeException("database unavailable")); + + assertThrows(RuntimeException.class, () -> profileService.deleteResume(STUDENT_ID)); + + verify(storageService, never()).delete("old_resume.pdf"); + } + // ── 获取 / 删除 ────────────────────────────────────────── @Test @@ -187,4 +252,9 @@ class ResumeServiceTest { assertNull(profile.getResumeContentType()); assertNull(profile.getResumeSize()); } + + private byte[] validPdf(String content) { + return ("%PDF-1.7\n" + content + "\n%%EOF\n") + .getBytes(java.nio.charset.StandardCharsets.US_ASCII); + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeUploadConsistencyIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeUploadConsistencyIntegrationTest.java new file mode 100644 index 0000000..027b7f7 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeUploadConsistencyIntegrationTest.java @@ -0,0 +1,153 @@ +package com.mydreamjob.profile; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.profile.storage.ResumeStorageService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("integration-test") +class ResumeUploadConsistencyIntegrationTest { + + private static final AtomicLong PHONE_SEQUENCE = new AtomicLong(800_000); + + @Autowired private ProfileService profileService; + @Autowired private StudentProfileRepository profileRepository; + @Autowired private UserRepository userRepository; + @Autowired private ResumeStorageService storageService; + @Autowired private PlatformTransactionManager transactionManager; + + @MockBean private RedisTemplate redisTemplate; + + private final List testStoragePaths = new ArrayList<>(); + private Long userId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations operations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(operations); + when(redisTemplate.hasKey(any())).thenReturn(false); + + User user = User.builder() + .phone("177" + String.format("%08d", PHONE_SEQUENCE.incrementAndGet())) + .nickname("上传一致性测试") + .role(User.Role.STUDENT) + .active(true) + .verified(true) + .build(); + user.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + userId = userRepository.saveAndFlush(user).getId(); + } + + @AfterEach + void tearDown() { + for (String path : testStoragePaths) { + storageService.delete(path); + } + profileRepository.findByUserId(userId).ifPresent(profileRepository::delete); + userRepository.findById(userId).ifPresent(userRepository::delete); + } + + @Test + void successfulReplacementCommitsNewReferenceBeforeDeletingOldFile() { + String oldPath = store(pdf("old")); + saveProfile(oldPath, "old.pdf"); + + @SuppressWarnings("unchecked") + String newPath = (String) profileService.uploadResume(userId, multipart("new.pdf", pdf("new"))) + .get("resumeUrl"); + testStoragePaths.add(newPath); + + StudentProfile persisted = profileRepository.findByUserId(userId).orElseThrow(); + assertEquals(newPath, persisted.getResumeUrl()); + assertArrayEquals(pdf("new"), storageService.read(newPath)); + assertThrows(BusinessException.class, () -> storageService.read(oldPath)); + } + + @Test + void outerRollbackRestoresOldReferenceAndDeletesOnlyNewFile() { + String oldPath = store(pdf("old")); + saveProfile(oldPath, "old.pdf"); + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + + String newPath = transaction.execute(status -> { + String stored = (String) profileService.uploadResume(userId, multipart("new.pdf", pdf("new"))) + .get("resumeUrl"); + testStoragePaths.add(stored); + status.setRollbackOnly(); + return stored; + }); + + StudentProfile persisted = profileRepository.findByUserId(userId).orElseThrow(); + assertEquals(oldPath, persisted.getResumeUrl()); + assertArrayEquals(pdf("old"), storageService.read(oldPath)); + assertThrows(BusinessException.class, () -> storageService.read(newPath)); + } + + @Test + void deleteRollbackKeepsDatabaseReferenceAndStoredFile() { + String oldPath = store(pdf("old")); + saveProfile(oldPath, "old.pdf"); + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + + transaction.executeWithoutResult(status -> { + profileService.deleteResume(userId); + status.setRollbackOnly(); + }); + + StudentProfile persisted = profileRepository.findByUserId(userId).orElseThrow(); + assertEquals(oldPath, persisted.getResumeUrl()); + assertArrayEquals(pdf("old"), storageService.read(oldPath)); + } + + private String store(byte[] content) { + String path = storageService.store(userId, multipart("resume.pdf", content)); + testStoragePaths.add(path); + return path; + } + + private void saveProfile(String path, String filename) { + profileRepository.saveAndFlush(StudentProfile.builder() + .userId(userId) + .resumeUrl(path) + .resumeOriginalName(filename) + .resumeContentType("application/pdf") + .resumeSize((long) pdf("old").length) + .build()); + } + + private MockMultipartFile multipart(String filename, byte[] content) { + return new MockMultipartFile("file", filename, "application/pdf", content); + } + + private byte[] pdf(String body) { + return ("%PDF-1.7\n" + body + "\n%%EOF\n").getBytes(StandardCharsets.US_ASCII); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/LocalResumeStorageServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/LocalResumeStorageServiceTest.java index 64d4d6c..0520ec4 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/LocalResumeStorageServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/LocalResumeStorageServiceTest.java @@ -9,7 +9,8 @@ import org.springframework.web.multipart.MultipartFile; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Arrays; +import java.io.IOException; +import java.io.InputStream; import static org.junit.jupiter.api.Assertions.*; @@ -105,6 +106,32 @@ class LocalResumeStorageServiceTest { assertNotNull(path); } + @Test + void store_interruptedCopy_removesTemporaryAndFinalFiles() throws Exception { + MultipartFile file = new MockMultipartFile("file", "resume.pdf", "application/pdf", "ignored".getBytes()) { + @Override + public InputStream getInputStream() { + return new InputStream() { + private final byte[] content = "partial-content".getBytes(); + private int position; + + @Override + public int read() throws IOException { + if (position >= 4) { + throw new IOException("simulated interrupted upload"); + } + return content[position++]; + } + }; + } + }; + + assertThrows(RuntimeException.class, () -> service.store(6L, file)); + try (var files = Files.list(tempDir)) { + assertEquals(0, files.count(), "failed upload must leave neither final nor .uploading files"); + } + } + // ── 文件不存在 ──────────────────────────────────────────── @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeFileValidatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeFileValidatorTest.java new file mode 100644 index 0000000..f3d3ddc --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeFileValidatorTest.java @@ -0,0 +1,142 @@ +package com.mydreamjob.profile.storage; + +import com.mydreamjob.common.exception.BusinessException; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ResumeFileValidatorTest { + + private final ResumeFileValidator validator = new ResumeFileValidator(); + + @Test + void validPdfDocAndDocxAreAccepted() throws Exception { + assertDoesNotThrow(() -> validator.validate(file("resume.pdf", "application/pdf", pdf("profile")))); + assertDoesNotThrow(() -> validator.validate(file("resume.doc", "application/msword", legacyDoc()))); + assertDoesNotThrow(() -> validator.validate(file("resume.docx", docxType(), docx("docProps/core.xml", "profile")))); + } + + @Test + void fakePdfAndFakeLegacyDocAreRejected() { + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.pdf", "application/pdf", "not a pdf".getBytes()))); + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.doc", "application/msword", "not a doc".getBytes()))); + } + + @Test + void extensionContentTypeMismatchIsRejectedEvenWhenSignatureLooksValid() { + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.pdf", "application/msword", pdf("profile")))); + } + + @Test + void arbitraryZipAndMissingDocumentPartAreRejected() throws Exception { + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.docx", docxType(), + zip(new Entry("payload.txt", "not office"))))); + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.docx", docxType(), + zip(new Entry("[Content_Types].xml", "types"))))); + } + + @Test + void docxPathTraversalEntryIsRejected() throws Exception { + byte[] malicious = zip( + new Entry("[Content_Types].xml", "types"), + new Entry("word/document.xml", "document"), + new Entry("../outside.txt", "secret")); + + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.docx", docxType(), malicious))); + } + + @Test + void highlyCompressedDocxPayloadIsRejected() throws Exception { + byte[] repeated = new byte[1024 * 1024]; + byte[] bomb = zip( + new Entry("[Content_Types].xml", "types"), + new Entry("word/document.xml", "document"), + new Entry("word/media/bomb.bin", repeated)); + + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.docx", docxType(), bomb))); + } + + @Test + void unsafeOrOversizedFilenameIsRejected() { + assertThrows(BusinessException.class, + () -> validator.validate(file("..\\resume.pdf", "application/pdf", pdf("profile")))); + assertThrows(BusinessException.class, + () -> validator.validate(file("resume\r\nX-Test.pdf", "application/pdf", pdf("profile")))); + assertThrows(BusinessException.class, + () -> validator.validate(file("a".repeat(252) + ".pdf", "application/pdf", pdf("profile")))); + } + + @Test + void downloadFilenameSanitizerProtectsLegacyMetadata() { + String safe = ResumeFilenameSanitizer.forDownload("..\\old\r\nInjected: value.pdf", "resume.pdf"); + org.junit.jupiter.api.Assertions.assertFalse(safe.contains("\\")); + org.junit.jupiter.api.Assertions.assertFalse(safe.contains("\r")); + org.junit.jupiter.api.Assertions.assertFalse(safe.contains("\n")); + org.junit.jupiter.api.Assertions.assertFalse(safe.contains(":")); + } + + static byte[] pdf(String body) { + return ("%PDF-1.7\n" + body + "\n%%EOF\n").getBytes(java.nio.charset.StandardCharsets.US_ASCII); + } + + static byte[] legacyDoc() { + byte[] marker = "WordDocument".getBytes(java.nio.charset.StandardCharsets.UTF_16LE); + byte[] bytes = new byte[512 + marker.length]; + byte[] signature = { + (byte) 0xD0, (byte) 0xCF, 0x11, (byte) 0xE0, + (byte) 0xA1, (byte) 0xB1, 0x1A, (byte) 0xE1 + }; + System.arraycopy(signature, 0, bytes, 0, signature.length); + System.arraycopy(marker, 0, bytes, 512, marker.length); + return bytes; + } + + static byte[] docx(String extraName, String extraContent) throws IOException { + return zip( + new Entry("[Content_Types].xml", ""), + new Entry("word/document.xml", ""), + new Entry(extraName, extraContent)); + } + + private static byte[] zip(Entry... entries) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + for (Entry entry : entries) { + zip.putNextEntry(new ZipEntry(entry.name())); + if (entry.bytes() != null) { + zip.write(entry.bytes()); + } + zip.closeEntry(); + } + } + return output.toByteArray(); + } + + private static MockMultipartFile file(String name, String contentType, byte[] content) { + return new MockMultipartFile("file", name, contentType, content); + } + + private static String docxType() { + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + } + + private record Entry(String name, byte[] bytes) { + Entry(String name, String content) { + this(name, content.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculatorTest.java new file mode 100644 index 0000000..f5f858f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculatorTest.java @@ -0,0 +1,80 @@ +package com.mydreamjob.recommendation; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class JobPreferenceScoreCalculatorTest { + + @Test + void noAvailablePreferenceReturnsNull() { + assertNull(score(criteria(List.of(), List.of(), null, List.of(), List.of()), target())); + } + + @Test + void allFiveSignalsMatchReturnsOneHundred() { + assertEquals(new BigDecimal("100.00"), score(fullCriteria(), target())); + } + + @Test + void noSignalMatchesReturnsZeroWithTwoDecimals() { + JobPreferenceScoreCalculator.Target other = new JobPreferenceScoreCalculator.Target( + "Shenzhen", "PART_TIME", 9999, "FINANCE", "SALES"); + assertEquals(new BigDecimal("0.00"), score(fullCriteria(), other)); + } + + @Test + void weightsAndHalfUpRoundingRemainThirtyTwentyTwentyFifteenFifteen() { + JobPreferenceScoreCalculator.Criteria criteria = criteria( + List.of("Shanghai"), List.of(), null, List.of("FINANCE"), List.of()); + assertEquals(new BigDecimal("66.67"), score(criteria, target())); + } + + @Test + void salaryBoundaryIsInclusiveAndNullMaximumDoesNotMatch() { + JobPreferenceScoreCalculator.Criteria salaryOnly = + criteria(List.of(), List.of(), 20000, List.of(), List.of()); + assertEquals(new BigDecimal("100.00"), score(salaryOnly, + new JobPreferenceScoreCalculator.Target(null, null, 20000, null, null))); + assertEquals(new BigDecimal("0.00"), score(salaryOnly, + new JobPreferenceScoreCalculator.Target(null, null, null, null, null))); + } + + @Test + void nullCollectionsAreTreatedAsUnavailable() { + assertNull(score(new JobPreferenceScoreCalculator.Criteria(null, null, null, null, null), target())); + } + + @Test + void nullElementsInsideConfiguredListsRemainTolerated() { + JobPreferenceScoreCalculator.Criteria criteria = criteria( + Arrays.asList(null, "Shanghai"), List.of(), null, List.of(), List.of()); + assertEquals(new BigDecimal("100.00"), score(criteria, target())); + } + + private BigDecimal score(JobPreferenceScoreCalculator.Criteria criteria, + JobPreferenceScoreCalculator.Target target) { + return JobPreferenceScoreCalculator.calculate(criteria, target); + } + + private JobPreferenceScoreCalculator.Criteria fullCriteria() { + return criteria(List.of("Shanghai"), List.of("FULL_TIME"), 20000, + List.of("TECH"), List.of("ENGINEERING")); + } + + private JobPreferenceScoreCalculator.Criteria criteria( + List cities, List types, Integer salary, + List industries, List families) { + return new JobPreferenceScoreCalculator.Criteria(cities, types, salary, industries, families); + } + + private JobPreferenceScoreCalculator.Target target() { + return new JobPreferenceScoreCalculator.Target( + "Shanghai", "FULL_TIME", 20000, "TECH", "ENGINEERING"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobRecommendationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobRecommendationServiceTest.java index a2f4916..2aeb9b5 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobRecommendationServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobRecommendationServiceTest.java @@ -3,10 +3,13 @@ package com.mydreamjob.recommendation; import com.mydreamjob.application.ApplicationRepository; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobRepository; import com.mydreamjob.preference.StudentPreferenceRepository; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.jdbc.core.JdbcTemplate; @@ -19,6 +22,11 @@ import static org.mockito.Mockito.*; @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class JobRecommendationServiceTest { + + @BeforeEach + void wireRoleGuard() { + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + } @Mock JobRecommendationTaxonomyRepository taxonomyRepository; @Mock JobRecommendationProfileRepository profileRepository; @Mock StudentJobRecommendationFeedbackRepository feedbackRepository; @@ -30,6 +38,9 @@ class JobRecommendationServiceTest { @Mock ApplicationRepository applicationRepository; @Mock com.mydreamjob.audit.AuditService auditService; @Mock JdbcTemplate jdbcTemplate; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks JobRecommendationService service; @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionControllerTest.java index 789e18f..54dfa7c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionControllerTest.java @@ -58,7 +58,7 @@ class ResumeVersionControllerTest { } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT"); } private Map versionView() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java index 6335daa..da9cc6c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java @@ -57,7 +57,8 @@ class ResumeVersionPermissionControllerTest { ownerUserId = createStudent(); otherUserId = createStudent(); - MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", "data".getBytes()); + MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", + "%PDF-1.7\npermission-fixture\n%%EOF\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); ResumeVersion version = resumeVersionService.createVersion(ownerUserId, "秋招版", file); versionId = version.getId(); } @@ -70,7 +71,7 @@ class ResumeVersionPermissionControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } // ── 另一学生访问 → 403 ──────────────────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPersistenceIntegrationTest.java index 135f93a..c896bbc 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPersistenceIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPersistenceIntegrationTest.java @@ -162,7 +162,7 @@ class ResumeVersionPersistenceIntegrationTest { ExecutorService pool = Executors.newFixedThreadPool(2); CountDownLatch start = new CountDownLatch(1); AtomicReference failure = new AtomicReference<>(); - MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", "data".getBytes()); + MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", pdf("data")); pool.submit(() -> runWithLatch(start, () -> resumeVersionService.createVersion(userId, "a", file), failure)); pool.submit(() -> runWithLatch(start, () -> resumeVersionService.createVersion(userId, "b", file), failure)); start.countDown(); @@ -183,8 +183,10 @@ class ResumeVersionPersistenceIntegrationTest { */ @Test void export_afterVersionModified_shouldUseOriginalFile() throws Exception { - MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", "AAA".getBytes()); - MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", "BBB".getBytes()); + byte[] contentA = pdf("AAA"); + byte[] contentB = pdf("BBB"); + MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", contentA); + MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", contentB); ResumeVersion v1 = resumeVersionService.createVersion(userId, "v1", fileA); ResumeExportTask task = resumeExportService.createExportTask(userId, v1.getId()); // 快照 = A 的路径 @@ -194,7 +196,7 @@ class ResumeVersionPersistenceIntegrationTest { ResumeExportTask done = awaitTerminal(task.getId()); assertEquals(ResumeExportTask.STATUS_SUCCESS, done.getStatus(), "导出应成功: " + done.getErrorMessage()); - assertArrayEquals("AAA".getBytes(), resumeExportStorageService.read(done.getOutputPath())); + assertArrayEquals(contentA, resumeExportStorageService.read(done.getOutputPath())); assertEquals("r.pdf", done.getOutputName()); } @@ -216,8 +218,8 @@ class ResumeVersionPersistenceIntegrationTest { */ @Test void updateVersion_outerRollback_shouldCleanNewFileAndKeepOldFileAndDb() throws Exception { - MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", "AAA".getBytes()); - MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", "BBB".getBytes()); + MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", pdf("AAA")); + MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", pdf("BBB")); ResumeVersion v1 = resumeVersionService.createVersion(userId, "v1", fileA); String pathA = v1.getFileStoragePath(); List before = listMyResumeFiles(); @@ -260,4 +262,9 @@ class ResumeVersionPersistenceIntegrationTest { failure.compareAndSet(null, t); } } + + private byte[] pdf(String content) { + return ("%PDF-1.7\n" + content + "\n%%EOF\n") + .getBytes(java.nio.charset.StandardCharsets.US_ASCII); + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionRefreshAtomicityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionRefreshAtomicityTest.java new file mode 100644 index 0000000..426074e --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionRefreshAtomicityTest.java @@ -0,0 +1,139 @@ +package com.mydreamjob.resume; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.profile.storage.ResumeStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * R01-D4-2 阻断缺陷 F3:简历版本行与评分刷新任务必须同一提交边界。 + * + *

    缺陷背景:{@code ResumeVersionWriter#saveNewVersion} 以 REQUIRES_NEW 独立提交版本行 + * (这是既有的一致性问题防护设计,不能简单移除),而 {@code createVersion} 在外层事务中 + * 登记刷新任务 —— 外层事务在提交阶段失败时,版本行已落库而刷新任务丢失。

    + * + *

    本测试断言真实数据库状态:外层事务回滚后,版本行存在则刷新任务必须同样存在。

    + * + *

    文件系统操作被 mock:本测试只验证数据库原子性,不把文件存储纳入数据库事务承诺。

    + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class ResumeVersionRefreshAtomicityTest { + + @Autowired private ResumeVersionService service; + @Autowired private ResumeVersionRepository resumeVersionRepository; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private TransactionTemplate txTemplate; + @MockBean private ResumeStorageService resumeStorageService; + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong SEQ = new AtomicLong(7000); + + private Long userId; + + private long taskCount(Long uid) { + Long c = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", + Long.class, uid); + return c == null ? -1 : c; + } + + /** 必须是带真实 PDF 签名的内容,否则 ResumeFileValidator 会拒绝。 */ + private MockMultipartFile file() { + return new MockMultipartFile("file", "resume.pdf", "application/pdf", + "%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n".getBytes()); + } + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + when(resumeStorageService.store(anyLong(), any())).thenReturn("test/resume.pdf"); + + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + resumeVersionRepository.deleteAll(); + + User student = userRepository.save(User.builder() + .phone("194" + String.format("%08d", SEQ.incrementAndGet())) + .nickname("F3学生").role(User.Role.STUDENT).active(true).verified(true).build()); + student.setRoles(java.util.Set.of(User.Role.STUDENT)); + userId = student.getId(); + } + + /** 清理本类产生的行,避免污染同一 JVM 内共享的 H2 内存库。 */ + @org.junit.jupiter.api.AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); + jdbcTemplate.update("DELETE FROM resume_export_tasks"); + resumeVersionRepository.deleteAll(); + } + + @Test + void createVersion_success_registersRefreshTask() { + service.createVersion(userId, "我的简历", file()); + + assertEquals(1L, resumeVersionRepository.count(), "版本行已创建"); + assertEquals(1L, taskCount(userId), "createVersion 必须登记刷新任务"); + } + + @Test + void createVersion_outerTransactionRollsBack_versionRowAndTaskCommitTogether() { + // 外层事务包裹 createVersion,并在返回后抛出 → 外层回滚 + assertThrows(RuntimeException.class, () -> txTemplate.execute(status -> { + service.createVersion(userId, "外层回滚简历", file()); + throw new RuntimeException("outer transaction failed after createVersion"); + })); + + long versions = resumeVersionRepository.count(); + long tasks = taskCount(userId); + + // 既有设计:版本行由 REQUIRES_NEW 独立提交 → 外层回滚后仍然存在 + assertEquals(1L, versions, "版本行经独立事务提交,外层回滚不应使其消失"); + + // F3 核心断言:版本行存在则刷新任务必须同样存在(同一提交边界) + assertEquals(versions > 0 ? 1L : 0L, tasks, + "版本行已提交时刷新任务不得丢失(必须与版本行同一提交边界)"); + } + + @Test + void createVersion_neverLeavesTaskWithoutVersionRow() { + // 反向:不允许出现"有任务但没有版本行" + service.createVersion(userId, "正常简历", file()); + long versions = resumeVersionRepository.count(); + long tasks = taskCount(userId); + assertTrue(versions >= tasks, + "不得出现任务多于版本行的不一致状态 (versions=" + versions + ", tasks=" + tasks + ")"); + } + + @Test + void createVersion_repeatedRegistrations_convergeToOneTaskPerPeriod() { + service.createVersion(userId, "简历一", file()); + service.createVersion(userId, "简历二", file()); + service.createVersion(userId, "简历三", file()); + + assertEquals(3L, resumeVersionRepository.count(), "三个版本行"); + assertEquals(1L, taskCount(userId), + "同学生同周期重复登记必须收敛为一条任务"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionServiceTest.java index 5af310b..abe84c2 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionServiceTest.java @@ -31,6 +31,7 @@ class ResumeVersionServiceTest { @Mock private ResumeStorageService resumeStorageService; @Mock private ResumeVersionWriter versionWriter; @Mock private ProfileService profileService; + @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; @InjectMocks private ResumeVersionService resumeVersionService; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewControllerTest.java index 66dbab4..f93fa99 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewControllerTest.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -16,6 +17,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -41,29 +43,42 @@ class AdminReviewControllerTest { @Autowired private MockMvc mockMvc; @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @Autowired private JdbcTemplate jdbcTemplate; @MockBean private JobReviewService reviewService; @MockBean private RedisTemplate redisTemplate; private final ObjectMapper mapper = new ObjectMapper(); - private static final Long ADMIN_ID = 1L; - private static final Long STUDENT_ID = 100L; + static final Long ADMIN_ID = 9_320_001L; + static final Long STUDENT_ID = 9_320_002L; private static final Long REVIEW_ID = 1L; @BeforeEach @SuppressWarnings("unchecked") void setUp() { + removeFixtureUsers(); + ReviewControllerFixtureUsers.provision(jdbcTemplate, ADMIN_ID, "r01b-ar-admin", "ADMIN"); + ReviewControllerFixtureUsers.provision(jdbcTemplate, STUDENT_ID, "r01b-ar-student", "STUDENT"); ValueOperations ops = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(any())).thenReturn(false); } + @AfterEach + void tearDown() { + removeFixtureUsers(); + } + + private void removeFixtureUsers() { + ReviewControllerFixtureUsers.remove(jdbcTemplate, ADMIN_ID, STUDENT_ID); + } + private String adminToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(ADMIN_ID, "1", Map.of("roles", "ADMIN")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(ADMIN_ID, "ADMIN"); } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT"); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java index e51c954..9f1aaad 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java @@ -5,11 +5,14 @@ import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.auth.JwtTokenProvider; import com.mydreamjob.review.dto.ReviewReplyPageResponse; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -28,13 +31,32 @@ class CompanyReviewManagementControllerTest { @Autowired private MockMvc mockMvc; @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private JdbcTemplate jdbcTemplate; @MockBean private CompanyReviewManagementService managementService; @MockBean private CompanyReviewReplyService replyService; @MockBean private RedisTemplate redisTemplate; + static final long COMPANY_USER_ID = 9_320_011L; + static final long STUDENT_USER_ID = 9_320_012L; + + @BeforeEach + void setUpFixtureUsers() { + removeFixtureUsers(); + ReviewControllerFixtureUsers.provision(jdbcTemplate, COMPANY_USER_ID, "r01b-cr-company", "COMPANY"); + ReviewControllerFixtureUsers.provision(jdbcTemplate, STUDENT_USER_ID, "r01b-cr-student", "STUDENT"); + } + + @AfterEach + void tearDownFixtureUsers() { + removeFixtureUsers(); + } + + private void removeFixtureUsers() { + ReviewControllerFixtureUsers.remove(jdbcTemplate, COMPANY_USER_ID, STUDENT_USER_ID); + } + private String token(long id, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(id, role.equals("COMPANY") ? "1" : "2", - Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(id, role); } @Test @@ -45,18 +67,18 @@ class CompanyReviewManagementControllerTest { @Test void studentCannotAccessCompanyReviews() throws Exception { - when(managementService.listMine(eq(2L), any(), any(), any(), any(), any(), any(), eq(0), eq(20))) + when(managementService.listMine(eq(STUDENT_USER_ID), any(), any(), any(), any(), any(), any(), eq(0), eq(20))) .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); - mockMvc.perform(get("/companies/me/job-reviews").header("Authorization", token(2L, "STUDENT"))) + mockMvc.perform(get("/companies/me/job-reviews").header("Authorization", token(STUDENT_USER_ID, "STUDENT"))) .andExpect(status().isForbidden()); } @Test void companyCanListItsReviews() throws Exception { - when(managementService.listMine(eq(1L), any(), any(), any(), any(), any(), any(), eq(0), eq(20))) + when(managementService.listMine(eq(COMPANY_USER_ID), any(), any(), any(), any(), any(), any(), eq(0), eq(20))) .thenReturn(ReviewReplyPageResponse.builder().items(java.util.List.of()).total(0) .page(0).size(20).totalPages(0).build()); - mockMvc.perform(get("/companies/me/job-reviews").header("Authorization", token(1L, "COMPANY"))) + mockMvc.perform(get("/companies/me/job-reviews").header("Authorization", token(COMPANY_USER_ID, "COMPANY"))) .andExpect(status().isOk()); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java index 8559a8e..4dfb82d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java @@ -45,8 +45,7 @@ class JobReviewControllerTest { } private String token() { - return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "1", - java.util.Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT"); } @Test void createReview_shouldReturn200() throws Exception { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionControllerTest.java index 320fd0b..ec92de9 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionControllerTest.java @@ -59,11 +59,11 @@ class JobReviewDimensionControllerTest { } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT"); } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(10L, "3", Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(10L, "COMPANY"); } private String reviewBody(boolean withDims) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java index e296a47..80da73c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java @@ -3,6 +3,8 @@ package com.mydreamjob.review; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.job.Job; @@ -43,6 +45,9 @@ class JobReviewDimensionServiceTest { @Mock private UserRepository userRepository; @Mock private AuditService auditService; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private JobReviewService reviewService; private static final Long STUDENT_ID = 100L; @@ -53,6 +58,9 @@ class JobReviewDimensionServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + lenient().when(dimensionScoreRepository.findByReviewId(any())).thenReturn(List.of()); lenient().when(dimensionScoreRepository.aggregateByDimension(any())).thenReturn(List.of()); lenient().when(moderationService.moderate(any(), any(), any(), any(), any(), any(), any())) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java index f6b5ca0..26fc1d2 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java @@ -3,6 +3,8 @@ package com.mydreamjob.review; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.job.Job; @@ -35,6 +37,9 @@ class JobReviewServiceTest { @Mock private JobService jobService; @Mock private UserRepository userRepository; @Mock private AuditService auditService; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private JobReviewService reviewService; private static final Long STUDENT_ID = 100L; @@ -46,6 +51,9 @@ class JobReviewServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + review = JobReview.builder().id(REVIEW_ID).jobId(JOB_ID).studentUserId(STUDENT_ID) .rating(4).content("不错的岗位").status("PUBLISHED").build(); // 维度相关默认空数据(旧用例不传 dimensionScores;lenient 避免部分用例未用到) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/ReviewControllerFixtureIsolationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/ReviewControllerFixtureIsolationTest.java new file mode 100644 index 0000000..ea070b9 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/ReviewControllerFixtureIsolationTest.java @@ -0,0 +1,91 @@ +package com.mydreamjob.review; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("integration-test") +class ReviewControllerFixtureIsolationTest { + + private static final long SENTINEL_ID = 9_320_099L; + private static final long PROBE_ID = 9_320_098L; + + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + + @AfterEach + void cleanup() { + ReviewControllerFixtureUsers.remove(jdbcTemplate, + AdminReviewControllerTest.ADMIN_ID, + AdminReviewControllerTest.STUDENT_ID, + CompanyReviewManagementControllerTest.COMPANY_USER_ID, + CompanyReviewManagementControllerTest.STUDENT_USER_ID, + SENTINEL_ID, PROBE_ID); + } + + @Test + void fixtureIdsAreOutsideLegacySeedRangeAndMutuallyDistinct() { + List ids = List.of( + AdminReviewControllerTest.ADMIN_ID, + AdminReviewControllerTest.STUDENT_ID, + CompanyReviewManagementControllerTest.COMPANY_USER_ID, + CompanyReviewManagementControllerTest.STUDENT_USER_ID); + + assertThat(ids).doesNotContain(1L, 2L, 3L).doesNotHaveDuplicates(); + } + + @Test + void provisionCreatesAnActiveVerifiedUserAndCanBeRepeated() { + for (int round = 0; round < 3; round++) { + ReviewControllerFixtureUsers.provision(jdbcTemplate, PROBE_ID, "r01b-review-probe", "ADMIN"); + } + + Map row = jdbcTemplate.queryForMap( + "SELECT id, role, is_active, is_verified FROM users WHERE id = ?", PROBE_ID); + assertThat(((Number) row.get("id")).longValue()).isEqualTo(PROBE_ID); + assertThat(row.get("role")).isEqualTo("ADMIN"); + assertThat(((Number) row.get("is_active")).intValue()).isEqualTo(1); + assertThat(((Number) row.get("is_verified")).intValue()).isEqualTo(1); + } + + @Test + void cleanupRemovesOnlyOwnedRows() { + ReviewControllerFixtureUsers.provision(jdbcTemplate, PROBE_ID, "r01b-review-probe", "ADMIN"); + ReviewControllerFixtureUsers.provision(jdbcTemplate, SENTINEL_ID, "r01b-review-sentinel", "STUDENT"); + + ReviewControllerFixtureUsers.remove(jdbcTemplate, PROBE_ID); + + assertThat(countUser(PROBE_ID)).isZero(); + assertThat(countUser(SENTINEL_ID)).isEqualTo(1); + } + + @Test + void fixtureCycleLeavesLegacySeedRowsUnchanged() { + List> before = seedRows(); + + ReviewControllerFixtureUsers.provision(jdbcTemplate, PROBE_ID, "r01b-review-probe", "ADMIN"); + ReviewControllerFixtureUsers.remove(jdbcTemplate, PROBE_ID); + + assertThat(seedRows()).isEqualTo(before); + } + + private int countUser(long id) { + return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, id); + } + + private List> seedRows() { + return jdbcTemplate.queryForList( + "SELECT id, phone, role, is_active, is_verified FROM users WHERE id IN (1, 2, 3) ORDER BY id"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/ReviewControllerFixtureUsers.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/ReviewControllerFixtureUsers.java new file mode 100644 index 0000000..868bd01 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/ReviewControllerFixtureUsers.java @@ -0,0 +1,24 @@ +package com.mydreamjob.review; + +import org.springframework.jdbc.core.JdbcTemplate; + +/** Test-only fixture helper that creates and removes only explicitly owned users. */ +final class ReviewControllerFixtureUsers { + + private ReviewControllerFixtureUsers() { + } + + static void provision(JdbcTemplate jdbcTemplate, long id, String phone, String role) { + remove(jdbcTemplate, id); + jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "r01b-review-fixture-" + role, role); + } + + static void remove(JdbcTemplate jdbcTemplate, long... ids) { + for (long id : ids) { + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightControllerTest.java index 8f08945..7078b5a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightControllerTest.java @@ -51,18 +51,15 @@ class StudentReviewWeightControllerTest { } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "13800138000", - Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT"); } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(200L, "13800138001", - Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(200L, "COMPANY"); } private String adminToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(300L, "13800138002", - Map.of("roles", "ADMIN")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(300L, "ADMIN"); } // ── 模板列表 ────────────────────────────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/risk/AdminRiskControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/risk/AdminRiskControllerTest.java index 0add3d9..14b00d5 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/risk/AdminRiskControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/risk/AdminRiskControllerTest.java @@ -103,8 +103,7 @@ class AdminRiskControllerTest { } private String token(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } private ScoringRiskEvent newEvent(String status, long version) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/safety/AdminReportControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/safety/AdminReportControllerTest.java index 4e8aa9a..8955356 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/safety/AdminReportControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/safety/AdminReportControllerTest.java @@ -101,13 +101,11 @@ class AdminReportControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } private String tokenWithRole(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/safety/BlockControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/safety/BlockControllerTest.java index 8c46a23..ba82b8d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/safety/BlockControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/safety/BlockControllerTest.java @@ -88,8 +88,7 @@ class BlockControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportControllerTest.java index 46df0df..0bf67bf 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportControllerTest.java @@ -123,13 +123,11 @@ class ReportControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } private String tokenWithRole(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } // ── 用户举报 ──────────────────────────────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java index 42a7b48..0d5f60c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java @@ -3,6 +3,8 @@ package com.mydreamjob.scoringmonitoring; import com.mydreamjob.auth.JwtTokenProvider; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -11,6 +13,7 @@ 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.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -34,9 +37,52 @@ class FairnessMonitoringControllerTest { @Autowired private MockMvc mockMvc; @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private JdbcTemplate jdbcTemplate; @MockBean private FairnessMonitoringService service; @MockBean private RedisTemplate redisTemplate; + /** + * 本类**独占**的夹具用户 ID。 + * + *

    刻意避开 {@code V1_1__seed_data.sql} 种子账号占用的 {@code users.id = 1/2/3} + * —— 那三行已被 {@code V43__disable_legacy_seed_accounts.sql} 置为 {@code is_active = 0}, + * 而 {@code TestAuthTokens} 只在用户行**不存在**时才插入,不会激活已存在的禁用行。 + * 曾因此在本类**单独运行**时全部 Token 请求被判 401(详见 + * {@code docs/r01-a6-independent-verification-20260917.md} §9)。 + * 改用独占号段后,本类的结果不再依赖 V1_1/V43 种子数据的存在性与状态, + * 也不再依赖其他测试类(如 {@code AuthIntegrationTest} 的 {@code deleteAll()})的执行顺序。

    + */ + static final long ADMIN_USER_ID = 9_310_001L; + static final long STUDENT_USER_ID = 9_310_002L; + + /** 独占手机号:不使用种子号的 138… 号段,也不与 {@code TestAuthTokens} 的 {@code "t"+id} 冲突。 */ + private static final String ADMIN_PHONE = "a6fair-admin"; + private static final String STUDENT_PHONE = "a6fair-student"; + + @BeforeEach + void provisionFixtureUsers() { + // 先清理可能的上轮残留(异常中断),再插入本类自有的 active 用户。 + // 注意:这里是"删除后重建",**不会**把任何已存在的 inactive 用户直接激活。 + removeFixtureUsers(); + insertActiveUser(ADMIN_USER_ID, ADMIN_PHONE, "ADMIN"); + insertActiveUser(STUDENT_USER_ID, STUDENT_PHONE, "STUDENT"); + } + + @AfterEach + void removeFixtureUsers() { + for (long id : new long[]{ADMIN_USER_ID, STUDENT_USER_ID}) { + // 外键顺序:会话 → 角色 → 用户;只删本类自建的确切 ID,不做任何整表删除。 + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); + } + } + + private void insertActiveUser(long id, String phone, String role) { + jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "a6-fairness-fixture-" + role, role); + } + @Test @SuppressWarnings("unchecked") void adminOverview_returnsAggregateWhitelistOnly() throws Exception { @@ -53,7 +99,7 @@ class FairnessMonitoringControllerTest { "40-49", 5, "50-59", 5, "60-69", 4, "70-79", 3, "80-89", 2, "90-100", 1)) .concentrationRate(new BigDecimal("16.67")).riskLevel("NORMAL") .availability("AVAILABLE").build(); - when(service.overview(eq(1L), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder() + when(service.overview(eq(ADMIN_USER_ID), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder() .basis("CURRENT_STORED_SNAPSHOTS") .scope("SCORED_SNAPSHOT_POPULATION") .period("2026-09") @@ -70,7 +116,7 @@ class FairnessMonitoringControllerTest { String json = mockMvc.perform(get("/admin/scoring/fairness/overview") .param("period", "2026-09") - .header("Authorization", token(1L, "ADMIN")) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN")) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.basis").value("CURRENT_STORED_SNAPSHOTS")) @@ -90,11 +136,11 @@ class FairnessMonitoringControllerTest { @Test void nonAdmin_isForbidden() throws Exception { doThrow(new BusinessException(ErrorCode.FORBIDDEN)) - .when(service).overview(eq(2L), eq("2026-09")); + .when(service).overview(eq(STUDENT_USER_ID), eq("2026-09")); mockMvc.perform(get("/admin/scoring/fairness/overview") .param("period", "2026-09") - .header("Authorization", token(2L, "STUDENT"))) + .header("Authorization", token(STUDENT_USER_ID, "STUDENT"))) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); } @@ -104,29 +150,29 @@ class FairnessMonitoringControllerTest { mockMvc.perform(get("/admin/scoring/fairness/overview") .param("period", "2026-09") .param("jobId", "9") - .header("Authorization", token(1L, "ADMIN"))) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN"))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); } @Test void rankingStability_returnsExplicitUnavailable() throws Exception { - when(service.rankingStability(eq(1L))).thenReturn(FairnessResponse.Unavailable.builder() + when(service.rankingStability(eq(ADMIN_USER_ID))).thenReturn(FairnessResponse.Unavailable.builder() .availability("UNAVAILABLE") .reason("RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE") .build()); - when(service.drift(eq(1L))).thenReturn(FairnessResponse.Unavailable.builder() + when(service.drift(eq(ADMIN_USER_ID))).thenReturn(FairnessResponse.Unavailable.builder() .availability("UNAVAILABLE") .reason("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE") .build()); mockMvc.perform(get("/admin/scoring/fairness/ranking-stability") - .header("Authorization", token(1L, "ADMIN"))) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN"))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")) .andExpect(jsonPath("$.data.reason").value("RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE")); mockMvc.perform(get("/admin/scoring/fairness/drift") - .header("Authorization", token(1L, "ADMIN"))) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN"))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")) .andExpect(jsonPath("$.data.reason").value("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE")); @@ -150,10 +196,10 @@ class FairnessMonitoringControllerTest { .unknownStatusCount(0).outOfRangeScoreCount(0).unknownRulesVersionCount(0) .distribution(distribution).distributionsByRulesVersion(Map.of("candidate-scoring-v0.2", distribution)) .availability("AVAILABLE").build(); - when(service.dimensions(eq(1L), eq("2026-09"), eq("ABILITY"))).thenReturn(FairnessResponse.Dimensions.builder() + when(service.dimensions(eq(ADMIN_USER_ID), eq("2026-09"), eq("ABILITY"))).thenReturn(FairnessResponse.Dimensions.builder() .basis("CURRENT_STORED_SNAPSHOTS").scope("SCORED_SNAPSHOT_POPULATION").period("2026-09") .availability("AVAILABLE").dimensions(java.util.List.of(metric)).build()); - when(service.appeals(eq(1L), eq(java.time.LocalDate.of(2026, 8, 1)), eq(java.time.LocalDate.of(2026, 9, 1)))) + when(service.appeals(eq(ADMIN_USER_ID), eq(java.time.LocalDate.of(2026, 8, 1)), eq(java.time.LocalDate.of(2026, 9, 1)))) .thenReturn(FairnessResponse.Appeals.builder().basis("APPEAL_CREATION_QUEUE_CURRENT_STATE") .from("2026-08-01").to("2026-09-01").appealCount(31).sampleSize(30) .statusCounts(Map.of("APPROVED", 20, "REJECTED", 10)).statusEventCounts(Map.of("APPROVED", 21, "REJECTED", 10)) @@ -167,14 +213,14 @@ class FairnessMonitoringControllerTest { .invalidScoreRollbackCount(1).ruleVersionChangedRollbackCount(1) .reasonCounts(Map.of("INVALID_SCORE", 1)).availability("AVAILABLE").build()); String dimensionJson = mockMvc.perform(get("/admin/scoring/fairness/dimensions").param("period", "2026-09").param("dimension", "ABILITY") - .header("Authorization", token(1L, "ADMIN")).accept(MediaType.APPLICATION_JSON)) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN")).accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()).andExpect(jsonPath("$.data.availability").value("AVAILABLE")) .andExpect(jsonPath("$.data.dimensions[0].distribution.mean").value(55.55)) .andExpect(jsonPath("$.data.dimensions[0].unknownStatusCount").value(0)) .andReturn().getResponse().getContentAsString(); assertNoSensitiveFields(dimensionJson); String appealJson = mockMvc.perform(get("/admin/scoring/fairness/appeals").param("from", "2026-08-01").param("to", "2026-09-01") - .header("Authorization", token(1L, "ADMIN")).accept(MediaType.APPLICATION_JSON)) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN")).accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()).andExpect(jsonPath("$.data.sampleSize").value(30)) .andExpect(jsonPath("$.data.statusCandidateCounts.APPROVED").value(20)) .andExpect(jsonPath("$.data.actualScoreChangeRate").value(37.50)) @@ -184,7 +230,7 @@ class FairnessMonitoringControllerTest { @Test void overviewSuppressionOmitsNestedDerivedValuesInSerializedJson() throws Exception { - when(service.overview(eq(1L), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder() + when(service.overview(eq(ADMIN_USER_ID), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder() .basis("CURRENT_STORED_SNAPSHOTS").scope("SCORED_SNAPSHOT_POPULATION") .period("2026-09").populationSize(100) .availability("SUPPRESSED").reason("SMALL_CELL_OR_COMPLEMENT_RISK") @@ -192,7 +238,7 @@ class FairnessMonitoringControllerTest { String json = mockMvc.perform(get("/admin/scoring/fairness/overview") .param("period", "2026-09") - .header("Authorization", token(1L, "ADMIN")) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN")) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.populationSize").value(100)) @@ -211,7 +257,6 @@ class FairnessMonitoringControllerTest { } private String token(long userId, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1380013" + userId, - Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java new file mode 100644 index 0000000..c3a1347 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java @@ -0,0 +1,301 @@ +package com.mydreamjob.scoringmonitoring; + +import com.mydreamjob.auth.support.TestAuthTokens; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Fairness 认证夹具隔离反证测试。 + * + *

    背景:{@code FairnessMonitoringControllerTest} 曾用 {@code userId = 1/2} 申请 Token, + * 而 {@code V1_1__seed_data.sql} 的种子账号正好占用 {@code users.id = 1/2/3}, + * 且 {@code V43__disable_legacy_seed_accounts.sql} 已把它们置为 {@code is_active = 0}; + * {@code TestAuthTokens} 对"已存在但 inactive"的行不插入也不激活,于是该测试类 + * **单独运行时**全部 Token 请求被 401 拒绝。全量顺序下 + * {@code com.mydreamjob.controller.AuthIntegrationTest} 的 {@code userRepository.deleteAll()} + * 偶然清掉了种子行,问题才被掩盖。

    + * + *

    本类是该缺陷的**反证**:证明修复后的夹具既不依赖种子行的存在性/状态, + * 也不依赖测试执行顺序,并且在清理时只删除自己创建的数据。

    + * + *

    本类自身不使用种子 ID,全部操作按精确 ID 限定,不做任何整表删除。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class FairnessMonitoringFixtureIsolationTest { + + /** 与 {@code FairnessMonitoringControllerTest} 相同的独占夹具 ID(同包可见)。 */ + private static final long ADMIN_USER_ID = FairnessMonitoringControllerTest.ADMIN_USER_ID; + + /** V1_1 种子账号占用的 ID;本类只读、只做快照与恢复,绝不把它们当作夹具。 */ + private static final List SEED_IDS = List.of(1L, 2L, 3L); + + /** 本类自有的探针 ID,不与其他测试共用。 */ + private static final long SENTINEL_USER_ID = 9_310_009L; + private static final long INACTIVE_USER_ID = 9_310_008L; + + private static final String DRIFT = "/admin/scoring/fairness/drift"; + + @Autowired private MockMvc mockMvc; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private FairnessMonitoringService service; + @MockBean private RedisTemplate redisTemplate; + + /** 种子行快照;非 null 时 {@link #restoreSeedRows()} 负责原样恢复。 */ + private List> seedSnapshot; + + @BeforeEach + void provision() { + cleanupOwnRows(); + insertActiveUser(ADMIN_USER_ID, "a6fair-admin", "ADMIN"); + when(service.drift(ADMIN_USER_ID)).thenReturn(FairnessResponse.Unavailable.builder() + .availability("UNAVAILABLE") + .reason("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE") + .build()); + } + + @AfterEach + void cleanup() { + restoreSeedRows(); + cleanupOwnRows(); + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", SENTINEL_USER_ID); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", SENTINEL_USER_ID); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", SENTINEL_USER_ID); + } + + private void cleanupOwnRows() { + for (long id : new long[]{ADMIN_USER_ID, INACTIVE_USER_ID}) { + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); + } + } + + private void insertActiveUser(long id, String phone, String role) { + jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "a6-fairness-isolation-" + role, role); + } + + private int isActive(long id) { + Integer v = jdbcTemplate.queryForObject("SELECT is_active FROM users WHERE id = ?", Integer.class, id); + return v == null ? -1 : v; + } + + private String bearer() { + return TestAuthTokens.bearer(ADMIN_USER_ID, "ADMIN"); + } + + // ── 1. 夹具 ID 不与被禁用的种子账号冲突 ──────────────────── + + @Test + @DisplayName("反证:夹具 ID 位于种子账号号段之外,且夹具用户确为 active") + void fixtureIdsAreOutsideSeedAccountRange() { + assertThat(ADMIN_USER_ID).as("夹具 ID 不得落在 V1_1 种子账号的 1/2/3 上") + .isNotIn(SEED_IDS); + assertThat(FairnessMonitoringControllerTest.STUDENT_USER_ID).isNotIn(SEED_IDS); + + assertThat(isActive(ADMIN_USER_ID)).as("夹具用户必须由本类显式插入为 active").isEqualTo(1); + + // 夹具手机号不得占用种子账号的手机号(唯一约束 + 语义隔离) + Integer squat = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone IN ('13800000001','13800000002','13800000003') " + + "AND id IN (?, ?)", Integer.class, ADMIN_USER_ID, + FairnessMonitoringControllerTest.STUDENT_USER_ID); + assertThat(squat).as("夹具不得占用种子账号手机号").isZero(); + } + + // ── 2. 种子行为 inactive(V43 状态)或缺失时,夹具仍可认证 ── + + @Test + @DisplayName("反证:种子行 inactive(V43 状态)或完全缺失时,Fairness 端点仍返回 200") + void seedRowsInactiveOrAbsent_stillAuthenticates() throws Exception { + snapshotAndTakeSeedRows(); + + // 阶段 A:种子行存在但 inactive(即 V43 执行后的真实状态,也是单跑时的状态) + jdbcTemplate.update("UPDATE users SET is_active = 0 WHERE id IN (1, 2, 3)"); + mockMvc.perform(get(DRIFT).header("Authorization", bearer())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")); + + // 阶段 B:种子行完全缺失(全量顺序下 AuthIntegrationTest.deleteAll() 之后的真实状态) + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (1, 2, 3)"); + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id IN (1, 2, 3)"); + jdbcTemplate.update("DELETE FROM users WHERE id IN (1, 2, 3)"); + mockMvc.perform(get(DRIFT).header("Authorization", bearer())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")); + } + + // ── 3. 连续多次夹具装配与请求不冲突 ──────────────────────── + + @Test + @DisplayName("反证:连续三次装配 + 三次请求均成功,无唯一约束或残留冲突") + void repeatedProvisioningAndRequests_doNotConflict() throws Exception { + for (int round = 1; round <= 3; round++) { + cleanupOwnRows(); + insertActiveUser(ADMIN_USER_ID, "a6fair-admin", "ADMIN"); + when(service.drift(ADMIN_USER_ID)).thenReturn(FairnessResponse.Unavailable.builder() + .availability("UNAVAILABLE") + .reason("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE") + .build()); + + mockMvc.perform(get(DRIFT).header("Authorization", bearer())) + .andExpect(status().isOk()); + } + + Integer users = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, ADMIN_USER_ID); + assertThat(users).as("重复装配不得产生重复用户行").isEqualTo(1); + } + + // ── 4. 夹具不触碰种子数据(顺序无关的根本保证)──────────── + + @Test + @DisplayName("反证:一次完整装配→请求→清理周期后,种子行逐字段不变") + void fixtureCycleLeavesSeedRowsUntouched() throws Exception { + List> before = seedRows(); + + mockMvc.perform(get(DRIFT).header("Authorization", bearer())).andExpect(status().isOk()); + cleanupOwnRows(); + insertActiveUser(ADMIN_USER_ID, "a6fair-admin", "ADMIN"); + + assertThat(seedRows()).as("夹具周期不得改写任何种子行").isEqualTo(before); + } + + // ── 5. 清理只删除本类自有数据 ────────────────────────────── + + @Test + @DisplayName("反证:清理只删除本类夹具行,探针用户与会话保持不变") + void cleanupRemovesOnlyOwnFixtureRows() { + jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, ?, ?, 1, 1)", SENTINEL_USER_ID, "a6fair-sentinel", + "a6-fairness-isolation-sentinel", "STUDENT"); + jdbcTemplate.update("INSERT INTO auth_tokens (user_id, token_hash, token_type, expired_at, revoked) " + + "VALUES (?, ?, 'REFRESH', ?, 0)", SENTINEL_USER_ID, "a6fair-sentinel-hash", + Timestamp.valueOf(java.time.LocalDateTime.now().plusDays(1))); + + cleanupOwnRows(); + + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, ADMIN_USER_ID)) + .as("本类夹具行应已被清理").isZero(); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, SENTINEL_USER_ID)) + .as("探针用户不得被清理波及").isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM auth_tokens WHERE user_id = ?", Integer.class, SENTINEL_USER_ID)) + .as("探针会话不得被清理波及").isEqualTo(1); + } + + // ── 6. 其他用户与会话不受影响 ────────────────────────────── + + @Test + @DisplayName("反证:装配→请求→清理周期前后,非夹具用户与会话逐字段不变") + void unrelatedUsersAndSessionsUnchanged() throws Exception { + List> usersBefore = nonFixtureUsers(); + List> tokensBefore = nonFixtureTokens(); + + mockMvc.perform(get(DRIFT).header("Authorization", bearer())).andExpect(status().isOk()); + cleanupOwnRows(); + + assertThat(nonFixtureUsers()).as("非夹具用户必须逐字段不变").isEqualTo(usersBefore); + assertThat(nonFixtureTokens()).as("非夹具会话必须逐字段不变").isEqualTo(tokensBefore); + } + + // ── 7. 公共夹具不会静默复活 inactive 用户 ───────────────── + + @Test + @DisplayName("反证:TestAuthTokens 不得把已存在的 inactive 用户静默激活") + void publicTokenFixtureDoesNotSilentlyReviveInactiveUser() throws Exception { + jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, ?, ?, 0, 1)", INACTIVE_USER_ID, "a6fair-inactive", + "a6-fairness-isolation-inactive", "ADMIN"); + + String token = TestAuthTokens.bearer(INACTIVE_USER_ID, "ADMIN"); + + assertThat(isActive(INACTIVE_USER_ID)) + .as("公共夹具必须保留该用户被禁用的状态,不得静默复活") + .isEqualTo(0); + mockMvc.perform(get(DRIFT).header("Authorization", token)) + .andExpect(status().isUnauthorized()); + verify(service, never()).drift(any()); + } + + // ── 工具 ─────────────────────────────────────────────────── + + private List> seedRows() { + return jdbcTemplate.queryForList( + "SELECT id, phone, email, nickname, password_hash, role, is_active, is_verified " + + "FROM users WHERE id IN (1, 2, 3) ORDER BY id"); + } + + private List> nonFixtureUsers() { + return jdbcTemplate.queryForList( + "SELECT id, phone, nickname, role, is_active FROM users WHERE id NOT IN (?, ?) ORDER BY id", + ADMIN_USER_ID, INACTIVE_USER_ID); + } + + private List> nonFixtureTokens() { + return jdbcTemplate.queryForList( + "SELECT id, user_id, token_type, revoked FROM auth_tokens " + + "WHERE user_id NOT IN (?, ?) ORDER BY id", + ADMIN_USER_ID, INACTIVE_USER_ID); + } + + /** 快照种子行(含主键与状态),供缺失场景后原样恢复。 */ + private void snapshotAndTakeSeedRows() { + seedSnapshot = jdbcTemplate.queryForList( + "SELECT id, phone, email, nickname, password_hash, role, is_active, is_verified, " + + "created_at, updated_at FROM users WHERE id IN (1, 2, 3) ORDER BY id"); + } + + /** + * 按精确主键恢复种子行。 + * + *

    只在测试**主动删除**过种子行时才执行({@code seedSnapshot} 非 null), + * 且严格还原全部字段,不新增、不批量重建、不影响其他行。

    + */ + private void restoreSeedRows() { + if (seedSnapshot == null) { + return; + } + List> snapshot = new ArrayList<>(seedSnapshot); + seedSnapshot = null; + for (Map row : snapshot) { + Long id = ((Number) row.get("id")).longValue(); + if (jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, id) > 0) { + continue; + } + jdbcTemplate.update("INSERT INTO users (id, phone, email, nickname, password_hash, role, " + + "is_active, is_verified, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + id, row.get("phone"), row.get("email"), row.get("nickname"), row.get("password_hash"), + row.get("role"), row.get("is_active"), row.get("is_verified"), + row.get("created_at"), row.get("updated_at")); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringHttpIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringHttpIntegrationTest.java index 191e3ea..1331da6 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringHttpIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringHttpIntegrationTest.java @@ -301,7 +301,6 @@ class FairnessMonitoringHttpIntegrationTest { } private String token(long userId, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1380013" + userId, - Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringPersistenceIntegrationTest.java index 2d4238f..85fce85 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringPersistenceIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringPersistenceIntegrationTest.java @@ -102,7 +102,7 @@ class FairnessMonitoringPersistenceIntegrationTest { String prefix = UUID.randomUUID().toString().substring(0,8); List> cases = List.of(List.of("10.00"), List.of("10.00","10.01"), List.of("10.00","10.00","10.01"), List.of("10.00","10.01","10.01")); - var calculator = new CandidateScoringCalculator(null,null,null,null,null,null); + var calculator = new CandidateScoringCalculator(null, null, null, null, null, null, null); for (int c=0;c覆盖任务书要求的 8 类输入,并额外覆盖 Long 溢出与“已认证但名称为 anonymousUser”的绕过尝试。 + * 所有非法输入必须转换为既有“未登录”语义({@link InsufficientAuthenticationException}, + * 由全局异常处理器映射为 401),且不回显非法 principal。

    + */ +class CurrentUserAccessorTest { + + private static final String BAD_PRINCIPAL = "INTERNAL_BAD_PRINCIPAL_SECRET"; + + private final CurrentUserAccessor accessor = new CurrentUserAccessor(); + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void nullAuthenticationIsUnauthorized() { + assertUnauthorized(() -> accessor.requireUserId((Authentication) null)); + } + + @Test + void unauthenticatedAuthenticationIsUnauthorized() { + // 双参构造 → isAuthenticated() == false + Authentication unauthenticated = new UsernamePasswordAuthenticationToken("42", null); + assertThat(unauthenticated.isAuthenticated()).isFalse(); + + assertUnauthorized(() -> accessor.requireUserId(unauthenticated)); + } + + @Test + void anonymousAuthenticationTokenIsUnauthorized() { + AnonymousAuthenticationToken anonymous = new AnonymousAuthenticationToken( + "anonymous-key", "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + // 关键前提:Spring 的匿名令牌 isAuthenticated() 为 true, + // 仅检查 isAuthenticated() 会把匿名当成已登录用户。 + assertThat(anonymous.isAuthenticated()).isTrue(); + + assertUnauthorized(() -> accessor.requireUserId(anonymous)); + } + + @Test + void anonymousUserNameIsUnauthorized() { + Authentication authenticatedButAnonymousName = + UsernamePasswordAuthenticationToken.authenticated("anonymousUser", null, List.of()); + + assertUnauthorized(() -> accessor.requireUserId(authenticatedButAnonymousName)); + } + + @Test + void blankPrincipalIsUnauthorized() { + assertUnauthorized(() -> accessor.requireUserId( + UsernamePasswordAuthenticationToken.authenticated(" ", null, List.of()))); + } + + @Test + void nonNumericPrincipalIsUnauthorizedWithoutEcho() { + Authentication bad = UsernamePasswordAuthenticationToken.authenticated(BAD_PRINCIPAL, null, List.of()); + + assertThatThrownBy(() -> accessor.requireUserId(bad)) + .isInstanceOf(InsufficientAuthenticationException.class) + .satisfies(e -> { + assertThat(e.getMessage()).doesNotContain(BAD_PRINCIPAL); + assertThat(String.valueOf(e)).doesNotContain(BAD_PRINCIPAL); + // 异常及其堆栈不得携带非法 principal 原文 + assertThat(java.util.Arrays.toString(e.getStackTrace())) + .doesNotContain(BAD_PRINCIPAL); + }); + } + + @Test + void nonPositiveUserIdIsUnauthorized() { + assertUnauthorized(() -> accessor.requireUserId( + UsernamePasswordAuthenticationToken.authenticated("0", null, List.of()))); + assertUnauthorized(() -> accessor.requireUserId( + UsernamePasswordAuthenticationToken.authenticated("-1", null, List.of()))); + } + + @Test + void validNumericPrincipalReturnsUserId() { + assertThat(accessor.requireUserId( + UsernamePasswordAuthenticationToken.authenticated("42", null, List.of()))) + .isEqualTo(42L); + + assertThat(accessor.requireUserId( + UsernamePasswordAuthenticationToken.authenticated( + String.valueOf(Long.MAX_VALUE), null, List.of()))) + .isEqualTo(Long.MAX_VALUE); + } + + // ── 额外边界:不得让 NumberFormatException 逃逸 ───────────────── + + @Test + void numericOverflowIsUnauthorizedNotNumberFormatException() { + Authentication overflow = UsernamePasswordAuthenticationToken.authenticated( + "99999999999999999999", null, List.of()); + + assertThatThrownBy(() -> accessor.requireUserId(overflow)) + .isInstanceOf(InsufficientAuthenticationException.class) + .isNotInstanceOf(NumberFormatException.class); + } + + @Test + void plusSignAndWhitespacePrincipalsAreUnauthorized() { + assertUnauthorized(() -> accessor.requireUserId( + UsernamePasswordAuthenticationToken.authenticated("+42", null, List.of()))); + assertUnauthorized(() -> accessor.requireUserId( + UsernamePasswordAuthenticationToken.authenticated(" 42", null, List.of()))); + assertUnauthorized(() -> accessor.requireUserId( + UsernamePasswordAuthenticationToken.authenticated("42 ", null, List.of()))); + } + + // ── SecurityContext 版本 ──────────────────────────────────────── + + @Test + void requireUserIdReadsFromSecurityContext() { + assertUnauthorized(() -> accessor.requireUserId()); + + SecurityContextHolder.getContext().setAuthentication( + UsernamePasswordAuthenticationToken.authenticated("7", null, List.of())); + assertThat(accessor.requireUserId()).isEqualTo(7L); + + SecurityContextHolder.getContext().setAuthentication( + new AnonymousAuthenticationToken("k", "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))); + assertUnauthorized(() -> accessor.requireUserId()); + } + + private void assertUnauthorized(Runnable action) { + assertThatThrownBy(action::run) + .isInstanceOf(InsufficientAuthenticationException.class); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java index bfef8e9..b0898d8 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java @@ -57,8 +57,7 @@ class TalentDiscoveryControllerTest { } private String token(long userId, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1380013" + userId, - java.util.Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java index 8b434eb..794dcf5 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java @@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.job.Job; @@ -13,6 +15,7 @@ import com.mydreamjob.recommendation.JobRecommendationProfileRepository; import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPageResponse; import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPreferenceUpdateRequest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -32,6 +35,11 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class TalentDiscoveryServiceTest { + + @BeforeEach + void wireRoleGuard() { + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + } @Mock StudentTalentDiscoveryPreferenceRepository preferenceRepository; @Mock UserRepository userRepository; @Mock CompanyRepository companyRepository; @@ -41,6 +49,9 @@ class TalentDiscoveryServiceTest { @Mock TalentDiscoveryScoreCalculator scoreCalculator; @Mock AuditService auditService; @Mock CandidateKeyGenerator candidateKeyGenerator; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks TalentDiscoveryService service; @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java index 132f2f2..837cf16 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java @@ -56,6 +56,6 @@ class TalentInvitationControllerTest { } private String token(Long userId, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/verification/CompanyVerificationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/verification/CompanyVerificationServiceTest.java index c584542..b8260ea 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/verification/CompanyVerificationServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/verification/CompanyVerificationServiceTest.java @@ -4,6 +4,8 @@ import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.Company; @@ -37,6 +39,9 @@ class CompanyVerificationServiceTest { @Mock private AuditService auditService; @Mock private EntityManager entityManager; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private CompanyVerificationService verificationService; @@ -51,6 +56,9 @@ class CompanyVerificationServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + company = Company.builder().id(companyId).ownerUserId(companyUserId) .name("测试企业").verified(false).build(); companyUser = User.builder().id(companyUserId).roles(Set.of(User.Role.COMPANY)).build(); diff --git a/mydreamjob-server/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/mydreamjob-server/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..64e551a --- /dev/null +++ b/mydreamjob-server/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.mydreamjob.auth.support.TestAuthTokensAutoConfiguration diff --git a/mydreamjob-server/src/test/resources/application-integration-test.yml b/mydreamjob-server/src/test/resources/application-integration-test.yml index 1588536..c43bbd0 100644 --- a/mydreamjob-server/src/test/resources/application-integration-test.yml +++ b/mydreamjob-server/src/test/resources/application-integration-test.yml @@ -34,6 +34,10 @@ spring: enabled: false app: + scoring: + refresh: + enabled: false + compensation-enabled: false redis: enabled: false notification: @@ -55,6 +59,9 @@ app: file-ttl-hours: 24 jwt: secret: test-jwt-secret-key-for-integration-tests-only-not-for-production + access-secret: test-access-secret-key-for-integration-tests-only-not-for-production + refresh-secret: test-refresh-secret-key-for-integration-tests-only-not-for-production + token-hash-secret: test-token-hash-secret-key-for-integration-tests-only-not-for-production expiration-ms: 3600000 refresh-expiration-ms: 86400000 sms: diff --git a/mydreamjob-server/src/test/resources/application-mysql-it.yml b/mydreamjob-server/src/test/resources/application-mysql-it.yml new file mode 100644 index 0000000..80d0fdb --- /dev/null +++ b/mydreamjob-server/src/test/resources/application-mysql-it.yml @@ -0,0 +1,59 @@ +# Q01 MySQL 真实层(Testcontainers)测试配置。 +# +# 只提供应用级测试开关与测试专用 JWT 密钥;**不覆盖数据源与 JPA 方言**: +# - 连接参数(URL / 用户名 / 密码)由测试类的 @DynamicPropertySource 注入容器地址; +# - 方言与 ddl-auto 沿用主配置(org.hibernate.dialect.MySQLDialect + validate)。 +# 这样 MySQL 用例与 H2 用例跑在**同一套应用配置**上,仅有数据库实现不同,便于逐项对账。 +# +# 密钥仅用于本地测试,长度 ≥ 32 且三项两两不同,满足 AppJwtProperties 校验。 + +spring: + config: + activate: + on-profile: mysql-it + main: + allow-bean-definition-overriding: true + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + data: + redis: + enabled: false + rabbitmq: + enabled: false + +app: + scoring: + refresh: + enabled: false + compensation-enabled: false + redis: + enabled: false + notification: + mq-enabled: false + offer: + scheduler: + enabled: false + match: + notification: + enabled: false + risk: + scan: + enabled: false + resume: + storage-dir: ./target/resumes + export-dir: ./target/resume-exports + data-export: + dir: ./target/data-exports + file-ttl-hours: 24 + jwt: + secret: mysql-it-legacy-secret-for-q01-tests-only-0001 + access-secret: mysql-it-access-secret-for-q01-tests-only-0001 + refresh-secret: mysql-it-refresh-secret-for-q01-tests-only-0001 + token-hash-secret: mysql-it-token-hash-secret-for-q01-tests-only-0001 + expiration-ms: 3600000 + refresh-expiration-ms: 86400000 + sms: + mock: + enabled: true + expire-seconds: 300 diff --git a/mydreamjob-server/src/test/resources/application-test.yml b/mydreamjob-server/src/test/resources/application-test.yml index 7666c4b..516062a 100644 --- a/mydreamjob-server/src/test/resources/application-test.yml +++ b/mydreamjob-server/src/test/resources/application-test.yml @@ -20,8 +20,15 @@ spring: enabled: false app: + scoring: + refresh: + enabled: false + compensation-enabled: false jwt: secret: test-jwt-secret-key-for-unit-tests-only-not-for-production + access-secret: test-access-secret-key-for-unit-tests-only-not-for-production + refresh-secret: test-refresh-secret-key-for-unit-tests-only-not-for-production + token-hash-secret: test-token-hash-secret-key-for-unit-tests-only-not-for-production expiration-ms: 3600000 refresh-expiration-ms: 604800000 diff --git a/mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv b/mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv new file mode 100644 index 0000000..7c6ad79 --- /dev/null +++ b/mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv @@ -0,0 +1,169 @@ +# R01-B 独立复验基线:从 HEAD:GlobalExceptionHandler.buildBusinessHttpStatuses() 独立提取 +# 列: ErrorCode数字码HEAD 状态(未登记则为 400 默认) +# 生成自 git HEAD 版本,与工作区实现无任何共享代码 +SUCCESS 1000 400 +BAD_REQUEST 1001 400 +INTERNAL_ERROR 1002 400 +UNAUTHORIZED 2001 400 +FORBIDDEN 2002 FORBIDDEN +TOKEN_EXPIRED 2003 400 +TOKEN_INVALID 2004 400 +CREDENTIALS_INVALID 2005 400 +SMS_CODE_SENT 3001 400 +SMS_CODE_EXPIRED 3002 400 +SMS_CODE_INVALID 3003 400 +SMS_CODE_ATTEMPTS_EXCEEDED 3004 400 +SMS_RATE_LIMITED 3005 400 +SMS_SEND_FAILED 3006 400 +USER_NOT_FOUND 4001 NOT_FOUND +USER_ALREADY_EXISTS 4002 400 +USER_DISABLED 4003 400 +COMPANY_NOT_FOUND 5001 NOT_FOUND +COMPANY_OWNER_MISMATCH 5002 400 +COMPANY_ALREADY_EXISTS 5003 CONFLICT +PROFILE_NOT_FOUND 6001 NOT_FOUND +JOB_NOT_FOUND 7001 NOT_FOUND +JOB_OWNER_MISMATCH 7002 400 +INVALID_JOB_STATUS_TRANSITION 7003 CONFLICT +APPLICATION_DUPLICATE 8001 CONFLICT +APPLICATION_NOT_FOUND 8002 NOT_FOUND +INVALID_APPLICATION_STATUS_TRANSITION 8003 CONFLICT +APPLICATION_OWNER_MISMATCH 8004 FORBIDDEN +OFFER_NOT_FOUND 8101 NOT_FOUND +DUPLICATE_OFFER 8102 CONFLICT +INVALID_OFFER_STATUS_TRANSITION 8103 CONFLICT +OFFER_EXPIRED 8104 CONFLICT +OFFER_NOT_EDITABLE 8105 CONFLICT +OFFER_APPLICATION_NOT_ELIGIBLE 8106 400 +OFFER_REMINDER_DUPLICATE 8107 CONFLICT +OFFER_NOT_REMINDABLE 8108 400 +OFFER_COUNTER_OFFER_EXISTS 8109 CONFLICT +OFFER_COUNTER_OFFER_NOT_FOUND 8110 NOT_FOUND +OFFER_COUNTER_OFFER_NOT_PENDING 8111 CONFLICT +OFFER_NOT_WITHDRAWABLE 8112 400 +OFFER_COUNTER_OFFER_EXPIRED 8113 CONFLICT +OFFER_NOT_NEGOTIABLE 8114 400 +OFFER_COMPARE_TOO_MANY 8115 400 +OFFER_COMPARE_NOT_OWNER 8116 FORBIDDEN +RESUME_VERSION_NOT_FOUND 8201 NOT_FOUND +DEFAULT_RESUME_DELETE_NOT_ALLOWED 8202 CONFLICT +RESUME_EXPORT_TASK_NOT_FOUND 8203 NOT_FOUND +RESUME_EXPORT_IN_PROGRESS 8204 CONFLICT +DUPLICATE_DEFAULT_RESUME 8205 CONFLICT +RESUME_EXPORT_NOT_READY 8206 CONFLICT +MATCH_CLOSED 8301 CONFLICT +MATCH_SCORES_LOCKED 8302 CONFLICT +MATCH_REQUIRED_FOR_CONVERSATION 8303 FORBIDDEN +APPLICATION_NOT_MATCHED 8304 CONFLICT +CONVERSATION_NOT_FOUND 8305 NOT_FOUND +MESSAGE_NOT_FOUND 8306 NOT_FOUND +MESSAGE_RECALL_WINDOW_EXPIRED 8307 CONFLICT +MESSAGE_RECALL_CONCURRENT_UPDATE 8308 CONFLICT +DUPLICATE_REVIEW 9101 CONFLICT +REVIEW_NOT_FOUND 9102 NOT_FOUND +REVIEW_ALREADY_DECIDED 9103 CONFLICT +DUPLICATE_FEEDBACK 9103 CONFLICT +FEEDBACK_NOT_FOUND 9104 NOT_FOUND +NOTIFICATION_NOT_FOUND 9105 400 +REVIEW_WEIGHT_CONCURRENT_UPDATE 9106 CONFLICT +INTERVIEW_NOT_FOUND 8401 400 +INTERVIEW_ACCESS_DENIED 8402 FORBIDDEN +INVALID_INTERVIEW_STATUS_TRANSITION 8403 CONFLICT +INTERVIEW_TIME_INVALID 8404 400 +INTERVIEW_DURATION_EXCEEDED 8405 400 +INTERVIEW_ONLINE_URL_REQUIRED 8406 400 +INTERVIEW_OFFLINE_ADDRESS_REQUIRED 8407 400 +INTERVIEW_START_TIME_PAST 8408 400 +INTERVIEW_COMPLETE_NOT_READY 8409 CONFLICT +INTERVIEW_ALREADY_RESPONDED 8410 CONFLICT +VERIFICATION_NOT_FOUND 8501 NOT_FOUND +VERIFICATION_ALREADY_PENDING 8502 CONFLICT +VERIFICATION_ALREADY_APPROVED 8503 CONFLICT +INVALID_VERIFICATION_STATUS_TRANSITION 8504 CONFLICT +VERIFICATION_REJECT_REASON_REQUIRED 8505 400 +APPEAL_NOT_FOUND 8601 NOT_FOUND +APPEAL_DUPLICATE 8602 CONFLICT +APPEAL_ALREADY_DECIDED 8603 CONFLICT +APPEAL_REASON_REQUIRED 8604 400 +APPEAL_ROLLBACK_LIMIT_EXCEEDED 8605 CONFLICT +APPEAL_INVALID_TRANSITION 8606 CONFLICT +APPEAL_IDEMPOTENCY_CONFLICT 8607 CONFLICT +SERVICE_UNAVAILABLE 9001 400 +RATE_LIMITED 9002 400 +PRIVACY_SETTINGS_CONCURRENT_UPDATE 9201 CONFLICT +INVALID_DELETION_STATE 9301 CONFLICT +ACCOUNT_DELETION_PENDING 9302 CONFLICT +ACCOUNT_DELETED 9303 CONFLICT +ACCOUNT_DELETION_CONCURRENT_UPDATE 9304 CONFLICT +ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED 9305 CONFLICT +LAST_ADMIN_CANNOT_DELETE 9306 CONFLICT +DATA_EXPORT_TASK_NOT_FOUND 9401 NOT_FOUND +DATA_EXPORT_DUPLICATE 9402 CONFLICT +DATA_EXPORT_NOT_READY 9403 CONFLICT +DATA_EXPORT_EXPIRED 9404 CONFLICT +DATA_EXPORT_FAILED 9405 CONFLICT +DATA_EXPORT_FILE_NOT_FOUND 9406 CONFLICT +INTERVIEW_SCORE_CONCURRENT_UPDATE 9501 CONFLICT +INTERVIEW_NOT_COMPLETED 9502 CONFLICT +INTERVIEW_SCORE_SUBMITTED 9503 CONFLICT +INTERVIEW_SCORE_NOT_FOUND 9504 NOT_FOUND +REPORT_NOT_FOUND 9601 NOT_FOUND +CANNOT_REPORT_SELF 9602 400 +REPORT_DUPLICATE 9603 CONFLICT +REPORT_ALREADY_DECIDED 9604 CONFLICT +REPORT_INVALID_TRANSITION 9605 CONFLICT +REPORT_IDEMPOTENCY_CONFLICT 9606 CONFLICT +REPORT_REASON_REQUIRED 9607 400 +CANNOT_BLOCK_SELF 9608 400 +REPORT_RATE_LIMITED 9609 TOO_MANY_REQUESTS +BLOCKED_SEND_PROHIBITED 9610 400 +ASSESSMENT_TYPE_NOT_FOUND 9701 NOT_FOUND +ASSESSMENT_ATTEMPT_NOT_FOUND 9702 NOT_FOUND +ASSESSMENT_ATTEMPT_INVALID_STATE 9703 CONFLICT +ASSESSMENT_ANSWER_INVALID 9705 400 +ASSESSMENT_INCOMPLETE 9706 400 +ASSESSMENT_CONCURRENT_UPDATE 9707 CONFLICT +ASSESSMENT_DUPLICATE_ACTIVE 9708 CONFLICT +ASSESSMENT_IDEMPOTENCY_CONFLICT 9709 CONFLICT +ABILITY_TYPE_NOT_FOUND 9801 NOT_FOUND +ABILITY_ATTEMPT_NOT_FOUND 9802 NOT_FOUND +ABILITY_ATTEMPT_INVALID_STATE 9803 CONFLICT +ABILITY_RESULT_NOT_FOUND 9804 NOT_FOUND +ABILITY_ANSWER_INVALID 9805 400 +ABILITY_INCOMPLETE 9806 400 +ABILITY_CONCURRENT_UPDATE 9807 CONFLICT +ABILITY_DUPLICATE_ACTIVE 9808 CONFLICT +ABILITY_IDEMPOTENCY_CONFLICT 9809 CONFLICT +ABILITY_RETAKE_COOLDOWN 9810 CONFLICT +ABILITY_RETAKE_LIMIT 9811 TOO_MANY_REQUESTS +RISK_RULE_NOT_FOUND 9901 NOT_FOUND +RISK_EVENT_NOT_FOUND 9902 NOT_FOUND +RISK_RULE_CONCURRENT_UPDATE 9903 CONFLICT +RISK_EVENT_CONCURRENT_UPDATE 9904 CONFLICT +RISK_INVALID_ACTION 9905 CONFLICT +RISK_INVALID_FILTER 9906 400 +RECOMMENDATION_PROFILE_NOT_FOUND 10001 400 +RECOMMENDATION_PROFILE_INVALID 10002 400 +RECOMMENDATION_PROFILE_CONCURRENT_UPDATE 10003 400 +RECOMMENDATION_INVALID_QUERY 10004 400 +RECOMMENDATION_TAXONOMY_INVALID 10005 400 +RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE 10006 400 +RECOMMENDATION_FEEDBACK_IDEMPOTENCY_CONFLICT 10007 400 +RECOMMENDATION_FEEDBACK_NOT_FOUND 10008 400 +RECOMMENDATION_PROFILE_INVALID_REVIEW_STATE 10009 400 +REVIEW_REPLY_NOT_FOUND 10101 NOT_FOUND +REVIEW_REPLY_ALREADY_EXISTS 10102 CONFLICT +REVIEW_REPLY_INVALID_STATE 10103 CONFLICT +REVIEW_REPLY_CONCURRENT_UPDATE 10104 CONFLICT +REVIEW_REPLY_CONTENT_INVALID 10105 400 +REVIEW_REPLY_IDEMPOTENCY_CONFLICT 10106 CONFLICT +REVIEW_REPLY_DECISION_INVALID 10107 400 +TALENT_INVITATION_NOT_FOUND 10201 NOT_FOUND +TALENT_INVITATION_CANDIDATE_UNAVAILABLE 10202 CONFLICT +TALENT_INVITATION_ALREADY_ACTIVE 10203 CONFLICT +TALENT_INVITATION_INVALID_STATE 10204 CONFLICT +TALENT_INVITATION_EXPIRED 10205 CONFLICT +TALENT_INVITATION_CONCURRENT_UPDATE 10206 CONFLICT +TALENT_INVITATION_IDEMPOTENCY_CONFLICT 10207 CONFLICT +TALENT_INVITATION_CONTENT_INVALID 10208 400 +TALENT_INVITATION_RATE_LIMITED 10209 TOO_MANY_REQUESTS