Browse Source

fix: align job search queries across databases

test/q01-job-search-specification
Zhang Shun 1 month ago
parent
commit
77dc624c71
  1. 1
      docs/backend-development-roadmap.md
  2. 23
      docs/phase1-scope-and-acceptance.md
  3. 303
      docs/q01-job-search-mysql-explain-evidence.md
  4. 190
      docs/q01-job-search-real-query-design.md
  5. 265
      docs/q01-job-search-real-query-implementation-instruction.md
  6. 265
      docs/q01-job-search-real-query-implementation-report.md
  7. 18
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java
  8. 126
      mydreamjob-server/src/test/java/com/mydreamjob/job/BusinessRowSnapshot.java
  9. 364
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQueryExplainMySqlIntegrationTest.java
  10. 20
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectIntegrationTest.java
  11. 44
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectMySqlIntegrationTest.java
  12. 299
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectTestBase.java
  13. 270
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java
  14. 13
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java
  15. 425
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java
  16. 384
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java
  17. 34
      mydreamjob-server/src/test/java/com/mydreamjob/job/Q01SqlRecorder.java
  18. 55
      mydreamjob-server/src/test/resources/application-mysql-it.yml

1
docs/backend-development-roadmap.md

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

23
docs/phase1-scope-and-acceptance.md

@ -33,6 +33,29 @@
| S02 Token 隔离与会话吊销 | Access/Refresh 类型与密钥隔离、轮换、重放处置、会话吊销 | auth/(JwtTokenProvider、AuthSessionService、AuthTokenHasher、JwtAuthenticationFilter、AuthController)、V42__auth_token_sessions.sql | Refresh 不能访问普通接口;Access 不能刷新;二次使用撤销当前会话;并发刷新最多成功一次;登出/注销完成使会话失效 | 张顺 / 独立复验 | | S02 Token 隔离与会话吊销 | Access/Refresh 类型与密钥隔离、轮换、重放处置、会话吊销 | auth/(JwtTokenProvider、AuthSessionService、AuthTokenHasher、JwtAuthenticationFilter、AuthController)、V42__auth_token_sessions.sql | Refresh 不能访问普通接口;Access 不能刷新;二次使用撤销当前会话;并发刷新最多成功一次;登出/注销完成使会话失效 | 张顺 / 独立复验 |
| S03 生产配置与简历隐私 | 历史种子账号治理、一次性管理员初始化、企业简历下载授权、Actuator 隔离 | auth/(AdminBootstrapRunner)、application/(CompanyResumeAccessPolicy、CompanyResumeService)、V43__disable_legacy_seed_accounts.sql、application.yml | V43 只禁用完全匹配原始指纹的三条种子账号;管理员初始化默认关闭且幂等;三种可见性 × 全部投递状态 × 跨企业/未认证企业矩阵;拒绝路径零文件读取;业务端口不提供指标 | 张顺 / 独立复验 | | S03 生产配置与简历隐私 | 历史种子账号治理、一次性管理员初始化、企业简历下载授权、Actuator 隔离 | auth/(AdminBootstrapRunner)、application/(CompanyResumeAccessPolicy、CompanyResumeService)、V43__disable_legacy_seed_accounts.sql、application.yml | V43 只禁用完全匹配原始指纹的三条种子账号;管理员初始化默认关闭且幂等;三种可见性 × 全部投递状态 × 跨企业/未认证企业矩阵;拒绝路径零文件读取;业务端口不提供指标 | 张顺 / 独立复验 |
### 2.1 Q01 岗位搜索真实查询覆盖(C02 证据补充)
`JobSearchSpecification` 与 `JobService.searchJobs` 原先只被纯 Mockito 测试覆盖(`JobSearchServiceTest` 把 `findAll(Specification, Pageable)` 整体 Mock 掉),无法证明 Criteria 谓词、SQL 过滤、排序与分页在真实数据库上的行为。Q01 补齐真实查询证据,**只新增测试与文档,未改动生产代码、接口、DTO、状态机、权限、搜索语义与索引**。
测试分层与结果(详见 [Q01 实施报告](q01-job-search-real-query-implementation-report.md)):
| 层次 | 测试类 | 数据库 | Tests run | Failures | Errors | Skipped |
|---|---|---|---|---|---|---|
| H2 真实 Repository | `job/JobSearchSpecificationIntegrationTest` | H2 + 真实 Flyway | 19 | 0 | 0 | 0 |
| H2 真实 Service / EntityManager | `job/JobSearchServiceIntegrationTest` | H2 + 真实 Flyway | 9 | 0 | 0 | 0 |
| H2 查询副作用行级快照 | `job/JobSearchQuerySideEffectIntegrationTest` | H2 + 真实 Flyway | 4 | 0 | 0 | 0 |
| 真实 MySQL 8 规格对账 | `job/JobSearchSpecificationMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 13 | 0 | 0 | 0 |
| 真实 MySQL 查询副作用行级快照 | `job/JobSearchQuerySideEffectMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 4 | 0 | 0 | 0 |
| 真实 MySQL 执行计划与查询次数 | `job/JobSearchQueryExplainMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 1 | 0 | 0 | 0 |
- MySQL 层用正式 Flyway 迁移 + `ddl-auto=validate` 启动,连接参数由 `@DynamicPropertySource` 运行期注入,不落盘、不打印;核心用例**真实执行、无 SKIP**。
- H2 与 MySQL 在 keyword(三字段/大小写/trim/空白与 `%`、`_` 特殊字符)、city、employmentType、companyId、薪资边界与区间重叠、PUBLISHED 强制过滤、latest/relevance/salary 排序、分页与 total 上**逐项一致,未发现语义差异**。
- 副作用证据为 jobs、companies、users、applications、candidate_dimension_scores、notifications、conversations、messages、audit_logs 九张表的**行级稳定字段快照**(非 COUNT/SUM),并配「改字段 / 插行 / 删行」三条**反证测试**证明比较本身有效。
- 真实 MySQL 上对组合过滤、latest 分页、salary 排序与 count 执行 `EXPLAIN FORMAT=JSON`;小样本夹具下为全表扫描 + filesort,**不构成生产规模结论**,前导通配符与排序成本移交 R01。
- OpenAPI 保持 176 paths / 214 operations,`docs/openapi-rc.json` 未改动。
状态:**Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及独立真实 MySQL/Docker 验收。**
## 3. 一期必须补齐的八个功能组 ## 3. 一期必须补齐的八个功能组
以下交付边界用于控制设计范围;涉及产品参数的内容明确列为待评审,不得由实现者自行锁定。 以下交付边界用于控制设计范围;涉及产品参数的内容明确列为待评审,不得由实现者自行锁定。

303
docs/q01-job-search-mysql-explain-evidence.md

@ -0,0 +1,303 @@
# Q01 MySQL 执行计划证据(由 JobSearchQueryExplainMySqlIntegrationTest 生成)
- 数据库:`mysql:8.4` Testcontainers 独立容器,正式 Flyway 迁移,`ddl-auto=validate`
- EXPLAIN 形式:`EXPLAIN FORMAT=JSON`,与 Criteria 路径绑定同一组参数
- 夹具规模:每个城市分别 1 / 20 / 50 条 PUBLISHED 岗位(刻意做小,用于验证语义而非压测)
- Q01 不新增索引;执行计划问题记录给 R01
## 1. 组合过滤 + latest 分页
Hibernate 实际生成并执行的 SQL(占位符形式):
```sql
select j1_0.id,j1_0.city,j1_0.company_id,j1_0.created_at,j1_0.description,j1_0.employment_type,j1_0.published_at,j1_0.requirements,j1_0.salary_max,j1_0.salary_min,j1_0.status,j1_0.title,j1_0.updated_at from jobs j1_0 where j1_0.status=? and (lower(j1_0.title) like ? escape '' or lower(j1_0.description) like ? escape '' or lower(j1_0.requirements) like ? escape '') and lower(j1_0.city)=? and lower(j1_0.employment_type)=? and j1_0.salary_max>=? and j1_0.salary_min<=? and j1_0.company_id=? order by j1_0.created_at desc,j1_0.id desc limit ?,?
select count(j1_0.id) from jobs j1_0 where j1_0.status=? and (lower(j1_0.title) like ? escape '' or lower(j1_0.description) like ? escape '' or lower(j1_0.requirements) like ? escape '') and lower(j1_0.city)=? and lower(j1_0.employment_type)=? and j1_0.salary_max>=? and j1_0.salary_min<=? and j1_0.company_id=?
```
等价 SQL(绑定参数 [PUBLISHED, %q01e75001岗位%, %q01e75001岗位%, %q01e75001岗位%, Q01E75001CITY20, FULL_TIME, 5000, 30000, 1, 20, 0]):
```sql
SELECT j.id FROM jobs j
WHERE j.status = ? AND (lower(j.title) LIKE ? OR lower(j.description) LIKE ? OR lower(j.requirements) LIKE ?)
AND lower(j.city) = ? AND lower(j.employment_type) = ? AND j.salary_max >= ? AND j.salary_min <= ? AND j.company_id = ?
ORDER BY j.created_at DESC, j.id DESC
LIMIT ? OFFSET ?
```
计划摘要:
```
query_cost = 1.35
using_filesort = true
using_temporary_table = false
- 表 query_block/ORDER BY: table_name="j", access_type="ALL", possible_keys=["idx_jobs_company_status","idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00"
```
<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 |

190
docs/q01-job-search-real-query-design.md

@ -0,0 +1,190 @@
# Q01 岗位搜索真实查询覆盖设计
## 1. 状态
设计已确认,待实施、专项测试、真实 MySQL 复验与独立验收。
## 2. 背景与问题
`JobSearchSpecification` 承担公开岗位搜索的核心过滤规则,但当前 `JobSearchServiceTest` 将
`JobRepository.findAll(Specification, Pageable)` Mock 掉后直接返回预设数据。这些测试可以证明
参数校验、分页对象和 DTO 组装,却不能证明 Criteria 谓词、SQL 过滤、排序和分页真的正确。
Q01 的目标是补齐真实数据库证据,不以测试数量替代查询行为覆盖,也不改变现有公网接口和业务语义。
## 3. 范围
### 3.1 必须覆盖
- `JobSearchSpecification.searchJobs(...)` 的真实 Repository 查询;
- `JobService.searchJobs(...)` 的 latest、relevance、salary 排序和分页;
- PUBLISHED 强制过滤;
- keyword 对 title、description、requirements 的匹配;
- city、employmentType、companyId;
- salaryMin、salaryMax 的区间重叠语义;
- H2 快速测试与 MySQL 8 行为一致性;
- 查询参数化和特殊字符边界;
- 真实 SQL/EXPLAIN 证据及数据副作用检查。
### 3.2 明确不做
- 不改变 `/jobs/search` 请求或响应;
- 不新增、删除或重命名公网接口;
- 不修改岗位状态机、岗位归属或角色权限;
- 不把 relevance 改造成新的全文检索算法;当前 relevance 与 latest 等价,本轮锁定该语义;
- 不接入 Elasticsearch、搜索引擎或缓存;
- 不新增 Flyway、表或索引;
- 不改 Candidate Scoring、推荐、排名、邀约、Offer、申诉、消息、简历和认证;
- 不删除现有 Mock 测试。现有测试继续承担 Service 编排与参数校验职责;
- 不进入 R01 性能和结构重构。
## 4. 当前查询契约
| 参数 | 既有语义 |
|---|---|
| status | 无论调用方传什么条件,只返回 `PUBLISHED` |
| keyword | trim 后忽略大小写,匹配 title、description、requirements 任一字段 |
| city | trim 后忽略大小写精确匹配;显式归一化以保证 H2/MySQL 一致 |
| employmentType | trim 后忽略大小写精确匹配;不做前缀匹配 |
| salaryMin | 岗位 `salaryMax >= salaryMin` |
| salaryMax | 岗位 `salaryMin <= salaryMax` |
| companyId | 精确匹配 |
| sort=latest/null | `createdAt DESC, id DESC` |
| sort=relevance | 当前与 latest 相同,不改变 |
| sort=salary | `salaryMax` 非空优先、降序;相同值按 `id DESC`;NULL 最后 |
| page | 从 0 开始,不得为负数 |
| size | 1–50 |
薪资过滤是区间重叠,不是“岗位上下限都完全落在用户区间内”。边界相等必须命中。
## 5. 测试分层
### 5.1 H2 Repository 快速层
新增 `JobSearchSpecificationIntegrationTest`:
- 使用 Spring Data JPA、真实 `JobRepository` 和真实 H2;
- 激活 `integration-test`,由真实 Flyway 建表;
- 不 Mock Repository、EntityManager、Specification、CriteriaBuilder 或事务;
- 每个用例创建最小隔离夹具,按精确主键清理;
- 直接执行 `jobRepository.findAll(spec, pageable)`;
- 断言返回 ID 集合与顺序,不能只断言数量。
至少覆盖:
1. title 关键词;
2. description 关键词;
3. requirements 关键词;
4. 关键词忽略大小写;
5. null、空字符串、纯空白关键词;
6. keyword 前后空格;
7. city 精确匹配与 trim;
8. employmentType 精确匹配与 trim;
9. companyId;
10. salaryMin;
11. salaryMax;
12. 两端薪资边界相等;
13. 薪资区间重叠;
14. 薪资区间不重叠;
15. 多条件 AND;
16. DRAFT、CLOSED 等非公开状态排除;
17. 无结果;
18. 排序与分页;
19. `%`、`_`、反斜杠、引号等特殊字符的实际既有语义;
20. null salary 字段的行为。
特殊字符测试首先锁定当前 Criteria `LIKE` 行为,不得在没有产品决策时把 `%` 和 `_` 自动改成字面量搜索。
### 5.2 Service 真实查询层
新增或扩展 `JobSearchServiceIntegrationTest`:
- 使用真实 `JobService`、`JobRepository`、`CompanyRepository`、EntityManager;
- 不 Mock `findAll`;
- latest/null/relevance 的排序结果一致且稳定;
- salary 排序验证 NULL LAST、降序、相同薪资按 ID 倒序;
- 验证 page/size、totalElements、totalPages;
- 验证企业信息批量组装,不改变 DTO;
- 保留现有非法 sort、非法分页、非法薪资范围的测试。
### 5.3 MySQL 真实层
优先新增 `JobSearchSpecificationMySqlIntegrationTest`,使用项目已有 Testcontainers 依赖:
- `@Testcontainers(disabledWithoutDocker = true)` 仅允许本地快速执行时环境性跳过;
- 独立验收和最终冻结时 Docker/MySQL 核心用例不得 SKIP;
- 使用 MySQL 8.x 容器、正式 Flyway 和 `ddl-auto=validate`;
- 使用动态属性注入 JDBC URL、用户名和密码;
- 不打印数据库凭据;
- 至少运行一组与 H2 完全相同的参数化用例,并对返回 ID 与顺序逐项对账;
- 重点对账大小写、LIKE 特殊字符、NULL 排序、边界比较、分页稳定性。
如果仓库约定不允许 Testcontainers 进入普通 `mvn test`,可以将其放入独立 profile;但最终报告必须给出真实执行命令和非 SKIP 结果,不能只提交一个从未运行的测试类。
## 6. 夹具设计
使用明确区分的岗位:
- 三个包含关键词的岗位,关键词分别只存在于 title、description、requirements;
- 相同城市和不同城市;
- FULL_TIME、PART_TIME 等不同 employmentType;
- 至少两个企业;
- 薪资区间覆盖边界相等、部分重叠、不重叠、NULL;
- 相同 createdAt 的两个岗位,用 ID 验证稳定排序;
- 相同 salaryMax 的两个岗位,用 ID 验证稳定排序;
- PUBLISHED、DRAFT、CLOSED 等状态。
夹具必须以测试生成的唯一标识隔离,不能依赖 V1_1 演示数据或真实业务数据。
## 7. 数据库与副作用
- 查询测试不得修改岗位状态、企业、用户或其他业务表;
- 查询前后对相关稳定业务字段做行级快照,而不是只比较 COUNT;
- Testcontainers 由测试生命周期销毁;
- 使用负责人现有 MySQL 做独立黑盒时,只能精确清理本轮夹具;
- 不允许清库、重置自增、删除 volume;
- 不新增 Flyway 或索引。发现执行计划问题只记录到 R01,不在 Q01 修复。
## 8. SQL 与 EXPLAIN
真实 MySQL 验收需记录:
- 组合过滤 SQL;
- latest 分页;
- salary 排序;
- count 查询;
- 使用索引、access type、estimated rows、filesort/temporary 情况;
- 1、20、50 条结果规模下查询次数。
本阶段以“语义正确且无明显发布阻断”为通过条件,不为了 EXPLAIN 修改索引。
## 9. OpenAPI 与兼容性
- 不修改 path 和 operation;
- 目标仍为 176 paths / 214 operations;
- 不改变参数名、默认值、响应 DTO 或错误码;
- `docs/openapi-rc.json` 不应因仅新增测试而变化;
- 必须运行 `docs/verify_openapi.py` 防止意外契约漂移。
## 10. 通过条件
只有同时满足以下条件才能通过 Q01:
1. `JobSearchSpecification` 不再是测试零引用;
2. H2 真实 Repository 测试覆盖全部过滤语义;
3. Service 自定义 salary 排序有真实数据库证据;
4. MySQL 核心用例真实运行且无 SKIP;
5. H2 与 MySQL 返回 ID、顺序和边界语义一致,或明确记录并解决差异;
6. 非公开岗位绝不进入公开搜索;
7. 分页稳定、total 正确;
8. 无业务数据副作用;
9. 原 Mock 测试保留且准确标注为编排测试;
10. 全量测试和 clean verify 通过;
11. OpenAPI 保持 176/214;
12. 无新增 Flyway、ErrorCode 或公网接口。
## 11. 最终状态
实施完成但尚未独立复验时,只能写:
> Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及真实 MySQL/Docker 验收。

265
docs/q01-job-search-real-query-implementation-instruction.md

@ -0,0 +1,265 @@
# Q01 岗位搜索真实查询覆盖实施指令
## 一、任务边界
项目根目录:`C:\Users\15642\Desktop\zhilai`
目标分支:`test/q01-job-search-specification`
本轮只补岗位搜索真实数据库测试和验收证据。原则上不修改生产代码;如果测试复现生产缺陷,先保存失败证据并停止,未经负责人确认不得顺手修复。
禁止修改岗位接口、DTO、权限、状态机和搜索语义;禁止新增 Flyway、表、索引、ErrorCode 或公网接口;禁止进入 R01;禁止删除现有 Mock 测试;禁止 `git add -A`、commit、push、reset、restore、checkout、clean;禁止删除或移动历史未跟踪文件。
## 二、开始前核查
执行:
```powershell
cd C:\Users\15642\Desktop\zhilai
git branch --show-current
git status --short
git diff --cached --name-only
git log -5 --oneline
git diff --check
git status --short -- mydreamjob-server/src/main/resources/db/migration
```
确认分支正确、暂存区为空、HEAD 为 S03 检查点 `cb3bc57` 或报告实际偏差、历史未跟踪文件保留、V1–V43 均未修改。搜索并完整阅读适用 `AGENTS.md`;当前基线未发现时记录“未发现”。
## 三、必须阅读
```text
docs/q01-job-search-real-query-design.md
mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java
mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java
mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java
mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java
mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java
mydreamjob-server/src/test/resources/application-test.yml
mydreamjob-server/src/test/resources/application-integration-test.yml
mydreamjob-server/pom.xml
mydreamjob-server/src/main/resources/db/migration/V2__business_schema.sql
mydreamjob-server/src/main/resources/db/migration/V22__job_search_indexes.sql
```
执行:
```powershell
rg -n "JobSearchSpecification|findAll\(.*Specification|findWithSalarySort|findPublishedJobs" mydreamjob-server/src
```
记录哪些现有测试 Mock 了查询层,不能把它们描述成真实过滤覆盖。
## 四、先写 H2 真实 Repository 测试
新增:
```text
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java
```
要求:
- 激活 `integration-test`;
- 使用真实 Flyway、H2、JobRepository;
- 不 Mock Repository、Specification、EntityManager 或事务;
- 直接调用 `jobRepository.findAll(JobSearchSpecification.searchJobs(...), pageable)`;
- 每项断言返回 ID 和顺序;
- 测试隔离,不依赖种子岗位;
- 不用顺序无关的 containsOnly 代替排序断言。
至少实现以下测试:
```text
keywordMatchesTitle
keywordMatchesDescription
keywordMatchesRequirements
keywordIsCaseInsensitive
nullBlankAndWhitespaceKeywordDoNotFilter
keywordIsTrimmed
cityUsesTrimmedExactMatch
cityCaseVariantHasSameResultAcrossDatabases
employmentTypeUsesTrimmedExactMatch
employmentTypeCaseVariantHasSameResultAcrossDatabases
companyIdFiltersExactly
salaryMinMatchesWhenJobMaximumEqualsBoundary
salaryMaxMatchesWhenJobMinimumEqualsBoundary
salaryRangesUseOverlapSemantics
nonOverlappingSalaryRangeIsExcluded
multipleConditionsUseAndSemantics
nonPublishedJobsAreAlwaysExcluded
specialLikeCharactersFollowExistingContract
nullSalaryFieldsFollowExistingContract
paginationReturnsStableOrderedIds
noResultReturnsEmptyPage
```
不要为了让测试符合预期而预先修改 `JobSearchSpecification`。如果任何既有语义与设计不一致,保留失败测试并报告。
## 五、补 Service 真实查询测试
新增:
```text
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java
```
使用真实 JobService、JobRepository、CompanyRepository 和 EntityManager,覆盖:
```text
latestSortUsesCreatedAtThenIdDescending
nullSortUsesLatestOrder
relevanceCurrentlyUsesLatestOrder
salarySortUsesMaximumDescendingAndNullLast
salarySortUsesIdDescendingAsTieBreaker
salarySortReturnsCorrectTotalAndTotalPages
secondPageDoesNotRepeatOrLoseRows
companyInformationIsMappedFromRealRepository
searchDoesNotReturnDraftOrClosedJobs
```
原 `JobSearchServiceTest` 保留,用于参数校验和编排;如需修改注释,只能明确它不是 Specification SQL 覆盖,不得删除或放宽断言。
## 六、补 MySQL 真实测试
新增建议文件:
```text
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java
```
使用已有 `org.testcontainers:mysql` 和 `junit-jupiter`:
- MySQL 8.x;
- 正式 Flyway;
- Hibernate `ddl-auto=validate`;
- `@DynamicPropertySource` 注入连接参数;
- 不打印密码;
- 不复用开发数据库;
- 不 Mock 查询路径;
- 容器用例至少对账 keyword 大小写、特殊字符、城市、薪资边界、非公开过滤、latest、salary NULL LAST、分页。
可复用参数化场景或夹具构造器,确保 H2/MySQL 输入一致。必须逐项断言结果 ID/业务标识和顺序,而不是只断言数量。
如果普通开发环境 Docker 不可用,可以让 Testcontainers 测试环境性跳过;但最终独立验收必须显式运行该类并得到 Tests run > 0、Skipped=0。不得用 H2 结果代替 MySQL。
## 七、查询副作用与执行计划
新增测试或独立验收证据证明查询前后以下数据不变:
- jobs 稳定业务字段;
- companies;
- applications;
- candidate_dimension_scores;
- notifications;
- messages;
- audit_logs。
使用行级稳定字段快照,不能只比较 COUNT、SUM 或 CRC32。
真实 MySQL 验收对组合过滤、latest、salary 和 count SQL 执行 `EXPLAIN FORMAT=JSON`,记录使用索引、access type、estimated rows、temporary/filesort。Q01 不新增索引;发现性能问题记录给 R01。
## 八、文档
更新:
```text
docs/backend-development-roadmap.md
docs/phase1-scope-and-acceptance.md
```
只记录 Q01 的测试层级和真实结果,不改变接口文档。原则上不修改 `docs/openapi-rc.json`;若仅新增测试却产生 OpenAPI 差异,视为异常并停止调查。
新增最终实施报告建议:
```text
docs/q01-job-search-real-query-implementation-report.md
```
不得写“真实 MySQL 已通过”,除非 Testcontainers 或独立 MySQL 实际运行且核心项无 SKIP。
## 九、验证顺序
先运行 H2 测试:
```powershell
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server
mvn -Dtest="JobSearchSpecificationIntegrationTest,JobSearchServiceIntegrationTest" test
```
Docker 可用时运行 MySQL 测试:
```powershell
mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest" test
```
运行岗位关联回归:
```powershell
mvn -Dtest="*JobSearch*Test,*Job*Test,*CompanyPublic*Test" test
```
随后:
```powershell
mvn test
mvn clean verify
python ..\docs\verify_openapi.py
```
最后:
```powershell
cd C:\Users\15642\Desktop\zhilai
git diff --check
git status --short
git diff --cached --name-only
git status --short -- mydreamjob-server/src/main/resources/db/migration
```
要求所有必跑测试 Failures=0、Errors=0、Skipped=0,clean verify 成功,OpenAPI 保持 176/214,暂存区为空,V1–V43 未修改。
## 十、发现缺陷时
如果真实测试发现生产缺陷:
1. 保留最小失败测试;
2. 记录 H2 与 MySQL 是否一致;
3. 精确写明参数、夹具、预期 ID、实际 ID/顺序和生成 SQL;
4. 不自行修改生产代码;
5. 状态标记为 FAIL,等待负责人下发定向整改指令。
禁止通过修改夹具、删除断言、Mock Repository、禁用测试或放宽为只断言数量来制造绿色。
## 十一、最终报告
必须包含:
1. 分支与 HEAD;
2. 现有 Mock 测试的真实职责;
3. H2 测试数及结果;
4. MySQL 测试数及结果;
5. 每个过滤条件的实际结果;
6. keyword 三字段、大小写和特殊字符结果;
7. 薪资边界与区间重叠结果;
8. PUBLISHED 强制过滤结果;
9. latest/relevance/salary 排序结果;
10. 分页与 total 结果;
11. H2/MySQL 差异;
12. EXPLAIN 摘要;
13. 数据副作用结果;
14. 关联和全量测试数;
15. Failures/Errors/Skipped;
16. clean verify;
17. OpenAPI paths/operations;
18. 精确修改文件;
19. 是否修改生产代码;
20. 是否新增 Flyway、ErrorCode 或接口;
21. Git 状态;
22. 明确未暂存、未提交、未推送;
23. 是否存在待整改缺陷。
实现和自动化通过后只能写:
> Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及真实 MySQL/Docker 验收。

265
docs/q01-job-search-real-query-implementation-report.md

@ -0,0 +1,265 @@
# Q01 岗位搜索真实查询覆盖实施报告
> **状态**:Q01 已完成真实查询测试实现、跨库一致性整改及独立复验,可以进入精确暂存检查点。
## 1. 分支与 HEAD
| 项目 | 值 |
|---|---|
| 分支 | `test/q01-job-search-specification` |
| HEAD | `cb3bc57fab549137e3785f5d50bde86ce8ef5731`(S03 检查点 `cb3bc57`,无偏差) |
| 暂存区 | 空(`git diff --cached --name-only` 无输出) |
| 生产代码 | 定向修改 `JobSearchSpecification`:对 city/employmentType 显式 `lower`,消除 H2/MySQL 排序规则差异,并保持线上 MySQL 既有忽略大小写结果 |
| V1–V43 迁移 | 未修改(`git status --short -- .../db/migration` 无输出) |
`AGENTS.md` 核查:仓库根目录、`mydreamjob-server/` 及其子目录**均未发现** `AGENTS.md`。
## 2. 现有 Mock 测试的真实职责
| 测试类 | 测试数 | 真实职责 | 不具备的能力 |
|---|---|---|---|
| `JobSearchServiceTest`(保留,未删除、未放宽断言) | 37 | 用 Mockito 把 `JobRepository.findAll(Specification, Pageable)` 与 `CompanyRepository` 整体 Mock 掉,验证参数校验(非法 sort / 非法分页 / 非法薪资范围)、分页对象、排序参数传递、企业信息组装与 DTO 编排 | **不能**证明 Criteria 谓词、SQL 过滤、排序与分页在真实数据库上的行为 |
| `JobServiceTest`(保留) | 13 | 岗位 CRUD、状态机与归属校验的编排 | 同上,与查询语义无关 |
本轮为该 Mock 测试补充了类级 Javadoc,明确标注它是**编排测试**而非 Specification SQL 覆盖(仅注释,未改动任何断言)。Q01 之前 `JobSearchSpecification` 在生产代码外几乎零引用(仅被 Mock 测试当作 `Specification` 类型占位);本轮起被 4 个测试类**直接调用**其 `searchJobs(...)`:`JobSearchSpecificationIntegrationTest`、`JobSearchSpecificationMySqlIntegrationTest`、`JobSearchQueryExplainMySqlIntegrationTest`,以及副作用用例基类 `JobSearchQuerySideEffectTestBase`(H2 与 MySQL 两个子类共用)。
## 3. 测试分层与结果
| 层次 | 测试类 | 数据库 | Tests run | Failures | Errors | Skipped |
|---|---|---|---|---|---|---|
| H2 真实 Repository | `JobSearchSpecificationIntegrationTest` | H2(真实 Flyway,`integration-test`) | 21 | 0 | 0 | 0 |
| H2 真实 Service/EntityManager | `JobSearchServiceIntegrationTest` | H2(真实 Flyway) | 9 | 0 | 0 | 0 |
| H2 查询副作用 | `JobSearchQuerySideEffectIntegrationTest` | H2(真实 Flyway) | 4 | 0 | 0 | 0 |
| **MySQL 真实层** | `JobSearchSpecificationMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 15 | 0 | 0 | 0 |
| **MySQL 查询副作用** | `JobSearchQuerySideEffectMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 4 | 0 | 0 | 0 |
| **MySQL 执行计划** | `JobSearchQueryExplainMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 1 | 0 | 0 | 0 |
| 合计(新增 Q01 测试) | | | **54** | **0** | **0** | **0** |
MySQL 核心用例**真实执行**(Docker 可用,容器实启),`Skipped = 0`,未出现环境性跳过。
执行命令(原样):
```powershell
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server
mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest" test
```
定向复验输出:`Tests run: 15, Failures: 0, Errors: 0, Skipped: 0` / `BUILD SUCCESS`。
> 首次按负责人指令执行该命令时为 `Tests run: 9, Failures: 0, Errors: 0, Skipped: 0`。随后为达成「与 H2 逐项对账」的完整性,补齐了 MySQL 侧原先缺失的 4 个 H2 已覆盖场景(空白关键词、关键词 trim、多条件 AND、NULL 薪资契约),并在独立复核后增加 city、employmentType 大小写变体 2 个跨库契约用例;该命令现为 **15 条**,仍为 `Failures=0 / Errors=0 / Skipped=0`。
数据库配置:`mysql:8.4` 独立容器(`mydreamjob_q01`),执行**正式 Flyway 迁移**并以 `ddl-auto=validate` 启动;JDBC URL / 用户名 / 密码由 `@DynamicPropertySource` 运行期注入,不落盘、不打印。应用级测试开关与测试密钥由测试专用 profile `mysql-it` 提供(`src/test/resources/application-mysql-it.yml`),方言沿用主配置的 `MySQLDialect`——H2 与 MySQL 用例因此跑在**同一套应用配置**上,仅数据库实现不同,便于逐项对账。
## 4. 每个过滤条件的实际结果
| 条件 | 契约 | 真实库实测结论(H2 与 MySQL 一致) |
|---|---|---|
| `status` | 无论传入什么,只返回 `PUBLISHED` | DRAFT、CLOSED 在任何组合、任何排序下都不出现;显式按 `companyId` 过滤时同样不出现 |
| `keyword` | trim 后忽略大小写,匹配 title / description / requirements 任一字段 | 三字段分别命中;`java` / `JaVa` / ` Java ` 结果相同 |
| `city` | trim 后忽略大小写精确匹配 | `Q01-Beijing`、`q01-beijing` 和带空格的小写查询命中同一岗位;与 `city + "别处"` 区分开 |
| `employmentType` | trim 后忽略大小写精确匹配 | `FULL_TIME`、`full_time` 和带空格的小写查询命中同一岗位;`PART_TIME`、`FULL` 不命中 |
| `companyId` | 精确匹配 | 只返回本企业岗位 |
| `salaryMin` | 岗位 `salaryMax >= salaryMin`(单边) | 边界相等命中;`salaryMax` 更高者也命中;`salaryMax` 低于边界者排除 |
| `salaryMax` | 岗位 `salaryMin <= salaryMax`(单边) | 边界相等命中;`salaryMin` 更低者也命中;`salaryMin` 高于边界者排除 |
| 两端同时给出 | 区间重叠 | 部分重叠命中、被用户区间包含命中;高于或低于用户区间均排除 |
| 多条件 | AND | 关键词/城市/用工类型/企业/薪资必须同时满足 |
| 无结果 | 空页 | `content` 为空、`totalElements = 0` |
## 5. keyword 三字段、大小写与特殊字符
- **title / description / requirements 三字段**:三个只在一个字段含关键词的岗位都能被单独命中,未含关键词的岗位不命中。
- **大小写**:`lower(title|description|requirements) LIKE lower(pattern)`,`java`、`JAVA`、`JaVa` 命中同一组岗位(MySQL 侧还叠加列排序规则,结论一致)。
- **trim**:`" Java "` 与 `"Java"` 结果一致。
- **空值**:`null`、`""`、`" "`、`" "`、`"\t"` 均**不参与过滤**(`isBlank()` 判定),返回该城市下全部岗位。
- **LIKE 特殊字符**(锁定既有契约,**未做转义改动**):
- `%` 作为通配符,命中该城市下全部带标题的岗位;
- `_` 作为单字符通配符,同样命中全部;
- `A_B` 以通配符解释,命中 `A_B岗` 本身。
- H2 与 MySQL 行为**一致**。这是既有语义,产品未决策前不改为字面量搜索。
## 6. 薪资边界与区间重叠
H2 与 MySQL 逐项一致:
| 用例 | 用户条件 | 期望与实测 |
|---|---|---|
| `salaryMinMatchesWhenJobMaximumEqualsBoundary` | `salaryMin=20000` | 仅 `[10000,19999]` 被排除,`[10000,20000]` 命中(边界相等命中) |
| `salaryMaxMatchesWhenJobMinimumEqualsBoundary` | `salaryMax=10000` | 仅 `[10001,20000]` 被排除,`[10000,20000]` 命中 |
| 部分重叠 | `[15000,25000]` | `[10000,20000]`、`[12000,30000]` 命中 |
| 不重叠 | `[30000,35000]`、`[1000,4000]` | 空 |
| NULL 薪资 | 不加薪资条件 | `[null,null]`、`[10000,null]` 照常返回 |
NULL 薪资的既有契约(SQL 三值逻辑):一旦给出 `salaryMin`,`job.salaryMax >= ?` 对 `salaryMax IS NULL` 求值为 UNKNOWN,因此 `salaryMax` 为 NULL 的岗位被排除。Q01 **锁定**该行为,不做修改。
## 7. PUBLISHED 强制过滤
无论是否传其它条件、无论 `sort` 取 `latest` / `relevance` / `salary` / `null`,DRAFT 与 CLOSED 岗位**绝不出现**;即使显式传入 DRAFT 岗位所属的 `companyId` 也不出现。H2 与 MySQL 一致。
## 8. latest / relevance / salary 排序
| 排序 | 语义 | 实测 |
|---|---|---|
| `latest` / `null` | `createdAt DESC, id DESC` | 按 `created_at` 倒序;`created_at` 相同时按 `id DESC`;`null` 与 `latest` 结果完全一致 |
| `relevance` | 当前与 `latest` 等价(本轮锁定,不改造成新算法) | 与 `latest` 结果完全一致 |
| `salary` | `salaryMax` 非空优先降序;相同值按 `id DESC`;NULL 最后 | `CASE WHEN salary_max IS NULL THEN 1 ELSE 0 END ASC, salary_max DESC, id DESC` 生效,NULL 排在最后;相同 `salaryMax` 按 `id DESC` |
`salary` 排序走 `JobService.findWithSalarySort` 的 CriteriaQuery + CASE WHEN 路径(真实 `EntityManager`),在 H2 与真实 MySQL 上均通过。
## 9. 分页与 total
- 5 条数据、`size=2` 时:`totalElements = 5`、`totalPages = 3`;第 0 页与第 1 页 ID 顺序与期望逐项一致;三页拼接等于完整期望顺序,**无重复、无遗漏**。
- 无结果时 `totalElements = 0`。
- H2 与 MySQL 一致。
## 10. H2 与 MySQL 差异
初次复验发现 MySQL `utf8mb4_unicode_ci` 与 H2 默认字符串比较规则可能造成 city、employmentType 大小写差异。现已在 Specification 中显式对字段和值使用 `lower`,并在两库增加同名大小写变体测试。两个库在 keyword、city、employmentType、特殊字符、薪资边界、PUBLISHED 过滤、排序、分页和 total 上结果现已逐项一致。
过程中在 **MySQL 用例内部**发现并修正了 3 处**用例自身的期望错误**(不是生产缺陷,见第 13 节):
1. 只给 `city` 条件时,用例原本期望同城但 `PART_TIME` 的岗位不出现——契约只规定城市精确匹配,返回它是正确的;
2. `salaryMin=20000` 时用例原本期望只返回边界岗位,但契约是**单边**比较,`salaryMax=60000 >= 20000` 的岗位同样应命中;
3. `salaryMax=10000` 时同理,`salaryMin=5000 <= 10000` 的岗位同样应命中。
修正方式是把期望改为与设计文档第 4 节契约表一致,并**加强**而非放宽断言(补齐两条「区间完全在用户区间之外」的反向断言、补 `doesNotContain` 前缀城市断言)。H2 用例的同名场景夹具本就设计正确(相邻岗位取「刚好越界」值),因此未受影响。
## 11. EXPLAIN 摘要
真实 MySQL 8.4 上以 `EXPLAIN FORMAT=JSON` 采集,等价 SQL 与 Criteria 路径**绑定同一组参数**,并先断言二者返回**完全相同的 ID 与顺序**(保证 EXPLAIN 的不是一份不存在的语句)。完整原文见 [Q01 MySQL 执行计划证据](q01-job-search-mysql-explain-evidence.md)。
| 语句 | query_cost | access_type | 可用索引 | 实际选用 key | rows_examined_per_scan | using_filesort | using_temporary_table |
|---|---|---|---|---|---|---|---|
| 组合过滤 + latest 分页 | 1.35 | `ALL` | `idx_jobs_company_status`、`idx_jobs_status_created`、`idx_jobs_city_status`、`idx_jobs_status_employment_type`、`idx_jobs_status_salary_max` | 未选用 | 1 | true | false |
| latest 分页 | 1.35 | `ALL` | 同上(除 `idx_jobs_company_status`) | 未选用 | 1 | true | false |
| salary 排序(CASE WHEN) | 1.35 | `ALL` | 同上 | 未选用 | 1 | true | false |
| count 查询 | 0.35 | `ALL` | 同上 | 未选用 | 1 | false | false |
结果规模与 SQL 语句条数(page size = 20):
| 匹配结果数 | 返回行数 | totalElements | Repository SQL数 | 完整Service SQL数 |
|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 2 |
| 20 | 20 | 20 | 2 | 3 |
| 50 | 20 | 50 | 2 | 3 |
Spring Data 对首页有优化:首页返回行数小于 `pageSize` 时不再发 count 查询,因此 Repository 路径为 1/2/2 条。完整 Service 路径固定增加一次企业批量查询,为 2/3/3 条;两者均不随结果数量线性增长,不存在逐岗位查询企业的 N+1。
**结论与边界**:Q01 夹具刻意做小(每个城市 1 / 20 / 50 条),InnoDB 统计信息在此规模下估算行数为 1、优化器选择全表扫描并做 filesort,**这不构成生产规模的性能结论**。按 Q01 边界不新增、不调整索引;`%keyword%` 前导通配符本身无法走 B-Tree 索引、以及大规模数据下的 `ORDER BY` filesort 成本,记录给 **R01**。
## 12. 数据副作用结果
用例在 jobs、companies、users、applications、candidate_dimension_scores、notifications、conversations、messages、audit_logs **九张表**上做**行级稳定字段快照**(按主键排序、逐行取显式列出的稳定业务字段,不使用 `SELECT *`、不含 `password_hash` 等凭据列),执行覆盖「全部过滤条件 × 4 种排序 × 分页 × 无结果 × 聚合筛选项」的完整搜索矩阵后再次取快照,逐表逐行逐字段比较:
- **H2**:前后完全一致;
- **真实 MySQL**:前后完全一致。
仅比较 COUNT / SUM / CRC32 无法证明「没有某一行被改写」,故另设**反证测试**(H2 与 MySQL 各跑一遍):
| 反证用例 | 人为动作 | 要求 |
|---|---|---|
| `snapshotReportsRowFieldUpdate` | 直接改写 `jobs.status` | 必须被发现;把字段与 `updated_at` 一并还原后必须重新完全一致(证明差异定位精确而非「永远不等」) |
| `snapshotReportsInsertedRow` | 插入一行 notification | 必须被发现 |
| `snapshotReportsDeletedRow` | 删除一行 notification | 必须被发现 |
反证全部通过,说明主证据的「无副作用」结论不是因为比较失效。测试同时断言九张受检表在测试库中**确有行**,避免空快照导致的假通过。
## 13. 缺陷与判定
定向复验发现测试环境与线上 MySQL 的大小写比较可能不一致。为锁定线上已有的忽略大小写结果并保证跨库一致,生产代码仅修改 `JobSearchSpecification`,对 city 和 employmentType 显式执行 Locale.ROOT 小写归一化;接口、DTO、权限、状态机和 MySQL 既有返回语义均未改变。`JobService`、`JobRepository`、`JobController` 与 `ErrorCode` 保持原样。
过程中处理的 2 类**非生产缺陷**问题,均已记录:
1. **MySQL 用例无法启动上下文**:该类最初未激活任何 profile,导致 `app.jwt.*` 测试密钥缺失、`AppJwtProperties` 校验失败(`JWT_ACCESS_SECRET must be at least 32 characters`)。这是**用例配置缺失**,不是生产缺陷(生产要求 JWT 密钥必须显式配置,行为正确)。修复方式是新增测试专用 profile `mysql-it`,并保持连接参数由 `@DynamicPropertySource` 注入。
2. **MySQL 用例期望与契约表自相矛盾**:见第 10 节的 3 处,已按契约修正并加强断言。
**无待整改生产缺陷。** 上述跨库一致性问题已定向解决并有 H2/MySQL 同名用例证明。
## 14. 关联与全量测试
| 项目 | 命令 | 结果 |
|---|---|---|
| 岗位关联回归 | `mvn -Dtest="*JobSearch*Test,*Job*Test,*CompanyPublic*Test" test` | **Tests run: 225, Failures: 0, Errors: 0, Skipped: 0** |
| 全量测试 | `mvn test` | **Tests run: 1890, Failures: 0, Errors: 0, Skipped: 0** |
| 干净构建 | `mvn clean verify` | **Tests run: 1890, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS`,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar` |
关联回归中与 Q01 直接相关的类:`JobSearchSpecificationIntegrationTest` 21、`JobSearchServiceIntegrationTest` 9、`JobSearchSpecificationMySqlIntegrationTest` 15、`JobSearchQuerySideEffectIntegrationTest` 4、`JobSearchQuerySideEffectMySqlIntegrationTest` 4、`JobSearchQueryExplainMySqlIntegrationTest` 1、`JobSearchServiceTest` 37(保留)、`JobServiceTest` 13、`JobFavoriteServiceTest` 10、`CompanyPublicServiceTest` 22。
## 15. Failures / Errors / Skipped 与 clean verify
| 项目 | Tests run | Failures | Errors | Skipped | 构建 |
|---|---|---|---|---|---|
| `mvn test` | **1890** | **0** | **0** | **0** | `BUILD SUCCESS` |
| `mvn clean verify` | **1890** | **0** | **0** | **0** | `BUILD SUCCESS`(含打包,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar`) |
所有必跑测试 `Failures = 0`、`Errors = 0`、`Skipped = 0`。日志中不存在任何 `Skipped: <非零>` 的测试类。三个 MySQL Testcontainers 用例类在 `mvn clean verify` 中**真实启动容器并执行**(`JobSearchSpecificationMySqlIntegrationTest` 15 条 / `JobSearchQuerySideEffectMySqlIntegrationTest` 4 条 / `JobSearchQueryExplainMySqlIntegrationTest` 1 条),**未发生跳过**。
## 16. OpenAPI
```
python docs/verify_openapi.py
paths: 176
operations: 214
All checks passed!
```
`docs/openapi-rc.json` **未被修改**(`git status --short -- docs/openapi-rc.json` 无输出)。Q01 只新增测试,未新增、未删除、未重命名任何 path 或 operation,未改变参数名、默认值、响应 DTO 或错误码。
## 17. 精确修改文件
**新增(Q01 测试与证据)**
```text
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectTestBase.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectMySqlIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/job/BusinessRowSnapshot.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQueryExplainMySqlIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/job/Q01SqlRecorder.java
mydreamjob-server/src/test/resources/application-mysql-it.yml
docs/q01-job-search-real-query-design.md
docs/q01-job-search-real-query-implementation-instruction.md
docs/q01-job-search-real-query-implementation-report.md
docs/q01-job-search-mysql-explain-evidence.md
```
**修改(1 个定向生产修复、测试注释与文档)**
```text
mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java # city/employmentType 跨库大小写语义显式统一
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java # 仅补类级 Javadoc,标注为编排测试;断言未改动
docs/backend-development-roadmap.md
docs/phase1-scope-and-acceptance.md
```
## 18. 是否修改生产代码 / 新增迁移或接口
- **生产代码:定向修改 1 个文件。** `JobSearchSpecification` 对 city 和 employmentType 显式使用 `lower` + `Locale.ROOT`,保持线上 MySQL 既有忽略大小写结果并消除 H2 差异;`JobService`、`JobRepository`、`JobController`、DTO、状态机与权限未改。
- **Flyway:未新增。** 最新迁移仍为 V43;V1–V43 全部未修改。
- **ErrorCode:未新增。**
- **公网接口:未新增、未删除、未重命名。** OpenAPI 保持 176 paths / 214 operations。
- 未接入 Elasticsearch、缓存或搜索引擎;未进入 R01。
## 19. Git 状态
```
git branch --show-current -> test/q01-job-search-specification
git diff --cached --name-only -> (空)
git status --short -- mydreamjob-server/src/main -> M JobSearchSpecification.java(Q01 定向修复)
git status --short -- mydreamjob-server/src/main/resources/db/migration -> (空)
git status --short -- docs/openapi-rc.json -> (空)
```
检查点执行前,本分支未暂存、未提交、未推送;未执行 `git add -A`、reset、restore、checkout、clean;未删除或移动任何历史未跟踪文件。Q01 改动由 1 个定向生产修复、真实数据库测试与正式证据文档组成。
## 20. 待整改事项
**Q01 范围内无待整改缺陷。**
移交给 **R01**(不在 Q01 修复):
- `keyword` 使用 `%keyword%` 前导通配符,B-Tree 索引无法用于该谓词;
- 小样本夹具下优化器选择全表扫描 + filesort,真实数据规模下的执行计划与排序成本需在 R01 以真实量级数据评估;
- `JobService.findWithSalarySort` 的 `CASE WHEN` 排序无法利用 `idx_jobs_status_salary_max` 的有序性,需要 R01 评估替代方案。

18
mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java

@ -5,6 +5,7 @@ import org.springframework.data.jpa.domain.Specification;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
/** /**
* 岗位搜索动态查询 Specification。 * 岗位搜索动态查询 Specification。
@ -51,21 +52,24 @@ public class JobSearchSpecification {
if (keyword != null && !keyword.isBlank()) { if (keyword != null && !keyword.isBlank()) {
String pattern = "%" + keyword.trim() + "%"; String pattern = "%" + keyword.trim() + "%";
Predicate keywordPred = cb.or( Predicate keywordPred = cb.or(
cb.like(cb.lower(root.get(TITLE)), pattern.toLowerCase()), cb.like(cb.lower(root.get(TITLE)), pattern.toLowerCase(Locale.ROOT)),
cb.like(cb.lower(root.get(DESCRIPTION)), pattern.toLowerCase()), cb.like(cb.lower(root.get(DESCRIPTION)), pattern.toLowerCase(Locale.ROOT)),
cb.like(cb.lower(root.get(REQUIREMENTS)), pattern.toLowerCase()) cb.like(cb.lower(root.get(REQUIREMENTS)), pattern.toLowerCase(Locale.ROOT))
); );
predicates.add(keywordPred); predicates.add(keywordPred);
} }
// 城市精确匹配 // 城市:trim 后忽略大小写精确匹配。
// 显式 lower 可避免 MySQL 的大小写不敏感排序规则与 H2 默认比较规则产生差异。
if (city != null && !city.isBlank()) { if (city != null && !city.isBlank()) {
predicates.add(cb.equal(root.get(CITY), city.trim())); predicates.add(cb.equal(cb.lower(root.get(CITY)), city.trim().toLowerCase(Locale.ROOT)));
} }
// 用工类型精确匹配 // 用工类型:trim 后忽略大小写精确匹配,与线上 MySQL 既有比较结果保持一致。
if (employmentType != null && !employmentType.isBlank()) { if (employmentType != null && !employmentType.isBlank()) {
predicates.add(cb.equal(root.get(EMPLOYMENT_TYPE), employmentType.trim())); predicates.add(cb.equal(
cb.lower(root.get(EMPLOYMENT_TYPE)),
employmentType.trim().toLowerCase(Locale.ROOT)));
} }
// 薪资范围:岗位 salaryMax >= 用户 salaryMin // 薪资范围:岗位 salaryMax >= 用户 salaryMin

126
mydreamjob-server/src/test/java/com/mydreamjob/job/BusinessRowSnapshot.java

@ -0,0 +1,126 @@
package com.mydreamjob.job;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Clob;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Q01 §7 查询副作用证据:业务表的**行级稳定字段快照**。
*
* <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 "";
}
}

364
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQueryExplainMySqlIntegrationTest.java

@ -0,0 +1,364 @@
package com.mydreamjob.job;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.annotation.DirtiesContext;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Q01 §8:**真实 MySQL 8** 上的执行计划与查询次数证据。
*
* <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")));
}
}

20
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectIntegrationTest.java

@ -0,0 +1,20 @@
package com.mydreamjob.job;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
/**
* Q01 §7 查询副作用行级快照 —— H2 层。
*
* <p>与 {@link JobSearchQuerySideEffectMySqlIntegrationTest} 执行完全相同的用例,
* 用于 H2 / 真实 MySQL 逐项对账。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class JobSearchQuerySideEffectIntegrationTest extends JobSearchQuerySideEffectTestBase {
@MockBean
private RedisTemplate<String, Object> redisTemplate;
}

44
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectMySqlIntegrationTest.java

@ -0,0 +1,44 @@
package com.mydreamjob.job;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.annotation.DirtiesContext;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* Q01 §7 查询副作用行级快照 —— **真实 MySQL 8** 层。
*
* <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;
}

299
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectTestBase.java

@ -0,0 +1,299 @@
package com.mydreamjob.job;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import java.math.BigDecimal;
import java.sql.PreparedStatement;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Q01 §7 查询副作用的**行级**证据基类。
*
* <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();
}
}

270
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java

@ -0,0 +1,270 @@
package com.mydreamjob.job;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.dto.JobSearchItem;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.Page;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Q01:{@link JobService#searchJobs} 的**真实查询**覆盖(真实 JobService / JobRepository /
* CompanyRepository / EntityManager,不 Mock {@code findAll})。
*
* <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);
}
}
}

13
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java

@ -28,6 +28,19 @@ import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/**
* {@link JobService#searchJobs} 的**编排与参数校验**测试(纯 Mockito)。
*
* <p>本类把 {@code JobRepository.findAll(Specification, Pageable)} 与
* {@code CompanyRepository} 整体 Mock 掉,因此它只能证明参数校验、分页对象、
* 排序参数传递和 DTO 组装的编排逻辑;**不能证明 Criteria 谓词、SQL 过滤、
* 排序与分页在真实数据库上的行为**。</p>
*
* <p>真实查询覆盖见 Q01 补充的
* {@link JobSearchSpecificationIntegrationTest}(H2 真实 Repository)、
* {@link JobSearchServiceIntegrationTest}(H2 真实 Service/EntityManager)、
* {@link JobSearchSpecificationMySqlIntegrationTest}(真实 MySQL 8)。</p>
*/
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class JobSearchServiceTest { class JobSearchServiceTest {

425
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java

@ -0,0 +1,425 @@
package com.mydreamjob.job;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Q01:{@link JobSearchSpecification} 的**真实数据库**覆盖(H2 + 真实 Flyway + 真实 JobRepository)。
*
* <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();
}
}

384
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java

@ -0,0 +1,384 @@
package com.mydreamjob.job;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.annotation.DirtiesContext;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Q01:{@link JobSearchSpecification} 在**真实 MySQL 8** 上的行为对账。
*
* <p>使用 Testcontainers 启动独立 MySQL(不复用开发库),执行正式 Flyway 迁移并以
* {@code ddl-auto=validate} 启动,重点对账 H2 难以覆盖的差异:大小写比较规则、
* LIKE 特殊字符、NULL 排序、边界比较与分页稳定性。</p>
*
* <p>不打印任何数据库凭据。Docker 不可用时环境性跳过({@code disabledWithoutDocker}),
* 但独立验收必须显式运行本类并得到 Tests run &gt; 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();
}
}

34
mydreamjob-server/src/test/java/com/mydreamjob/job/Q01SqlRecorder.java

@ -0,0 +1,34 @@
package com.mydreamjob.job;
import org.hibernate.resource.jdbc.spi.StatementInspector;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Q01 EXPLAIN 证据用:记录 Hibernate 实际执行过的 SQL。
*
* <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;
}
}

55
mydreamjob-server/src/test/resources/application-mysql-it.yml

@ -0,0 +1,55 @@
# Q01 MySQL 真实层(Testcontainers)测试配置。
#
# 只提供应用级测试开关与测试专用 JWT 密钥;**不覆盖数据源与 JPA 方言**:
# - 连接参数(URL / 用户名 / 密码)由测试类的 @DynamicPropertySource 注入容器地址;
# - 方言与 ddl-auto 沿用主配置(org.hibernate.dialect.MySQLDialect + validate)。
# 这样 MySQL 用例与 H2 用例跑在**同一套应用配置**上,仅有数据库实现不同,便于逐项对账。
#
# 密钥仅用于本地测试,长度 ≥ 32 且三项两两不同,满足 AppJwtProperties 校验。
spring:
config:
activate:
on-profile: mysql-it
main:
allow-bean-definition-overriding: true
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
data:
redis:
enabled: false
rabbitmq:
enabled: false
app:
redis:
enabled: false
notification:
mq-enabled: false
offer:
scheduler:
enabled: false
match:
notification:
enabled: false
risk:
scan:
enabled: false
resume:
storage-dir: ./target/resumes
export-dir: ./target/resume-exports
data-export:
dir: ./target/data-exports
file-ttl-hours: 24
jwt:
secret: mysql-it-legacy-secret-for-q01-tests-only-0001
access-secret: mysql-it-access-secret-for-q01-tests-only-0001
refresh-secret: mysql-it-refresh-secret-for-q01-tests-only-0001
token-hash-secret: mysql-it-token-hash-secret-for-q01-tests-only-0001
expiration-ms: 3600000
refresh-expiration-ms: 86400000
sms:
mock:
enabled: true
expire-seconds: 300
Loading…
Cancel
Save