轻松聘AI开发
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

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 节):

  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 执行计划证据。

语句 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 测试与证据)

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 评估替代方案。