20 KiB
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,未出现环境性跳过。
执行命令(原样):
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 节):
- 只给
city条件时,用例原本期望同城但PART_TIME的岗位不出现——契约只规定城市精确匹配,返回它是正确的; salaryMin=20000时用例原本期望只返回边界岗位,但契约是单边比较,salaryMax=60000 >= 20000的岗位同样应命中;salaryMax=10000时同理,salaryMin=5000 <= 10000的岗位同样应命中。
修正方式是把期望改为与设计文档第 4 节契约表一致,并加强而非放宽断言(补齐两条「区间完全在用户区间之外」的反向断言、补 doesNotContain 前缀城市断言)。H2 用例的同名场景夹具本就设计正确(相邻岗位取「刚好越界」值),因此未受影响。
11. EXPLAIN 摘要
真实 MySQL 8.4 上以 EXPLAIN FORMAT=JSON 采集,等价 SQL 与 Criteria 路径绑定同一组参数,并先断言二者返回完全相同的 ID 与顺序(保证 EXPLAIN 的不是一份不存在的语句)。完整原文见 Q01 MySQL 执行计划证据。
| 语句 | 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 类非生产缺陷问题,均已记录:
- MySQL 用例无法启动上下文:该类最初未激活任何 profile,导致
app.jwt.*测试密钥缺失、AppJwtProperties校验失败(JWT_ACCESS_SECRET must be at least 32 characters)。这是用例配置缺失,不是生产缺陷(生产要求 JWT 密钥必须显式配置,行为正确)。修复方式是新增测试专用 profilemysql-it,并保持连接参数由@DynamicPropertySource注入。 - 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 测试与证据)
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 个定向生产修复、测试注释与文档)
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 评估替代方案。