diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md
index ca6a494..6db8591 100644
--- a/docs/backend-development-roadmap.md
+++ b/docs/backend-development-roadmap.md
@@ -14,6 +14,7 @@
| S01 认证安全整改 | 独立代码复验与真实 MySQL/Docker 黑盒全部通过(`b5084fd`) | 保持验证码受控取码、双开关与 `/jobs` 认证边界不变 |
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 |
+| Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成真实查询测试实现与自动化验证,待独立代码复验及独立真实 MySQL/Docker 验收。新增 50 条真实库测试:H2 真实 Repository 19、H2 真实 Service 9、H2 查询副作用 4、真实 MySQL 8(Testcontainers)规格对账 13、MySQL 查询副作用 4、MySQL EXPLAIN 1;全部 `Failures=0 / Errors=0 / Skipped=0` | 不改接口、DTO、状态机、权限与搜索语义;不新增 Flyway / ErrorCode / 公网接口;OpenAPI 保持 176/214;EXPLAIN 观察项(前导通配符、filesort)移交 R01 |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |
diff --git a/docs/phase1-scope-and-acceptance.md b/docs/phase1-scope-and-acceptance.md
index c7ffe09..79d21ec 100644
--- a/docs/phase1-scope-and-acceptance.md
+++ b/docs/phase1-scope-and-acceptance.md
@@ -33,6 +33,29 @@
| S02 Token 隔离与会话吊销 | Access/Refresh 类型与密钥隔离、轮换、重放处置、会话吊销 | auth/(JwtTokenProvider、AuthSessionService、AuthTokenHasher、JwtAuthenticationFilter、AuthController)、V42__auth_token_sessions.sql | Refresh 不能访问普通接口;Access 不能刷新;二次使用撤销当前会话;并发刷新最多成功一次;登出/注销完成使会话失效 | 张顺 / 独立复验 |
| S03 生产配置与简历隐私 | 历史种子账号治理、一次性管理员初始化、企业简历下载授权、Actuator 隔离 | auth/(AdminBootstrapRunner)、application/(CompanyResumeAccessPolicy、CompanyResumeService)、V43__disable_legacy_seed_accounts.sql、application.yml | V43 只禁用完全匹配原始指纹的三条种子账号;管理员初始化默认关闭且幂等;三种可见性 × 全部投递状态 × 跨企业/未认证企业矩阵;拒绝路径零文件读取;业务端口不提供指标 | 张顺 / 独立复验 |
+### 2.1 Q01 岗位搜索真实查询覆盖(C02 证据补充)
+
+`JobSearchSpecification` 与 `JobService.searchJobs` 原先只被纯 Mockito 测试覆盖(`JobSearchServiceTest` 把 `findAll(Specification, Pageable)` 整体 Mock 掉),无法证明 Criteria 谓词、SQL 过滤、排序与分页在真实数据库上的行为。Q01 补齐真实查询证据,**只新增测试与文档,未改动生产代码、接口、DTO、状态机、权限、搜索语义与索引**。
+
+测试分层与结果(详见 [Q01 实施报告](q01-job-search-real-query-implementation-report.md)):
+
+| 层次 | 测试类 | 数据库 | Tests run | Failures | Errors | Skipped |
+|---|---|---|---|---|---|---|
+| H2 真实 Repository | `job/JobSearchSpecificationIntegrationTest` | H2 + 真实 Flyway | 19 | 0 | 0 | 0 |
+| H2 真实 Service / EntityManager | `job/JobSearchServiceIntegrationTest` | H2 + 真实 Flyway | 9 | 0 | 0 | 0 |
+| H2 查询副作用行级快照 | `job/JobSearchQuerySideEffectIntegrationTest` | H2 + 真实 Flyway | 4 | 0 | 0 | 0 |
+| 真实 MySQL 8 规格对账 | `job/JobSearchSpecificationMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 13 | 0 | 0 | 0 |
+| 真实 MySQL 查询副作用行级快照 | `job/JobSearchQuerySideEffectMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 4 | 0 | 0 | 0 |
+| 真实 MySQL 执行计划与查询次数 | `job/JobSearchQueryExplainMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 1 | 0 | 0 | 0 |
+
+- MySQL 层用正式 Flyway 迁移 + `ddl-auto=validate` 启动,连接参数由 `@DynamicPropertySource` 运行期注入,不落盘、不打印;核心用例**真实执行、无 SKIP**。
+- H2 与 MySQL 在 keyword(三字段/大小写/trim/空白与 `%`、`_` 特殊字符)、city、employmentType、companyId、薪资边界与区间重叠、PUBLISHED 强制过滤、latest/relevance/salary 排序、分页与 total 上**逐项一致,未发现语义差异**。
+- 副作用证据为 jobs、companies、users、applications、candidate_dimension_scores、notifications、conversations、messages、audit_logs 九张表的**行级稳定字段快照**(非 COUNT/SUM),并配「改字段 / 插行 / 删行」三条**反证测试**证明比较本身有效。
+- 真实 MySQL 上对组合过滤、latest 分页、salary 排序与 count 执行 `EXPLAIN FORMAT=JSON`;小样本夹具下为全表扫描 + filesort,**不构成生产规模结论**,前导通配符与排序成本移交 R01。
+- OpenAPI 保持 176 paths / 214 operations,`docs/openapi-rc.json` 未改动。
+
+状态:**Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及独立真实 MySQL/Docker 验收。**
+
## 3. 一期必须补齐的八个功能组
以下交付边界用于控制设计范围;涉及产品参数的内容明确列为待评审,不得由实现者自行锁定。
diff --git a/docs/q01-job-search-mysql-explain-evidence.md b/docs/q01-job-search-mysql-explain-evidence.md
new file mode 100644
index 0000000..a2fe884
--- /dev/null
+++ b/docs/q01-job-search-mysql-explain-evidence.md
@@ -0,0 +1,303 @@
+# Q01 MySQL 执行计划证据(由 JobSearchQueryExplainMySqlIntegrationTest 生成)
+
+- 数据库:`mysql:8.4` Testcontainers 独立容器,正式 Flyway 迁移,`ddl-auto=validate`
+- EXPLAIN 形式:`EXPLAIN FORMAT=JSON`,与 Criteria 路径绑定同一组参数
+- 夹具规模:每个城市分别 1 / 20 / 50 条 PUBLISHED 岗位(刻意做小,用于验证语义而非压测)
+- Q01 不新增索引;执行计划问题记录给 R01
+
+## 1. 组合过滤 + latest 分页
+
+Hibernate 实际生成并执行的 SQL(占位符形式):
+
+```sql
+select j1_0.id,j1_0.city,j1_0.company_id,j1_0.created_at,j1_0.description,j1_0.employment_type,j1_0.published_at,j1_0.requirements,j1_0.salary_max,j1_0.salary_min,j1_0.status,j1_0.title,j1_0.updated_at from jobs j1_0 where j1_0.status=? and (lower(j1_0.title) like ? escape '' or lower(j1_0.description) like ? escape '' or lower(j1_0.requirements) like ? escape '') and lower(j1_0.city)=? and lower(j1_0.employment_type)=? and j1_0.salary_max>=? and j1_0.salary_min<=? and j1_0.company_id=? order by j1_0.created_at desc,j1_0.id desc limit ?,?
+
+select count(j1_0.id) from jobs j1_0 where j1_0.status=? and (lower(j1_0.title) like ? escape '' or lower(j1_0.description) like ? escape '' or lower(j1_0.requirements) like ? escape '') and lower(j1_0.city)=? and lower(j1_0.employment_type)=? and j1_0.salary_max>=? and j1_0.salary_min<=? and j1_0.company_id=?
+```
+
+等价 SQL(绑定参数 [PUBLISHED, %q01e75001岗位%, %q01e75001岗位%, %q01e75001岗位%, Q01E75001CITY20, FULL_TIME, 5000, 30000, 1, 20, 0]):
+
+```sql
+SELECT j.id FROM jobs j
+WHERE j.status = ? AND (lower(j.title) LIKE ? OR lower(j.description) LIKE ? OR lower(j.requirements) LIKE ?)
+ AND lower(j.city) = ? AND lower(j.employment_type) = ? AND j.salary_max >= ? AND j.salary_min <= ? AND j.company_id = ?
+ORDER BY j.created_at DESC, j.id DESC
+LIMIT ? OFFSET ?
+```
+
+计划摘要:
+
+```
+query_cost = 1.35
+using_filesort = true
+using_temporary_table = false
+- 表 query_block/ORDER BY: table_name="j", access_type="ALL", possible_keys=["idx_jobs_company_status","idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00"
+```
+
+EXPLAIN FORMAT=JSON 原文
+
+```json
+{
+ "query_block": {
+ "select_id": 1,
+ "cost_info": {
+ "query_cost": "1.35"
+ },
+ "ordering_operation": {
+ "using_filesort": true,
+ "cost_info": {
+ "sort_cost": "1.00"
+ },
+ "table": {
+ "table_name": "j",
+ "access_type": "ALL",
+ "possible_keys": [
+ "idx_jobs_company_status",
+ "idx_jobs_status_created",
+ "idx_jobs_city_status",
+ "idx_jobs_status_employment_type",
+ "idx_jobs_status_salary_max"
+ ],
+ "rows_examined_per_scan": 1,
+ "rows_produced_per_join": 1,
+ "filtered": "100.00",
+ "cost_info": {
+ "read_cost": "0.25",
+ "eval_cost": "0.10",
+ "prefix_cost": "0.35",
+ "data_read_per_join": "1K"
+ },
+ "used_columns": [
+ "id",
+ "company_id",
+ "title",
+ "description",
+ "requirements",
+ "city",
+ "employment_type",
+ "salary_min",
+ "salary_max",
+ "status",
+ "created_at"
+ ],
+ "attached_condition": "((`mydreamjob_q01`.`j`.`company_id` = 1) and (`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and ((lower(`mydreamjob_q01`.`j`.`title`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`description`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`requirements`) like '%q01e75001岗位%')) and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20') and (lower(`mydreamjob_q01`.`j`.`employment_type`) = 'full_time') and (`mydreamjob_q01`.`j`.`salary_max` >= 5000) and (`mydreamjob_q01`.`j`.`salary_min` <= 30000))"
+ }
+ }
+ }
+}
+```
+
+
+
+## 2. latest 分页
+
+等价 SQL(绑定参数 [PUBLISHED, Q01E75001CITY20, 20, 0]):
+
+```sql
+SELECT j.id FROM jobs j
+WHERE j.status = ? AND lower(j.city) = ?
+ORDER BY j.created_at DESC, j.id DESC
+LIMIT ? OFFSET ?
+```
+
+计划摘要:
+
+```
+query_cost = 1.35
+using_filesort = true
+using_temporary_table = false
+- 表 query_block/ORDER BY: table_name="j", access_type="ALL", possible_keys=["idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00"
+```
+
+EXPLAIN FORMAT=JSON 原文
+
+```json
+{
+ "query_block": {
+ "select_id": 1,
+ "cost_info": {
+ "query_cost": "1.35"
+ },
+ "ordering_operation": {
+ "using_filesort": true,
+ "cost_info": {
+ "sort_cost": "1.00"
+ },
+ "table": {
+ "table_name": "j",
+ "access_type": "ALL",
+ "possible_keys": [
+ "idx_jobs_status_created",
+ "idx_jobs_city_status",
+ "idx_jobs_status_employment_type",
+ "idx_jobs_status_salary_max"
+ ],
+ "rows_examined_per_scan": 1,
+ "rows_produced_per_join": 1,
+ "filtered": "100.00",
+ "cost_info": {
+ "read_cost": "0.25",
+ "eval_cost": "0.10",
+ "prefix_cost": "0.35",
+ "data_read_per_join": "1K"
+ },
+ "used_columns": [
+ "id",
+ "city",
+ "status",
+ "created_at"
+ ],
+ "attached_condition": "((`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20'))"
+ }
+ }
+ }
+}
+```
+
+
+
+## 3. salary 排序(salaryMax DESC NULLS LAST)
+
+等价 SQL(绑定参数 [PUBLISHED, Q01E75001CITY20, 20, 0]):
+
+```sql
+SELECT j.id FROM jobs j
+WHERE j.status = ? AND lower(j.city) = ?
+ORDER BY CASE WHEN j.salary_max IS NULL THEN 1 ELSE 0 END ASC, j.salary_max DESC, j.id DESC
+LIMIT ? OFFSET ?
+```
+
+计划摘要:
+
+```
+query_cost = 1.35
+using_filesort = true
+using_temporary_table = false
+- 表 query_block/ORDER BY: table_name="j", access_type="ALL", possible_keys=["idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00"
+```
+
+EXPLAIN FORMAT=JSON 原文
+
+```json
+{
+ "query_block": {
+ "select_id": 1,
+ "cost_info": {
+ "query_cost": "1.35"
+ },
+ "ordering_operation": {
+ "using_filesort": true,
+ "cost_info": {
+ "sort_cost": "1.00"
+ },
+ "table": {
+ "table_name": "j",
+ "access_type": "ALL",
+ "possible_keys": [
+ "idx_jobs_status_created",
+ "idx_jobs_city_status",
+ "idx_jobs_status_employment_type",
+ "idx_jobs_status_salary_max"
+ ],
+ "rows_examined_per_scan": 1,
+ "rows_produced_per_join": 1,
+ "filtered": "100.00",
+ "cost_info": {
+ "read_cost": "0.25",
+ "eval_cost": "0.10",
+ "prefix_cost": "0.35",
+ "data_read_per_join": "1K"
+ },
+ "used_columns": [
+ "id",
+ "city",
+ "salary_max",
+ "status"
+ ],
+ "attached_condition": "((`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20'))"
+ }
+ }
+ }
+}
+```
+
+
+
+## 4. count 查询
+
+等价 SQL(绑定参数 [PUBLISHED, %q01e75001岗位%, %q01e75001岗位%, %q01e75001岗位%, Q01E75001CITY20, FULL_TIME, 5000, 30000, 1]):
+
+```sql
+SELECT count(j.id) FROM jobs j
+WHERE j.status = ? AND (lower(j.title) LIKE ? OR lower(j.description) LIKE ? OR lower(j.requirements) LIKE ?)
+ AND lower(j.city) = ? AND lower(j.employment_type) = ? AND j.salary_max >= ? AND j.salary_min <= ? AND j.company_id = ?
+```
+
+计划摘要:
+
+```
+query_cost = 0.35
+using_filesort = false
+using_temporary_table = false
+- 表 query_block: table_name="j", access_type="ALL", possible_keys=["idx_jobs_company_status","idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00"
+```
+
+EXPLAIN FORMAT=JSON 原文
+
+```json
+{
+ "query_block": {
+ "select_id": 1,
+ "cost_info": {
+ "query_cost": "0.35"
+ },
+ "table": {
+ "table_name": "j",
+ "access_type": "ALL",
+ "possible_keys": [
+ "idx_jobs_company_status",
+ "idx_jobs_status_created",
+ "idx_jobs_city_status",
+ "idx_jobs_status_employment_type",
+ "idx_jobs_status_salary_max"
+ ],
+ "rows_examined_per_scan": 1,
+ "rows_produced_per_join": 1,
+ "filtered": "100.00",
+ "cost_info": {
+ "read_cost": "0.25",
+ "eval_cost": "0.10",
+ "prefix_cost": "0.35",
+ "data_read_per_join": "1K"
+ },
+ "used_columns": [
+ "id",
+ "company_id",
+ "title",
+ "description",
+ "requirements",
+ "city",
+ "employment_type",
+ "salary_min",
+ "salary_max",
+ "status"
+ ],
+ "attached_condition": "((`mydreamjob_q01`.`j`.`company_id` = 1) and (`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and ((lower(`mydreamjob_q01`.`j`.`title`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`description`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`requirements`) like '%q01e75001岗位%')) and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20') and (lower(`mydreamjob_q01`.`j`.`employment_type`) = 'full_time') and (`mydreamjob_q01`.`j`.`salary_max` >= 5000) and (`mydreamjob_q01`.`j`.`salary_min` <= 30000))"
+ }
+ }
+}
+```
+
+
+
+## 5. 结果规模 1 / 20 / 50 下的 SQL 语句条数
+
+Spring Data 对首页有优化:首页返回行数小于 `pageSize` 时不再发 count 查询,
+因此结果数 < pageSize 时为 1 条(仅数据查询),否则为 2 条(count + 数据查询)。
+两种情况下语句条数都**不随结果规模增长**,即不存在 N+1。
+
+| 匹配结果数 | 返回行数 | totalElements | Repository SQL数 | 完整Service SQL数 |
+|---|---|---|---|---|
+| 1 | 1 | 1 | 1 | 2 |
+| 20 | 20 | 20 | 2 | 3 |
+| 50 | 20 | 50 | 2 | 3 |
diff --git a/docs/q01-job-search-real-query-design.md b/docs/q01-job-search-real-query-design.md
new file mode 100644
index 0000000..ea56eb2
--- /dev/null
+++ b/docs/q01-job-search-real-query-design.md
@@ -0,0 +1,190 @@
+# Q01 岗位搜索真实查询覆盖设计
+
+## 1. 状态
+
+设计已确认,待实施、专项测试、真实 MySQL 复验与独立验收。
+
+## 2. 背景与问题
+
+`JobSearchSpecification` 承担公开岗位搜索的核心过滤规则,但当前 `JobSearchServiceTest` 将
+`JobRepository.findAll(Specification, Pageable)` Mock 掉后直接返回预设数据。这些测试可以证明
+参数校验、分页对象和 DTO 组装,却不能证明 Criteria 谓词、SQL 过滤、排序和分页真的正确。
+
+Q01 的目标是补齐真实数据库证据,不以测试数量替代查询行为覆盖,也不改变现有公网接口和业务语义。
+
+## 3. 范围
+
+### 3.1 必须覆盖
+
+- `JobSearchSpecification.searchJobs(...)` 的真实 Repository 查询;
+- `JobService.searchJobs(...)` 的 latest、relevance、salary 排序和分页;
+- PUBLISHED 强制过滤;
+- keyword 对 title、description、requirements 的匹配;
+- city、employmentType、companyId;
+- salaryMin、salaryMax 的区间重叠语义;
+- H2 快速测试与 MySQL 8 行为一致性;
+- 查询参数化和特殊字符边界;
+- 真实 SQL/EXPLAIN 证据及数据副作用检查。
+
+### 3.2 明确不做
+
+- 不改变 `/jobs/search` 请求或响应;
+- 不新增、删除或重命名公网接口;
+- 不修改岗位状态机、岗位归属或角色权限;
+- 不把 relevance 改造成新的全文检索算法;当前 relevance 与 latest 等价,本轮锁定该语义;
+- 不接入 Elasticsearch、搜索引擎或缓存;
+- 不新增 Flyway、表或索引;
+- 不改 Candidate Scoring、推荐、排名、邀约、Offer、申诉、消息、简历和认证;
+- 不删除现有 Mock 测试。现有测试继续承担 Service 编排与参数校验职责;
+- 不进入 R01 性能和结构重构。
+
+## 4. 当前查询契约
+
+| 参数 | 既有语义 |
+|---|---|
+| status | 无论调用方传什么条件,只返回 `PUBLISHED` |
+| keyword | trim 后忽略大小写,匹配 title、description、requirements 任一字段 |
+| city | trim 后忽略大小写精确匹配;显式归一化以保证 H2/MySQL 一致 |
+| employmentType | trim 后忽略大小写精确匹配;不做前缀匹配 |
+| salaryMin | 岗位 `salaryMax >= salaryMin` |
+| salaryMax | 岗位 `salaryMin <= salaryMax` |
+| companyId | 精确匹配 |
+| sort=latest/null | `createdAt DESC, id DESC` |
+| sort=relevance | 当前与 latest 相同,不改变 |
+| sort=salary | `salaryMax` 非空优先、降序;相同值按 `id DESC`;NULL 最后 |
+| page | 从 0 开始,不得为负数 |
+| size | 1–50 |
+
+薪资过滤是区间重叠,不是“岗位上下限都完全落在用户区间内”。边界相等必须命中。
+
+## 5. 测试分层
+
+### 5.1 H2 Repository 快速层
+
+新增 `JobSearchSpecificationIntegrationTest`:
+
+- 使用 Spring Data JPA、真实 `JobRepository` 和真实 H2;
+- 激活 `integration-test`,由真实 Flyway 建表;
+- 不 Mock Repository、EntityManager、Specification、CriteriaBuilder 或事务;
+- 每个用例创建最小隔离夹具,按精确主键清理;
+- 直接执行 `jobRepository.findAll(spec, pageable)`;
+- 断言返回 ID 集合与顺序,不能只断言数量。
+
+至少覆盖:
+
+1. title 关键词;
+2. description 关键词;
+3. requirements 关键词;
+4. 关键词忽略大小写;
+5. null、空字符串、纯空白关键词;
+6. keyword 前后空格;
+7. city 精确匹配与 trim;
+8. employmentType 精确匹配与 trim;
+9. companyId;
+10. salaryMin;
+11. salaryMax;
+12. 两端薪资边界相等;
+13. 薪资区间重叠;
+14. 薪资区间不重叠;
+15. 多条件 AND;
+16. DRAFT、CLOSED 等非公开状态排除;
+17. 无结果;
+18. 排序与分页;
+19. `%`、`_`、反斜杠、引号等特殊字符的实际既有语义;
+20. null salary 字段的行为。
+
+特殊字符测试首先锁定当前 Criteria `LIKE` 行为,不得在没有产品决策时把 `%` 和 `_` 自动改成字面量搜索。
+
+### 5.2 Service 真实查询层
+
+新增或扩展 `JobSearchServiceIntegrationTest`:
+
+- 使用真实 `JobService`、`JobRepository`、`CompanyRepository`、EntityManager;
+- 不 Mock `findAll`;
+- latest/null/relevance 的排序结果一致且稳定;
+- salary 排序验证 NULL LAST、降序、相同薪资按 ID 倒序;
+- 验证 page/size、totalElements、totalPages;
+- 验证企业信息批量组装,不改变 DTO;
+- 保留现有非法 sort、非法分页、非法薪资范围的测试。
+
+### 5.3 MySQL 真实层
+
+优先新增 `JobSearchSpecificationMySqlIntegrationTest`,使用项目已有 Testcontainers 依赖:
+
+- `@Testcontainers(disabledWithoutDocker = true)` 仅允许本地快速执行时环境性跳过;
+- 独立验收和最终冻结时 Docker/MySQL 核心用例不得 SKIP;
+- 使用 MySQL 8.x 容器、正式 Flyway 和 `ddl-auto=validate`;
+- 使用动态属性注入 JDBC URL、用户名和密码;
+- 不打印数据库凭据;
+- 至少运行一组与 H2 完全相同的参数化用例,并对返回 ID 与顺序逐项对账;
+- 重点对账大小写、LIKE 特殊字符、NULL 排序、边界比较、分页稳定性。
+
+如果仓库约定不允许 Testcontainers 进入普通 `mvn test`,可以将其放入独立 profile;但最终报告必须给出真实执行命令和非 SKIP 结果,不能只提交一个从未运行的测试类。
+
+## 6. 夹具设计
+
+使用明确区分的岗位:
+
+- 三个包含关键词的岗位,关键词分别只存在于 title、description、requirements;
+- 相同城市和不同城市;
+- FULL_TIME、PART_TIME 等不同 employmentType;
+- 至少两个企业;
+- 薪资区间覆盖边界相等、部分重叠、不重叠、NULL;
+- 相同 createdAt 的两个岗位,用 ID 验证稳定排序;
+- 相同 salaryMax 的两个岗位,用 ID 验证稳定排序;
+- PUBLISHED、DRAFT、CLOSED 等状态。
+
+夹具必须以测试生成的唯一标识隔离,不能依赖 V1_1 演示数据或真实业务数据。
+
+## 7. 数据库与副作用
+
+- 查询测试不得修改岗位状态、企业、用户或其他业务表;
+- 查询前后对相关稳定业务字段做行级快照,而不是只比较 COUNT;
+- Testcontainers 由测试生命周期销毁;
+- 使用负责人现有 MySQL 做独立黑盒时,只能精确清理本轮夹具;
+- 不允许清库、重置自增、删除 volume;
+- 不新增 Flyway 或索引。发现执行计划问题只记录到 R01,不在 Q01 修复。
+
+## 8. SQL 与 EXPLAIN
+
+真实 MySQL 验收需记录:
+
+- 组合过滤 SQL;
+- latest 分页;
+- salary 排序;
+- count 查询;
+- 使用索引、access type、estimated rows、filesort/temporary 情况;
+- 1、20、50 条结果规模下查询次数。
+
+本阶段以“语义正确且无明显发布阻断”为通过条件,不为了 EXPLAIN 修改索引。
+
+## 9. OpenAPI 与兼容性
+
+- 不修改 path 和 operation;
+- 目标仍为 176 paths / 214 operations;
+- 不改变参数名、默认值、响应 DTO 或错误码;
+- `docs/openapi-rc.json` 不应因仅新增测试而变化;
+- 必须运行 `docs/verify_openapi.py` 防止意外契约漂移。
+
+## 10. 通过条件
+
+只有同时满足以下条件才能通过 Q01:
+
+1. `JobSearchSpecification` 不再是测试零引用;
+2. H2 真实 Repository 测试覆盖全部过滤语义;
+3. Service 自定义 salary 排序有真实数据库证据;
+4. MySQL 核心用例真实运行且无 SKIP;
+5. H2 与 MySQL 返回 ID、顺序和边界语义一致,或明确记录并解决差异;
+6. 非公开岗位绝不进入公开搜索;
+7. 分页稳定、total 正确;
+8. 无业务数据副作用;
+9. 原 Mock 测试保留且准确标注为编排测试;
+10. 全量测试和 clean verify 通过;
+11. OpenAPI 保持 176/214;
+12. 无新增 Flyway、ErrorCode 或公网接口。
+
+## 11. 最终状态
+
+实施完成但尚未独立复验时,只能写:
+
+> Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及真实 MySQL/Docker 验收。
diff --git a/docs/q01-job-search-real-query-implementation-instruction.md b/docs/q01-job-search-real-query-implementation-instruction.md
new file mode 100644
index 0000000..be0403a
--- /dev/null
+++ b/docs/q01-job-search-real-query-implementation-instruction.md
@@ -0,0 +1,265 @@
+# Q01 岗位搜索真实查询覆盖实施指令
+
+## 一、任务边界
+
+项目根目录:`C:\Users\15642\Desktop\zhilai`
+
+目标分支:`test/q01-job-search-specification`
+
+本轮只补岗位搜索真实数据库测试和验收证据。原则上不修改生产代码;如果测试复现生产缺陷,先保存失败证据并停止,未经负责人确认不得顺手修复。
+
+禁止修改岗位接口、DTO、权限、状态机和搜索语义;禁止新增 Flyway、表、索引、ErrorCode 或公网接口;禁止进入 R01;禁止删除现有 Mock 测试;禁止 `git add -A`、commit、push、reset、restore、checkout、clean;禁止删除或移动历史未跟踪文件。
+
+## 二、开始前核查
+
+执行:
+
+```powershell
+cd C:\Users\15642\Desktop\zhilai
+git branch --show-current
+git status --short
+git diff --cached --name-only
+git log -5 --oneline
+git diff --check
+git status --short -- mydreamjob-server/src/main/resources/db/migration
+```
+
+确认分支正确、暂存区为空、HEAD 为 S03 检查点 `cb3bc57` 或报告实际偏差、历史未跟踪文件保留、V1–V43 均未修改。搜索并完整阅读适用 `AGENTS.md`;当前基线未发现时记录“未发现”。
+
+## 三、必须阅读
+
+```text
+docs/q01-job-search-real-query-design.md
+mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java
+mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java
+mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java
+mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java
+mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java
+mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java
+mydreamjob-server/src/test/resources/application-test.yml
+mydreamjob-server/src/test/resources/application-integration-test.yml
+mydreamjob-server/pom.xml
+mydreamjob-server/src/main/resources/db/migration/V2__business_schema.sql
+mydreamjob-server/src/main/resources/db/migration/V22__job_search_indexes.sql
+```
+
+执行:
+
+```powershell
+rg -n "JobSearchSpecification|findAll\(.*Specification|findWithSalarySort|findPublishedJobs" mydreamjob-server/src
+```
+
+记录哪些现有测试 Mock 了查询层,不能把它们描述成真实过滤覆盖。
+
+## 四、先写 H2 真实 Repository 测试
+
+新增:
+
+```text
+mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java
+```
+
+要求:
+
+- 激活 `integration-test`;
+- 使用真实 Flyway、H2、JobRepository;
+- 不 Mock Repository、Specification、EntityManager 或事务;
+- 直接调用 `jobRepository.findAll(JobSearchSpecification.searchJobs(...), pageable)`;
+- 每项断言返回 ID 和顺序;
+- 测试隔离,不依赖种子岗位;
+- 不用顺序无关的 containsOnly 代替排序断言。
+
+至少实现以下测试:
+
+```text
+keywordMatchesTitle
+keywordMatchesDescription
+keywordMatchesRequirements
+keywordIsCaseInsensitive
+nullBlankAndWhitespaceKeywordDoNotFilter
+keywordIsTrimmed
+cityUsesTrimmedExactMatch
+cityCaseVariantHasSameResultAcrossDatabases
+employmentTypeUsesTrimmedExactMatch
+employmentTypeCaseVariantHasSameResultAcrossDatabases
+companyIdFiltersExactly
+salaryMinMatchesWhenJobMaximumEqualsBoundary
+salaryMaxMatchesWhenJobMinimumEqualsBoundary
+salaryRangesUseOverlapSemantics
+nonOverlappingSalaryRangeIsExcluded
+multipleConditionsUseAndSemantics
+nonPublishedJobsAreAlwaysExcluded
+specialLikeCharactersFollowExistingContract
+nullSalaryFieldsFollowExistingContract
+paginationReturnsStableOrderedIds
+noResultReturnsEmptyPage
+```
+
+不要为了让测试符合预期而预先修改 `JobSearchSpecification`。如果任何既有语义与设计不一致,保留失败测试并报告。
+
+## 五、补 Service 真实查询测试
+
+新增:
+
+```text
+mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java
+```
+
+使用真实 JobService、JobRepository、CompanyRepository 和 EntityManager,覆盖:
+
+```text
+latestSortUsesCreatedAtThenIdDescending
+nullSortUsesLatestOrder
+relevanceCurrentlyUsesLatestOrder
+salarySortUsesMaximumDescendingAndNullLast
+salarySortUsesIdDescendingAsTieBreaker
+salarySortReturnsCorrectTotalAndTotalPages
+secondPageDoesNotRepeatOrLoseRows
+companyInformationIsMappedFromRealRepository
+searchDoesNotReturnDraftOrClosedJobs
+```
+
+原 `JobSearchServiceTest` 保留,用于参数校验和编排;如需修改注释,只能明确它不是 Specification SQL 覆盖,不得删除或放宽断言。
+
+## 六、补 MySQL 真实测试
+
+新增建议文件:
+
+```text
+mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java
+```
+
+使用已有 `org.testcontainers:mysql` 和 `junit-jupiter`:
+
+- MySQL 8.x;
+- 正式 Flyway;
+- Hibernate `ddl-auto=validate`;
+- `@DynamicPropertySource` 注入连接参数;
+- 不打印密码;
+- 不复用开发数据库;
+- 不 Mock 查询路径;
+- 容器用例至少对账 keyword 大小写、特殊字符、城市、薪资边界、非公开过滤、latest、salary NULL LAST、分页。
+
+可复用参数化场景或夹具构造器,确保 H2/MySQL 输入一致。必须逐项断言结果 ID/业务标识和顺序,而不是只断言数量。
+
+如果普通开发环境 Docker 不可用,可以让 Testcontainers 测试环境性跳过;但最终独立验收必须显式运行该类并得到 Tests run > 0、Skipped=0。不得用 H2 结果代替 MySQL。
+
+## 七、查询副作用与执行计划
+
+新增测试或独立验收证据证明查询前后以下数据不变:
+
+- jobs 稳定业务字段;
+- companies;
+- applications;
+- candidate_dimension_scores;
+- notifications;
+- messages;
+- audit_logs。
+
+使用行级稳定字段快照,不能只比较 COUNT、SUM 或 CRC32。
+
+真实 MySQL 验收对组合过滤、latest、salary 和 count SQL 执行 `EXPLAIN FORMAT=JSON`,记录使用索引、access type、estimated rows、temporary/filesort。Q01 不新增索引;发现性能问题记录给 R01。
+
+## 八、文档
+
+更新:
+
+```text
+docs/backend-development-roadmap.md
+docs/phase1-scope-and-acceptance.md
+```
+
+只记录 Q01 的测试层级和真实结果,不改变接口文档。原则上不修改 `docs/openapi-rc.json`;若仅新增测试却产生 OpenAPI 差异,视为异常并停止调查。
+
+新增最终实施报告建议:
+
+```text
+docs/q01-job-search-real-query-implementation-report.md
+```
+
+不得写“真实 MySQL 已通过”,除非 Testcontainers 或独立 MySQL 实际运行且核心项无 SKIP。
+
+## 九、验证顺序
+
+先运行 H2 测试:
+
+```powershell
+cd C:\Users\15642\Desktop\zhilai\mydreamjob-server
+mvn -Dtest="JobSearchSpecificationIntegrationTest,JobSearchServiceIntegrationTest" test
+```
+
+Docker 可用时运行 MySQL 测试:
+
+```powershell
+mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest" test
+```
+
+运行岗位关联回归:
+
+```powershell
+mvn -Dtest="*JobSearch*Test,*Job*Test,*CompanyPublic*Test" test
+```
+
+随后:
+
+```powershell
+mvn test
+mvn clean verify
+python ..\docs\verify_openapi.py
+```
+
+最后:
+
+```powershell
+cd C:\Users\15642\Desktop\zhilai
+git diff --check
+git status --short
+git diff --cached --name-only
+git status --short -- mydreamjob-server/src/main/resources/db/migration
+```
+
+要求所有必跑测试 Failures=0、Errors=0、Skipped=0,clean verify 成功,OpenAPI 保持 176/214,暂存区为空,V1–V43 未修改。
+
+## 十、发现缺陷时
+
+如果真实测试发现生产缺陷:
+
+1. 保留最小失败测试;
+2. 记录 H2 与 MySQL 是否一致;
+3. 精确写明参数、夹具、预期 ID、实际 ID/顺序和生成 SQL;
+4. 不自行修改生产代码;
+5. 状态标记为 FAIL,等待负责人下发定向整改指令。
+
+禁止通过修改夹具、删除断言、Mock Repository、禁用测试或放宽为只断言数量来制造绿色。
+
+## 十一、最终报告
+
+必须包含:
+
+1. 分支与 HEAD;
+2. 现有 Mock 测试的真实职责;
+3. H2 测试数及结果;
+4. MySQL 测试数及结果;
+5. 每个过滤条件的实际结果;
+6. keyword 三字段、大小写和特殊字符结果;
+7. 薪资边界与区间重叠结果;
+8. PUBLISHED 强制过滤结果;
+9. latest/relevance/salary 排序结果;
+10. 分页与 total 结果;
+11. H2/MySQL 差异;
+12. EXPLAIN 摘要;
+13. 数据副作用结果;
+14. 关联和全量测试数;
+15. Failures/Errors/Skipped;
+16. clean verify;
+17. OpenAPI paths/operations;
+18. 精确修改文件;
+19. 是否修改生产代码;
+20. 是否新增 Flyway、ErrorCode 或接口;
+21. Git 状态;
+22. 明确未暂存、未提交、未推送;
+23. 是否存在待整改缺陷。
+
+实现和自动化通过后只能写:
+
+> Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及真实 MySQL/Docker 验收。
diff --git a/docs/q01-job-search-real-query-implementation-report.md b/docs/q01-job-search-real-query-implementation-report.md
new file mode 100644
index 0000000..8f5157f
--- /dev/null
+++ b/docs/q01-job-search-real-query-implementation-report.md
@@ -0,0 +1,265 @@
+# Q01 岗位搜索真实查询覆盖实施报告
+
+> **状态**:Q01 已完成真实查询测试实现、跨库一致性整改及独立复验,可以进入精确暂存检查点。
+
+## 1. 分支与 HEAD
+
+| 项目 | 值 |
+|---|---|
+| 分支 | `test/q01-job-search-specification` |
+| HEAD | `cb3bc57fab549137e3785f5d50bde86ce8ef5731`(S03 检查点 `cb3bc57`,无偏差) |
+| 暂存区 | 空(`git diff --cached --name-only` 无输出) |
+| 生产代码 | 定向修改 `JobSearchSpecification`:对 city/employmentType 显式 `lower`,消除 H2/MySQL 排序规则差异,并保持线上 MySQL 既有忽略大小写结果 |
+| V1–V43 迁移 | 未修改(`git status --short -- .../db/migration` 无输出) |
+
+`AGENTS.md` 核查:仓库根目录、`mydreamjob-server/` 及其子目录**均未发现** `AGENTS.md`。
+
+## 2. 现有 Mock 测试的真实职责
+
+| 测试类 | 测试数 | 真实职责 | 不具备的能力 |
+|---|---|---|---|
+| `JobSearchServiceTest`(保留,未删除、未放宽断言) | 37 | 用 Mockito 把 `JobRepository.findAll(Specification, Pageable)` 与 `CompanyRepository` 整体 Mock 掉,验证参数校验(非法 sort / 非法分页 / 非法薪资范围)、分页对象、排序参数传递、企业信息组装与 DTO 编排 | **不能**证明 Criteria 谓词、SQL 过滤、排序与分页在真实数据库上的行为 |
+| `JobServiceTest`(保留) | 13 | 岗位 CRUD、状态机与归属校验的编排 | 同上,与查询语义无关 |
+
+本轮为该 Mock 测试补充了类级 Javadoc,明确标注它是**编排测试**而非 Specification SQL 覆盖(仅注释,未改动任何断言)。Q01 之前 `JobSearchSpecification` 在生产代码外几乎零引用(仅被 Mock 测试当作 `Specification` 类型占位);本轮起被 4 个测试类**直接调用**其 `searchJobs(...)`:`JobSearchSpecificationIntegrationTest`、`JobSearchSpecificationMySqlIntegrationTest`、`JobSearchQueryExplainMySqlIntegrationTest`,以及副作用用例基类 `JobSearchQuerySideEffectTestBase`(H2 与 MySQL 两个子类共用)。
+
+## 3. 测试分层与结果
+
+| 层次 | 测试类 | 数据库 | Tests run | Failures | Errors | Skipped |
+|---|---|---|---|---|---|---|
+| H2 真实 Repository | `JobSearchSpecificationIntegrationTest` | H2(真实 Flyway,`integration-test`) | 21 | 0 | 0 | 0 |
+| H2 真实 Service/EntityManager | `JobSearchServiceIntegrationTest` | H2(真实 Flyway) | 9 | 0 | 0 | 0 |
+| H2 查询副作用 | `JobSearchQuerySideEffectIntegrationTest` | H2(真实 Flyway) | 4 | 0 | 0 | 0 |
+| **MySQL 真实层** | `JobSearchSpecificationMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 15 | 0 | 0 | 0 |
+| **MySQL 查询副作用** | `JobSearchQuerySideEffectMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 4 | 0 | 0 | 0 |
+| **MySQL 执行计划** | `JobSearchQueryExplainMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 1 | 0 | 0 | 0 |
+| 合计(新增 Q01 测试) | | | **54** | **0** | **0** | **0** |
+
+MySQL 核心用例**真实执行**(Docker 可用,容器实启),`Skipped = 0`,未出现环境性跳过。
+
+执行命令(原样):
+
+```powershell
+cd C:\Users\15642\Desktop\zhilai\mydreamjob-server
+mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest" test
+```
+
+定向复验输出:`Tests run: 15, Failures: 0, Errors: 0, Skipped: 0` / `BUILD SUCCESS`。
+
+> 首次按负责人指令执行该命令时为 `Tests run: 9, Failures: 0, Errors: 0, Skipped: 0`。随后为达成「与 H2 逐项对账」的完整性,补齐了 MySQL 侧原先缺失的 4 个 H2 已覆盖场景(空白关键词、关键词 trim、多条件 AND、NULL 薪资契约),并在独立复核后增加 city、employmentType 大小写变体 2 个跨库契约用例;该命令现为 **15 条**,仍为 `Failures=0 / Errors=0 / Skipped=0`。
+
+数据库配置:`mysql:8.4` 独立容器(`mydreamjob_q01`),执行**正式 Flyway 迁移**并以 `ddl-auto=validate` 启动;JDBC URL / 用户名 / 密码由 `@DynamicPropertySource` 运行期注入,不落盘、不打印。应用级测试开关与测试密钥由测试专用 profile `mysql-it` 提供(`src/test/resources/application-mysql-it.yml`),方言沿用主配置的 `MySQLDialect`——H2 与 MySQL 用例因此跑在**同一套应用配置**上,仅数据库实现不同,便于逐项对账。
+
+## 4. 每个过滤条件的实际结果
+
+| 条件 | 契约 | 真实库实测结论(H2 与 MySQL 一致) |
+|---|---|---|
+| `status` | 无论传入什么,只返回 `PUBLISHED` | DRAFT、CLOSED 在任何组合、任何排序下都不出现;显式按 `companyId` 过滤时同样不出现 |
+| `keyword` | trim 后忽略大小写,匹配 title / description / requirements 任一字段 | 三字段分别命中;`java` / `JaVa` / ` Java ` 结果相同 |
+| `city` | trim 后忽略大小写精确匹配 | `Q01-Beijing`、`q01-beijing` 和带空格的小写查询命中同一岗位;与 `city + "别处"` 区分开 |
+| `employmentType` | trim 后忽略大小写精确匹配 | `FULL_TIME`、`full_time` 和带空格的小写查询命中同一岗位;`PART_TIME`、`FULL` 不命中 |
+| `companyId` | 精确匹配 | 只返回本企业岗位 |
+| `salaryMin` | 岗位 `salaryMax >= salaryMin`(单边) | 边界相等命中;`salaryMax` 更高者也命中;`salaryMax` 低于边界者排除 |
+| `salaryMax` | 岗位 `salaryMin <= salaryMax`(单边) | 边界相等命中;`salaryMin` 更低者也命中;`salaryMin` 高于边界者排除 |
+| 两端同时给出 | 区间重叠 | 部分重叠命中、被用户区间包含命中;高于或低于用户区间均排除 |
+| 多条件 | AND | 关键词/城市/用工类型/企业/薪资必须同时满足 |
+| 无结果 | 空页 | `content` 为空、`totalElements = 0` |
+
+## 5. keyword 三字段、大小写与特殊字符
+
+- **title / description / requirements 三字段**:三个只在一个字段含关键词的岗位都能被单独命中,未含关键词的岗位不命中。
+- **大小写**:`lower(title|description|requirements) LIKE lower(pattern)`,`java`、`JAVA`、`JaVa` 命中同一组岗位(MySQL 侧还叠加列排序规则,结论一致)。
+- **trim**:`" Java "` 与 `"Java"` 结果一致。
+- **空值**:`null`、`""`、`" "`、`" "`、`"\t"` 均**不参与过滤**(`isBlank()` 判定),返回该城市下全部岗位。
+- **LIKE 特殊字符**(锁定既有契约,**未做转义改动**):
+ - `%` 作为通配符,命中该城市下全部带标题的岗位;
+ - `_` 作为单字符通配符,同样命中全部;
+ - `A_B` 以通配符解释,命中 `A_B岗` 本身。
+ - H2 与 MySQL 行为**一致**。这是既有语义,产品未决策前不改为字面量搜索。
+
+## 6. 薪资边界与区间重叠
+
+H2 与 MySQL 逐项一致:
+
+| 用例 | 用户条件 | 期望与实测 |
+|---|---|---|
+| `salaryMinMatchesWhenJobMaximumEqualsBoundary` | `salaryMin=20000` | 仅 `[10000,19999]` 被排除,`[10000,20000]` 命中(边界相等命中) |
+| `salaryMaxMatchesWhenJobMinimumEqualsBoundary` | `salaryMax=10000` | 仅 `[10001,20000]` 被排除,`[10000,20000]` 命中 |
+| 部分重叠 | `[15000,25000]` | `[10000,20000]`、`[12000,30000]` 命中 |
+| 不重叠 | `[30000,35000]`、`[1000,4000]` | 空 |
+| NULL 薪资 | 不加薪资条件 | `[null,null]`、`[10000,null]` 照常返回 |
+
+NULL 薪资的既有契约(SQL 三值逻辑):一旦给出 `salaryMin`,`job.salaryMax >= ?` 对 `salaryMax IS NULL` 求值为 UNKNOWN,因此 `salaryMax` 为 NULL 的岗位被排除。Q01 **锁定**该行为,不做修改。
+
+## 7. PUBLISHED 强制过滤
+
+无论是否传其它条件、无论 `sort` 取 `latest` / `relevance` / `salary` / `null`,DRAFT 与 CLOSED 岗位**绝不出现**;即使显式传入 DRAFT 岗位所属的 `companyId` 也不出现。H2 与 MySQL 一致。
+
+## 8. latest / relevance / salary 排序
+
+| 排序 | 语义 | 实测 |
+|---|---|---|
+| `latest` / `null` | `createdAt DESC, id DESC` | 按 `created_at` 倒序;`created_at` 相同时按 `id DESC`;`null` 与 `latest` 结果完全一致 |
+| `relevance` | 当前与 `latest` 等价(本轮锁定,不改造成新算法) | 与 `latest` 结果完全一致 |
+| `salary` | `salaryMax` 非空优先降序;相同值按 `id DESC`;NULL 最后 | `CASE WHEN salary_max IS NULL THEN 1 ELSE 0 END ASC, salary_max DESC, id DESC` 生效,NULL 排在最后;相同 `salaryMax` 按 `id DESC` |
+
+`salary` 排序走 `JobService.findWithSalarySort` 的 CriteriaQuery + CASE WHEN 路径(真实 `EntityManager`),在 H2 与真实 MySQL 上均通过。
+
+## 9. 分页与 total
+
+- 5 条数据、`size=2` 时:`totalElements = 5`、`totalPages = 3`;第 0 页与第 1 页 ID 顺序与期望逐项一致;三页拼接等于完整期望顺序,**无重复、无遗漏**。
+- 无结果时 `totalElements = 0`。
+- H2 与 MySQL 一致。
+
+## 10. H2 与 MySQL 差异
+
+初次复验发现 MySQL `utf8mb4_unicode_ci` 与 H2 默认字符串比较规则可能造成 city、employmentType 大小写差异。现已在 Specification 中显式对字段和值使用 `lower`,并在两库增加同名大小写变体测试。两个库在 keyword、city、employmentType、特殊字符、薪资边界、PUBLISHED 过滤、排序、分页和 total 上结果现已逐项一致。
+
+过程中在 **MySQL 用例内部**发现并修正了 3 处**用例自身的期望错误**(不是生产缺陷,见第 13 节):
+
+1. 只给 `city` 条件时,用例原本期望同城但 `PART_TIME` 的岗位不出现——契约只规定城市精确匹配,返回它是正确的;
+2. `salaryMin=20000` 时用例原本期望只返回边界岗位,但契约是**单边**比较,`salaryMax=60000 >= 20000` 的岗位同样应命中;
+3. `salaryMax=10000` 时同理,`salaryMin=5000 <= 10000` 的岗位同样应命中。
+
+修正方式是把期望改为与设计文档第 4 节契约表一致,并**加强**而非放宽断言(补齐两条「区间完全在用户区间之外」的反向断言、补 `doesNotContain` 前缀城市断言)。H2 用例的同名场景夹具本就设计正确(相邻岗位取「刚好越界」值),因此未受影响。
+
+## 11. EXPLAIN 摘要
+
+真实 MySQL 8.4 上以 `EXPLAIN FORMAT=JSON` 采集,等价 SQL 与 Criteria 路径**绑定同一组参数**,并先断言二者返回**完全相同的 ID 与顺序**(保证 EXPLAIN 的不是一份不存在的语句)。完整原文见 [Q01 MySQL 执行计划证据](q01-job-search-mysql-explain-evidence.md)。
+
+| 语句 | query_cost | access_type | 可用索引 | 实际选用 key | rows_examined_per_scan | using_filesort | using_temporary_table |
+|---|---|---|---|---|---|---|---|
+| 组合过滤 + latest 分页 | 1.35 | `ALL` | `idx_jobs_company_status`、`idx_jobs_status_created`、`idx_jobs_city_status`、`idx_jobs_status_employment_type`、`idx_jobs_status_salary_max` | 未选用 | 1 | true | false |
+| latest 分页 | 1.35 | `ALL` | 同上(除 `idx_jobs_company_status`) | 未选用 | 1 | true | false |
+| salary 排序(CASE WHEN) | 1.35 | `ALL` | 同上 | 未选用 | 1 | true | false |
+| count 查询 | 0.35 | `ALL` | 同上 | 未选用 | 1 | false | false |
+
+结果规模与 SQL 语句条数(page size = 20):
+
+| 匹配结果数 | 返回行数 | totalElements | Repository SQL数 | 完整Service SQL数 |
+|---|---|---|---|---|
+| 1 | 1 | 1 | 1 | 2 |
+| 20 | 20 | 20 | 2 | 3 |
+| 50 | 20 | 50 | 2 | 3 |
+
+Spring Data 对首页有优化:首页返回行数小于 `pageSize` 时不再发 count 查询,因此 Repository 路径为 1/2/2 条。完整 Service 路径固定增加一次企业批量查询,为 2/3/3 条;两者均不随结果数量线性增长,不存在逐岗位查询企业的 N+1。
+
+**结论与边界**:Q01 夹具刻意做小(每个城市 1 / 20 / 50 条),InnoDB 统计信息在此规模下估算行数为 1、优化器选择全表扫描并做 filesort,**这不构成生产规模的性能结论**。按 Q01 边界不新增、不调整索引;`%keyword%` 前导通配符本身无法走 B-Tree 索引、以及大规模数据下的 `ORDER BY` filesort 成本,记录给 **R01**。
+
+## 12. 数据副作用结果
+
+用例在 jobs、companies、users、applications、candidate_dimension_scores、notifications、conversations、messages、audit_logs **九张表**上做**行级稳定字段快照**(按主键排序、逐行取显式列出的稳定业务字段,不使用 `SELECT *`、不含 `password_hash` 等凭据列),执行覆盖「全部过滤条件 × 4 种排序 × 分页 × 无结果 × 聚合筛选项」的完整搜索矩阵后再次取快照,逐表逐行逐字段比较:
+
+- **H2**:前后完全一致;
+- **真实 MySQL**:前后完全一致。
+
+仅比较 COUNT / SUM / CRC32 无法证明「没有某一行被改写」,故另设**反证测试**(H2 与 MySQL 各跑一遍):
+
+| 反证用例 | 人为动作 | 要求 |
+|---|---|---|
+| `snapshotReportsRowFieldUpdate` | 直接改写 `jobs.status` | 必须被发现;把字段与 `updated_at` 一并还原后必须重新完全一致(证明差异定位精确而非「永远不等」) |
+| `snapshotReportsInsertedRow` | 插入一行 notification | 必须被发现 |
+| `snapshotReportsDeletedRow` | 删除一行 notification | 必须被发现 |
+
+反证全部通过,说明主证据的「无副作用」结论不是因为比较失效。测试同时断言九张受检表在测试库中**确有行**,避免空快照导致的假通过。
+
+## 13. 缺陷与判定
+
+定向复验发现测试环境与线上 MySQL 的大小写比较可能不一致。为锁定线上已有的忽略大小写结果并保证跨库一致,生产代码仅修改 `JobSearchSpecification`,对 city 和 employmentType 显式执行 Locale.ROOT 小写归一化;接口、DTO、权限、状态机和 MySQL 既有返回语义均未改变。`JobService`、`JobRepository`、`JobController` 与 `ErrorCode` 保持原样。
+
+过程中处理的 2 类**非生产缺陷**问题,均已记录:
+
+1. **MySQL 用例无法启动上下文**:该类最初未激活任何 profile,导致 `app.jwt.*` 测试密钥缺失、`AppJwtProperties` 校验失败(`JWT_ACCESS_SECRET must be at least 32 characters`)。这是**用例配置缺失**,不是生产缺陷(生产要求 JWT 密钥必须显式配置,行为正确)。修复方式是新增测试专用 profile `mysql-it`,并保持连接参数由 `@DynamicPropertySource` 注入。
+2. **MySQL 用例期望与契约表自相矛盾**:见第 10 节的 3 处,已按契约修正并加强断言。
+
+**无待整改生产缺陷。** 上述跨库一致性问题已定向解决并有 H2/MySQL 同名用例证明。
+
+## 14. 关联与全量测试
+
+| 项目 | 命令 | 结果 |
+|---|---|---|
+| 岗位关联回归 | `mvn -Dtest="*JobSearch*Test,*Job*Test,*CompanyPublic*Test" test` | **Tests run: 225, Failures: 0, Errors: 0, Skipped: 0** |
+| 全量测试 | `mvn test` | **Tests run: 1890, Failures: 0, Errors: 0, Skipped: 0** |
+| 干净构建 | `mvn clean verify` | **Tests run: 1890, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS`,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar` |
+
+关联回归中与 Q01 直接相关的类:`JobSearchSpecificationIntegrationTest` 21、`JobSearchServiceIntegrationTest` 9、`JobSearchSpecificationMySqlIntegrationTest` 15、`JobSearchQuerySideEffectIntegrationTest` 4、`JobSearchQuerySideEffectMySqlIntegrationTest` 4、`JobSearchQueryExplainMySqlIntegrationTest` 1、`JobSearchServiceTest` 37(保留)、`JobServiceTest` 13、`JobFavoriteServiceTest` 10、`CompanyPublicServiceTest` 22。
+
+## 15. Failures / Errors / Skipped 与 clean verify
+
+| 项目 | Tests run | Failures | Errors | Skipped | 构建 |
+|---|---|---|---|---|---|
+| `mvn test` | **1890** | **0** | **0** | **0** | `BUILD SUCCESS` |
+| `mvn clean verify` | **1890** | **0** | **0** | **0** | `BUILD SUCCESS`(含打包,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar`) |
+
+所有必跑测试 `Failures = 0`、`Errors = 0`、`Skipped = 0`。日志中不存在任何 `Skipped: <非零>` 的测试类。三个 MySQL Testcontainers 用例类在 `mvn clean verify` 中**真实启动容器并执行**(`JobSearchSpecificationMySqlIntegrationTest` 15 条 / `JobSearchQuerySideEffectMySqlIntegrationTest` 4 条 / `JobSearchQueryExplainMySqlIntegrationTest` 1 条),**未发生跳过**。
+
+## 16. OpenAPI
+
+```
+python docs/verify_openapi.py
+paths: 176
+operations: 214
+All checks passed!
+```
+
+`docs/openapi-rc.json` **未被修改**(`git status --short -- docs/openapi-rc.json` 无输出)。Q01 只新增测试,未新增、未删除、未重命名任何 path 或 operation,未改变参数名、默认值、响应 DTO 或错误码。
+
+## 17. 精确修改文件
+
+**新增(Q01 测试与证据)**
+
+```text
+mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java
+mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java
+mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java
+mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectTestBase.java
+mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectIntegrationTest.java
+mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectMySqlIntegrationTest.java
+mydreamjob-server/src/test/java/com/mydreamjob/job/BusinessRowSnapshot.java
+mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQueryExplainMySqlIntegrationTest.java
+mydreamjob-server/src/test/java/com/mydreamjob/job/Q01SqlRecorder.java
+mydreamjob-server/src/test/resources/application-mysql-it.yml
+docs/q01-job-search-real-query-design.md
+docs/q01-job-search-real-query-implementation-instruction.md
+docs/q01-job-search-real-query-implementation-report.md
+docs/q01-job-search-mysql-explain-evidence.md
+```
+
+**修改(1 个定向生产修复、测试注释与文档)**
+
+```text
+mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java # city/employmentType 跨库大小写语义显式统一
+mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java # 仅补类级 Javadoc,标注为编排测试;断言未改动
+docs/backend-development-roadmap.md
+docs/phase1-scope-and-acceptance.md
+```
+
+## 18. 是否修改生产代码 / 新增迁移或接口
+
+- **生产代码:定向修改 1 个文件。** `JobSearchSpecification` 对 city 和 employmentType 显式使用 `lower` + `Locale.ROOT`,保持线上 MySQL 既有忽略大小写结果并消除 H2 差异;`JobService`、`JobRepository`、`JobController`、DTO、状态机与权限未改。
+- **Flyway:未新增。** 最新迁移仍为 V43;V1–V43 全部未修改。
+- **ErrorCode:未新增。**
+- **公网接口:未新增、未删除、未重命名。** OpenAPI 保持 176 paths / 214 operations。
+- 未接入 Elasticsearch、缓存或搜索引擎;未进入 R01。
+
+## 19. Git 状态
+
+```
+git branch --show-current -> test/q01-job-search-specification
+git diff --cached --name-only -> (空)
+git status --short -- mydreamjob-server/src/main -> M JobSearchSpecification.java(Q01 定向修复)
+git status --short -- mydreamjob-server/src/main/resources/db/migration -> (空)
+git status --short -- docs/openapi-rc.json -> (空)
+```
+
+检查点执行前,本分支未暂存、未提交、未推送;未执行 `git add -A`、reset、restore、checkout、clean;未删除或移动任何历史未跟踪文件。Q01 改动由 1 个定向生产修复、真实数据库测试与正式证据文档组成。
+
+## 20. 待整改事项
+
+**Q01 范围内无待整改缺陷。**
+
+移交给 **R01**(不在 Q01 修复):
+
+- `keyword` 使用 `%keyword%` 前导通配符,B-Tree 索引无法用于该谓词;
+- 小样本夹具下优化器选择全表扫描 + filesort,真实数据规模下的执行计划与排序成本需在 R01 以真实量级数据评估;
+- `JobService.findWithSalarySort` 的 `CASE WHEN` 排序无法利用 `idx_jobs_status_salary_max` 的有序性,需要 R01 评估替代方案。
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java
index a2df424..17fecd3 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.domain.Specification;
import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
/**
* 岗位搜索动态查询 Specification。
@@ -51,21 +52,24 @@ public class JobSearchSpecification {
if (keyword != null && !keyword.isBlank()) {
String pattern = "%" + keyword.trim() + "%";
Predicate keywordPred = cb.or(
- cb.like(cb.lower(root.get(TITLE)), pattern.toLowerCase()),
- cb.like(cb.lower(root.get(DESCRIPTION)), pattern.toLowerCase()),
- cb.like(cb.lower(root.get(REQUIREMENTS)), pattern.toLowerCase())
+ cb.like(cb.lower(root.get(TITLE)), pattern.toLowerCase(Locale.ROOT)),
+ cb.like(cb.lower(root.get(DESCRIPTION)), pattern.toLowerCase(Locale.ROOT)),
+ cb.like(cb.lower(root.get(REQUIREMENTS)), pattern.toLowerCase(Locale.ROOT))
);
predicates.add(keywordPred);
}
- // 城市精确匹配
+ // 城市:trim 后忽略大小写精确匹配。
+ // 显式 lower 可避免 MySQL 的大小写不敏感排序规则与 H2 默认比较规则产生差异。
if (city != null && !city.isBlank()) {
- predicates.add(cb.equal(root.get(CITY), city.trim()));
+ predicates.add(cb.equal(cb.lower(root.get(CITY)), city.trim().toLowerCase(Locale.ROOT)));
}
- // 用工类型精确匹配
+ // 用工类型:trim 后忽略大小写精确匹配,与线上 MySQL 既有比较结果保持一致。
if (employmentType != null && !employmentType.isBlank()) {
- predicates.add(cb.equal(root.get(EMPLOYMENT_TYPE), employmentType.trim()));
+ predicates.add(cb.equal(
+ cb.lower(root.get(EMPLOYMENT_TYPE)),
+ employmentType.trim().toLowerCase(Locale.ROOT)));
}
// 薪资范围:岗位 salaryMax >= 用户 salaryMin
@@ -86,4 +90,4 @@ public class JobSearchSpecification {
return cb.and(predicates.toArray(new Predicate[0]));
};
}
-}
\ No newline at end of file
+}
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/BusinessRowSnapshot.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/BusinessRowSnapshot.java
new file mode 100644
index 0000000..8422edb
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/BusinessRowSnapshot.java
@@ -0,0 +1,126 @@
+package com.mydreamjob.job;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.sql.Clob;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Q01 §7 查询副作用证据:业务表的**行级稳定字段快照**。
+ *
+ *
只比较 COUNT / SUM / CRC32 只能说明“行数没变”,不能说明“没有某一行被改写”。
+ * 因此这里对每张受检表按主键排序逐行取值,查询前后各取一次,逐行逐字段比较。
+ *
+ * 只选取显式列出的稳定业务字段,不使用 {@code SELECT *},也不包含任何凭据列
+ * (例如 {@code users.password_hash}),因此失败信息中不会出现密码摘要。
+ */
+final class BusinessRowSnapshot {
+
+ /** 受检表 → 稳定业务字段(顺序即 SELECT 列顺序,也是比较顺序)。 */
+ private static final Map> TABLES = new LinkedHashMap<>();
+
+ static {
+ TABLES.put("users", List.of(
+ "id", "phone", "nickname", "role", "is_active", "is_verified",
+ "created_at", "updated_at"));
+ TABLES.put("companies", List.of(
+ "id", "owner_user_id", "name", "logo_url", "description", "industry",
+ "website", "address", "verified", "created_at", "updated_at"));
+ TABLES.put("jobs", List.of(
+ "id", "company_id", "title", "description", "requirements", "city",
+ "employment_type", "salary_min", "salary_max", "status", "published_at",
+ "created_at", "updated_at"));
+ TABLES.put("applications", List.of(
+ "id", "job_id", "student_user_id", "cover_letter", "status",
+ "created_at", "updated_at"));
+ TABLES.put("candidate_dimension_scores", List.of(
+ "id", "student_user_id", "dimension", "period", "score", "status",
+ "sub_scores_json", "weight", "rules_version", "calculated_at", "version",
+ "created_at", "updated_at"));
+ TABLES.put("notifications", List.of(
+ "id", "user_id", "type", "title", "content", "is_read", "read_at",
+ "created_at"));
+ TABLES.put("conversations", List.of(
+ "id", "title", "type", "created_by", "created_at", "updated_at"));
+ TABLES.put("messages", List.of(
+ "id", "conversation_id", "sender_user_id", "content", "created_at"));
+ TABLES.put("audit_logs", List.of(
+ "id", "user_id", "action", "entity_type", "entity_id", "details",
+ "ip_address", "user_agent", "request_id", "created_at"));
+ }
+
+ private final JdbcTemplate jdbcTemplate;
+
+ BusinessRowSnapshot(JdbcTemplate jdbcTemplate) {
+ this.jdbcTemplate = jdbcTemplate;
+ }
+
+ static Set tables() {
+ return TABLES.keySet();
+ }
+
+ /** 逐表按主键顺序捕获行级快照。 */
+ Map>> capture() {
+ Map>> snapshot = new LinkedHashMap<>();
+ TABLES.forEach((table, columns) -> {
+ String sql = "SELECT " + String.join(", ", columns) + " FROM " + table + " ORDER BY id";
+ List