# 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 追加章节于提交前保持**待提交**状态。