forked from baskbuddy/zhangshun
18 changed files with 3093 additions and 8 deletions
@ -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" |
||||
|
``` |
||||
|
|
||||
|
<details><summary>EXPLAIN FORMAT=JSON 原文</summary> |
||||
|
|
||||
|
```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))" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
</details> |
||||
|
|
||||
|
## 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" |
||||
|
``` |
||||
|
|
||||
|
<details><summary>EXPLAIN FORMAT=JSON 原文</summary> |
||||
|
|
||||
|
```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'))" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
</details> |
||||
|
|
||||
|
## 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" |
||||
|
``` |
||||
|
|
||||
|
<details><summary>EXPLAIN FORMAT=JSON 原文</summary> |
||||
|
|
||||
|
```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'))" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
</details> |
||||
|
|
||||
|
## 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" |
||||
|
``` |
||||
|
|
||||
|
<details><summary>EXPLAIN FORMAT=JSON 原文</summary> |
||||
|
|
||||
|
```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))" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
</details> |
||||
|
|
||||
|
## 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 | |
||||
@ -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 验收。 |
||||
@ -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 验收。 |
||||
@ -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 评估替代方案。 |
||||
@ -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 查询副作用证据:业务表的**行级稳定字段快照**。 |
||||
|
* |
||||
|
* <p>只比较 COUNT / SUM / CRC32 只能说明“行数没变”,不能说明“没有某一行被改写”。 |
||||
|
* 因此这里对每张受检表按主键排序逐行取值,查询前后各取一次,逐行逐字段比较。</p> |
||||
|
* |
||||
|
* <p>只选取显式列出的稳定业务字段,不使用 {@code SELECT *},也不包含任何凭据列 |
||||
|
* (例如 {@code users.password_hash}),因此失败信息中不会出现密码摘要。</p> |
||||
|
*/ |
||||
|
final class BusinessRowSnapshot { |
||||
|
|
||||
|
/** 受检表 → 稳定业务字段(顺序即 SELECT 列顺序,也是比较顺序)。 */ |
||||
|
private static final Map<String, List<String>> 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<String> tables() { |
||||
|
return TABLES.keySet(); |
||||
|
} |
||||
|
|
||||
|
/** 逐表按主键顺序捕获行级快照。 */ |
||||
|
Map<String, List<Map<String, Object>>> capture() { |
||||
|
Map<String, List<Map<String, Object>>> snapshot = new LinkedHashMap<>(); |
||||
|
TABLES.forEach((table, columns) -> { |
||||
|
String sql = "SELECT " + String.join(", ", columns) + " FROM " + table + " ORDER BY id"; |
||||
|
List<Map<String, Object>> rows = new ArrayList<>(); |
||||
|
for (Map<String, Object> row : jdbcTemplate.queryForList(sql)) { |
||||
|
Map<String, Object> 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<String, List<Map<String, Object>>> before, |
||||
|
Map<String, List<Map<String, Object>>> after) { |
||||
|
List<String> differences = new ArrayList<>(); |
||||
|
for (String table : TABLES.keySet()) { |
||||
|
List<Map<String, Object>> beforeRows = before.get(table); |
||||
|
List<Map<String, Object>> 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<Map<String, Object>> before, |
||||
|
List<Map<String, Object>> 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 ""; |
||||
|
} |
||||
|
} |
||||
@ -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** 上的执行计划与查询次数证据。 |
||||
|
* |
||||
|
* <p>对组合过滤、latest 分页、salary 排序与 count 四类语句执行 |
||||
|
* {@code EXPLAIN FORMAT=JSON},记录使用索引、access type、估算行数以及 |
||||
|
* filesort / temporary 情况,并测量结果规模为 1 / 20 / 50 时的 SQL 语句条数。</p> |
||||
|
* |
||||
|
* <p>EXPLAIN 使用的是与 Criteria 语义等价的 SQL,绑定同一组参数;用例会先断言 |
||||
|
* 等价 SQL 与 JPA/Criteria 路径返回**完全相同的 ID 与顺序**,避免 EXPLAIN 一份 |
||||
|
* 并不存在的语句。</p> |
||||
|
* |
||||
|
* <p>按 Q01 边界,本用例只记录、不改动索引;发现的问题留给 R01。</p> |
||||
|
*/ |
||||
|
@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<String> 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<String, Object> redisTemplate; |
||||
|
|
||||
|
@Autowired private JobService jobService; |
||||
|
@Autowired private JobRepository jobRepository; |
||||
|
@Autowired private CompanyRepository companyRepository; |
||||
|
@Autowired private UserRepository userRepository; |
||||
|
@Autowired private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
private final List<Long> jobIds = new ArrayList<>(); |
||||
|
private final List<Long> companyIds = new ArrayList<>(); |
||||
|
private final List<Long> userIds = new ArrayList<>(); |
||||
|
|
||||
|
private String tag; |
||||
|
private Long companyA; |
||||
|
private final Map<Integer, String> 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<Job> 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<Job> 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<Long> jpaCombined = ids(jobRepository.findAll(combined, latest(0, 20))); |
||||
|
List<String> 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<Job> 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<Long> 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<Long> 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<String> repositoryStatements = Q01SqlRecorder.drain(); |
||||
|
|
||||
|
Q01SqlRecorder.clear(); |
||||
|
var servicePage = jobService.searchJobs( |
||||
|
null, citiesByResultSize.get(size), null, null, null, null, "latest", 0, 20); |
||||
|
List<String> 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<Long> jpaIds) { |
||||
|
List<Long> 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("<details><summary>EXPLAIN FORMAT=JSON 原文</summary>\n\n```json\n") |
||||
|
.append(plan).append("\n```\n\n</details>\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<String> 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<Long> ids(org.springframework.data.domain.Page<Job> 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"))); |
||||
|
} |
||||
|
} |
||||
@ -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 层。 |
||||
|
* |
||||
|
* <p>与 {@link JobSearchQuerySideEffectMySqlIntegrationTest} 执行完全相同的用例, |
||||
|
* 用于 H2 / 真实 MySQL 逐项对账。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class JobSearchQuerySideEffectIntegrationTest extends JobSearchQuerySideEffectTestBase { |
||||
|
|
||||
|
@MockBean |
||||
|
private RedisTemplate<String, Object> redisTemplate; |
||||
|
} |
||||
@ -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** 层。 |
||||
|
* |
||||
|
* <p>用例与 {@link JobSearchQuerySideEffectIntegrationTest} 完全相同(同一基类), |
||||
|
* 用于 H2 / MySQL 逐项对账。使用独立容器、正式 Flyway 迁移与 {@code ddl-auto=validate}, |
||||
|
* 不复用开发库,不打印任何连接凭据。</p> |
||||
|
*/ |
||||
|
@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<String, Object> redisTemplate; |
||||
|
} |
||||
@ -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 查询副作用的**行级**证据基类。 |
||||
|
* |
||||
|
* <p>证明公开搜索({@link JobService#searchJobs} 与 |
||||
|
* {@link JobSearchSpecification#searchJobs} 的真实 Repository 路径)只读, |
||||
|
* 不会改写岗位状态、企业、用户或其他业务表。</p> |
||||
|
* |
||||
|
* <p>做法:在 jobs、companies、users、applications、candidate_dimension_scores、 |
||||
|
* notifications、conversations、messages、audit_logs 九张表上取**行级稳定字段快照**, |
||||
|
* 执行完整搜索矩阵后再次取快照,逐表逐行逐字段比较。只比较 COUNT / SUM / CRC32 |
||||
|
* 无法证明“没有某一行被改写”,因此这里不做聚合比较。</p> |
||||
|
* |
||||
|
* <p>快照比较本身也可能失效(例如永远返回相等),所以另有三条**反证测试**:人为改字段、 |
||||
|
* 插行、删行,必须被快照发现。</p> |
||||
|
* |
||||
|
* <p>本基类不绑定数据库:H2 用例与真实 MySQL 用例各自继承并断言同一组行为, |
||||
|
* 从而形成跨库逐项对账。</p> |
||||
|
*/ |
||||
|
abstract class JobSearchQuerySideEffectTestBase { |
||||
|
|
||||
|
private static final AtomicLong SEQ = new AtomicLong(74000); |
||||
|
private static final int MAX_RESULTS = 50; |
||||
|
|
||||
|
/** 清理顺序:先删有外键指向其他受检表的行,再删主表。 */ |
||||
|
private static final List<String> 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<Long> jobIds = new ArrayList<>(); |
||||
|
private final List<Long> companyIds = new ArrayList<>(); |
||||
|
private final List<Long> userIds = new ArrayList<>(); |
||||
|
private final Map<String, List<Long>> 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<Long> 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<String, List<Map<String, Object>>> 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<String, List<Map<String, Object>>> before = snapshot.capture(); |
||||
|
Long jobId = jobIds.get(0); |
||||
|
Map<String, Object> 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<String, List<Map<String, Object>>> before = snapshot.capture(); |
||||
|
|
||||
|
insertNotification(tag + "-反证插入"); |
||||
|
|
||||
|
assertThat(BusinessRowSnapshot.describeDifference(before, snapshot.capture())) |
||||
|
.as("反证:新增一行必须被快照发现") |
||||
|
.contains("notifications:"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void snapshotReportsDeletedRow() { |
||||
|
Long temporaryId = insertNotification(tag + "-反证删除"); |
||||
|
Map<String, List<Map<String, Object>>> 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<String, Object> rowById(Map<String, List<Map<String, Object>>> 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 <T, ID> void deleteAllById(JpaRepository<T, ID> repository, List<ID> ids) { |
||||
|
if (!ids.isEmpty()) { |
||||
|
repository.deleteAllById(ids); |
||||
|
} |
||||
|
ids.clear(); |
||||
|
} |
||||
|
} |
||||
@ -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})。 |
||||
|
* |
||||
|
* <p>原 {@code JobSearchServiceTest} 保留,承担参数校验与编排职责;本类补的是排序、 |
||||
|
* 分页与企业信息组装在真实数据库上的行为。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class JobSearchServiceIntegrationTest { |
||||
|
|
||||
|
private static final AtomicLong SEQ = new AtomicLong(72000); |
||||
|
|
||||
|
@MockBean |
||||
|
private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
@Autowired private JobService jobService; |
||||
|
@Autowired private JobRepository jobRepository; |
||||
|
@Autowired private CompanyRepository companyRepository; |
||||
|
@Autowired private UserRepository userRepository; |
||||
|
@Autowired private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
private final List<Long> jobIds = new ArrayList<>(); |
||||
|
private final List<Long> companyIds = new ArrayList<>(); |
||||
|
private final List<Long> 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<Long> ids(Page<JobSearchItem> 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<JobSearchItem> 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<Long> created = new ArrayList<>(); |
||||
|
for (int i = 0; i < 5; i++) { |
||||
|
created.add(publishedJob(companyA, city, "FULL_TIME", 10000, 20000 + i)); |
||||
|
} |
||||
|
// salaryMax 递增 → 期望顺序为其反序
|
||||
|
List<Long> expected = new ArrayList<>(created); |
||||
|
java.util.Collections.reverse(expected); |
||||
|
|
||||
|
Page<JobSearchItem> p0 = jobService.searchJobs(null, city, null, null, null, null, "salary", 0, 2); |
||||
|
Page<JobSearchItem> p1 = jobService.searchJobs(null, city, null, null, null, null, "salary", 1, 2); |
||||
|
Page<JobSearchItem> p2 = jobService.searchJobs(null, city, null, null, null, null, "salary", 2, 2); |
||||
|
|
||||
|
List<Long> 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<JobSearchItem> 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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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)。 |
||||
|
* |
||||
|
* <p>与 {@code JobSearchServiceTest} 的区别:这里不 Mock Repository / Specification / |
||||
|
* EntityManager / 事务,直接执行 {@code jobRepository.findAll(spec, pageable)}, |
||||
|
* 断言返回的**ID 及顺序**,从而真正验证 Criteria 谓词、过滤与排序。</p> |
||||
|
* |
||||
|
* <p>夹具隔离:每个用例使用唯一城市标记,只在自己的数据上断言,不依赖种子岗位。</p> |
||||
|
*/ |
||||
|
@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<String, Object> redisTemplate; |
||||
|
|
||||
|
@Autowired private JobRepository jobRepository; |
||||
|
@Autowired private CompanyRepository companyRepository; |
||||
|
@Autowired private UserRepository userRepository; |
||||
|
@Autowired private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
private final List<Long> jobIds = new ArrayList<>(); |
||||
|
private final List<Long> companyIds = new ArrayList<>(); |
||||
|
private final List<Long> 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<Long> 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<Long> 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<Long> 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(); |
||||
|
} |
||||
|
} |
||||
@ -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** 上的行为对账。 |
||||
|
* |
||||
|
* <p>使用 Testcontainers 启动独立 MySQL(不复用开发库),执行正式 Flyway 迁移并以 |
||||
|
* {@code ddl-auto=validate} 启动,重点对账 H2 难以覆盖的差异:大小写比较规则、 |
||||
|
* LIKE 特殊字符、NULL 排序、边界比较与分页稳定性。</p> |
||||
|
* |
||||
|
* <p>不打印任何数据库凭据。Docker 不可用时环境性跳过({@code disabledWithoutDocker}), |
||||
|
* 但独立验收必须显式运行本类并得到 Tests run > 0、Skipped = 0。</p> |
||||
|
*/ |
||||
|
@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<String, Object> redisTemplate; |
||||
|
|
||||
|
@Autowired private JobRepository jobRepository; |
||||
|
@Autowired private JobService jobService; |
||||
|
@Autowired private CompanyRepository companyRepository; |
||||
|
@Autowired private UserRepository userRepository; |
||||
|
@Autowired private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
private final List<Long> jobIds = new ArrayList<>(); |
||||
|
private final List<Long> companyIds = new ArrayList<>(); |
||||
|
private final List<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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(); |
||||
|
} |
||||
|
} |
||||
@ -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。 |
||||
|
* |
||||
|
* <p>通过 {@code hibernate.session_factory.statement_inspector} 注册(仅 MySQL EXPLAIN |
||||
|
* 用例使用),用来在报告中留下**真实生成**的 SQL 原文,而不是只留一份手写的等价 SQL。</p> |
||||
|
*/ |
||||
|
public class Q01SqlRecorder implements StatementInspector { |
||||
|
|
||||
|
private static final List<String> STATEMENTS = Collections.synchronizedList(new ArrayList<>()); |
||||
|
|
||||
|
@Override |
||||
|
public String inspect(String sql) { |
||||
|
STATEMENTS.add(sql); |
||||
|
return sql; |
||||
|
} |
||||
|
|
||||
|
static void clear() { |
||||
|
STATEMENTS.clear(); |
||||
|
} |
||||
|
|
||||
|
static List<String> drain() { |
||||
|
List<String> copy = new ArrayList<>(STATEMENTS); |
||||
|
STATEMENTS.clear(); |
||||
|
return copy; |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
Loading…
Reference in new issue