diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index ca6a494..6db8591 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -14,6 +14,7 @@ | S01 认证安全整改 | 独立代码复验与真实 MySQL/Docker 黑盒全部通过(`b5084fd`) | 保持验证码受控取码、双开关与 `/jobs` 认证边界不变 | | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | +| Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成真实查询测试实现与自动化验证,待独立代码复验及独立真实 MySQL/Docker 验收。新增 50 条真实库测试:H2 真实 Repository 19、H2 真实 Service 9、H2 查询副作用 4、真实 MySQL 8(Testcontainers)规格对账 13、MySQL 查询副作用 4、MySQL EXPLAIN 1;全部 `Failures=0 / Errors=0 / Skipped=0` | 不改接口、DTO、状态机、权限与搜索语义;不新增 Flyway / ErrorCode / 公网接口;OpenAPI 保持 176/214;EXPLAIN 观察项(前导通配符、filesort)移交 R01 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/phase1-scope-and-acceptance.md b/docs/phase1-scope-and-acceptance.md index c7ffe09..79d21ec 100644 --- a/docs/phase1-scope-and-acceptance.md +++ b/docs/phase1-scope-and-acceptance.md @@ -33,6 +33,29 @@ | 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/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/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/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/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/resources/application-mysql-it.yml b/mydreamjob-server/src/test/resources/application-mysql-it.yml new file mode 100644 index 0000000..d6d9cde --- /dev/null +++ b/mydreamjob-server/src/test/resources/application-mysql-it.yml @@ -0,0 +1,55 @@ +# 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: + 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