From 41ab7ba9d44cc6cc937275464a02c0275809b9c4 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Mon, 19 Oct 2026 14:27:18 +0800 Subject: [PATCH] feat(jobs): add industry experience and education filters Add CSV multi-select filters with OR within dimensions and AND across dimensions, dynamic published-job options, and OpenAPI updates. Targeted regression: 93 passed, including H2 and real MySQL HTTP integration. Full mvn verify and latest JAR blackbox remain pending; release requested before full validation. --- docs/openapi-rc.json | 31 +++- docs/verify_openapi.py | 8 + .../com/mydreamjob/job/JobController.java | 13 +- .../com/mydreamjob/job/JobFilterValues.java | 27 ++++ .../com/mydreamjob/job/JobRepository.java | 13 ++ .../job/JobSearchSpecification.java | 26 +++ .../java/com/mydreamjob/job/JobService.java | 13 +- .../mydreamjob/job/JobFilterValuesTest.java | 35 ++++ .../JobMultiFilterHttpIntegrationTest.java | 6 + .../JobMultiFilterMySqlIntegrationTest.java | 23 +++ .../job/JobMultiFilterTestBase.java | 150 ++++++++++++++++++ 11 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/JobFilterValues.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/JobFilterValuesTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterHttpIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterMySqlIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterTestBase.java diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 9afe518..f121635 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -3314,7 +3314,7 @@ "岗位" ], "summary": "岗位筛选选项", - "description": "返回当前 PUBLISHED 岗位可用的城市、用工类型和薪资范围", + "description": "返回当前 PUBLISHED 岗位可用的 cities、employmentTypes、salaryRange,以及 industries、experiences、educations 字符串数组。新增选项按实际数据去重排序、排除空值,不是固定枚举;不含仅草稿/关闭岗位的值;行业来自有已发布岗位的企业。选项值原样作为搜索标签,不发送不限。", "operationId": "getFilters", "responses": { "200": { @@ -3369,7 +3369,7 @@ "岗位" ], "summary": "岗位高级搜索", - "description": "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资、公司筛选、排序和分页", + "description": "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资、公司、行业、经验、学历筛选、排序和分页;industry/experience/education 逗号分隔多选,同维度 OR、跨维度 AND,trim 后忽略大小写精确匹配,空值不筛选;筛选选项见 /jobs/filters。每项原始参数最多4096字符。", "operationId": "searchJobs", "parameters": [ { @@ -3423,6 +3423,33 @@ "format": "int64" } }, + { + "name": "industry", + "in": "query", + "required": false, + "description": "标签 trim 后忽略大小写精确匹配,逗号分隔多选,同维度 OR、跨维度 AND;最多20个去重值、单项80字符、原始长度4096。空值不筛选,空项/超限/控制字符400,未知值无匹配。选项见 /jobs/filters 的 industries;不推断年限、学历等级或行业别名。", + "schema": { + "type": "string" + } + }, + { + "name": "experience", + "in": "query", + "required": false, + "description": "标签 trim 后忽略大小写精确匹配,逗号分隔多选,同维度 OR、跨维度 AND;最多20个去重值、单项40字符、原始长度4096。空值不筛选,空项/超限/控制字符400,未知值无匹配。选项见 /jobs/filters 的 experiences;不推断年限、学历等级或行业别名。", + "schema": { + "type": "string" + } + }, + { + "name": "education", + "in": "query", + "required": false, + "description": "标签 trim 后忽略大小写精确匹配,逗号分隔多选,同维度 OR、跨维度 AND;最多20个去重值、单项40字符、原始长度4096。空值不筛选,空项/超限/控制字符400,未知值无匹配。选项见 /jobs/filters 的 educations;不推断年限、学历等级或行业别名。", + "schema": { + "type": "string" + } + }, { "name": "sort", "in": "query", diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 45dd164..c461817 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -878,5 +878,13 @@ data = json.dumps(d) assert '"" : "#/components/schemas/' not in data, "Found empty ref key" print("No empty ref keys found") +_search_parameters = {p['name']: p for p in paths['/jobs/search']['get']['parameters']} +for _name in ('industry', 'experience', 'education'): + assert _search_parameters[_name]['in'] == 'query' + assert _search_parameters[_name]['required'] is False + assert _search_parameters[_name]['schema']['type'] == 'string' +for _option in ('industries', 'experiences', 'educations'): + assert _option in paths['/jobs/filters']['get']['description'] +print(' 岗位行业/经验/学历多选参数和动态选项说明已验证') print("Valid JSON: True") print("All checks passed!") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java index 3e4f2f0..d78b059 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java @@ -14,6 +14,7 @@ import com.mydreamjob.job.dto.JobStatusRequest; import com.mydreamjob.job.dto.JobUpdateRequest; import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; @@ -150,7 +151,7 @@ public class JobController { // ── 公开端:高级搜索 ──────────────────────────────────────── - @Operation(summary = "岗位高级搜索", description = "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资、公司筛选、排序和分页") + @Operation(summary = "岗位高级搜索", description = "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资、公司、行业、经验、学历筛选、排序和分页;industry/experience/education 逗号分隔多选,同维度 OR、跨维度 AND,trim 后忽略大小写精确匹配,空值不筛选;筛选选项见 /jobs/filters。每项原始参数最多4096字符。") @GetMapping("/search") public ResponseEntity>> searchJobs( @RequestParam(required = false) String keyword, @@ -159,13 +160,19 @@ public class JobController { @RequestParam(required = false) Integer salaryMin, @RequestParam(required = false) Integer salaryMax, @RequestParam(required = false) Long companyId, + @Parameter(description = "行业标签精确匹配,取公司 industry;逗号分隔多选,同维度 OR、跨维度 AND。选项见 /jobs/filters 的 industries;最多20个去重值、每项80字符,空值不筛选,非法格式400,未知值无匹配。") + @RequestParam(required = false) String industry, + @Parameter(description = "经验标签精确匹配,不做年限推断;逗号分隔多选,选项见 experiences;最多20个去重值、每项40字符,空值不筛选,非法格式400。") + @RequestParam(required = false) String experience, + @Parameter(description = "学历标签精确匹配,不做学历高低推断;逗号分隔多选,选项见 educations;最多20个去重值、每项40字符,空值不筛选,非法格式400。") + @RequestParam(required = false) String education, @RequestParam(required = false) String sort, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { Page result = jobService.searchJobs( keyword, city, employmentType, salaryMin, salaryMax, companyId, - sort, page, size); + sort, page, size, industry, experience, education); Map data = new HashMap<>(); data.put("items", result.getContent()); data.put("total", result.getTotalElements()); @@ -177,7 +184,7 @@ public class JobController { // ── 公开端:筛选选项 ──────────────────────────────────────── - @Operation(summary = "岗位筛选选项", description = "返回当前 PUBLISHED 岗位可用的城市、用工类型和薪资范围") + @Operation(summary = "岗位筛选选项", description = "返回当前 PUBLISHED 岗位可用的 cities、employmentTypes、salaryRange,以及 industries、experiences、educations 字符串数组。新增选项按实际数据去重排序、排除空值,不是固定枚举;不含仅草稿/关闭岗位的值;行业来自有已发布岗位的企业。选项值原样作为搜索标签,不发送不限。") @GetMapping("/filters") public ResponseEntity>> getFilters( HttpServletRequest request) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFilterValues.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFilterValues.java new file mode 100644 index 0000000..aea9e73 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFilterValues.java @@ -0,0 +1,27 @@ +package com.mydreamjob.job; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; + +/** 多选筛选:仅解析标签,不猜测年限、学历等级或行业别名。 */ +final class JobFilterValues { + private JobFilterValues() { } + + static List parse(String raw, int itemLimit) { + if (raw == null || raw.isBlank()) return List.of(); + if (raw.length() > 4096) throw new BusinessException(ErrorCode.BAD_REQUEST); + var values = new LinkedHashSet(); + for (String token : raw.split(",", -1)) { + String value = token.trim(); + if (value.isEmpty() || value.length() > itemLimit || value.chars().anyMatch(Character::isISOControl)) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + values.add(value.toLowerCase(Locale.ROOT)); + if (values.size() > 20) throw new BusinessException(ErrorCode.BAD_REQUEST); + } + return List.copyOf(values); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java index f2d51d5..db1823e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java @@ -46,6 +46,19 @@ public interface JobRepository extends JpaRepository, JpaSpecificatio @Query("SELECT DISTINCT j.employmentType FROM Job j WHERE j.status = 'PUBLISHED' ORDER BY j.employmentType ASC") List findDistinctEmploymentTypesPublished(); + @Query("SELECT DISTINCT TRIM(c.industry) FROM Company c WHERE c.industry IS NOT NULL" + + " AND TRIM(c.industry) <> '' AND EXISTS (SELECT j.id FROM Job j" + + " WHERE j.companyId = c.id AND j.status = 'PUBLISHED') ORDER BY TRIM(c.industry)") + List findDistinctIndustriesPublished(); + + @Query("SELECT DISTINCT TRIM(j.experience) FROM Job j WHERE j.status = 'PUBLISHED'" + + " AND j.experience IS NOT NULL AND TRIM(j.experience) <> '' ORDER BY TRIM(j.experience)") + List findDistinctExperiencesPublished(); + + @Query("SELECT DISTINCT TRIM(j.education) FROM Job j WHERE j.status = 'PUBLISHED'" + + " AND j.education IS NOT NULL AND TRIM(j.education) <> '' ORDER BY TRIM(j.education)") + List findDistinctEducationsPublished(); + @Query("SELECT MIN(j.salaryMin) FROM Job j WHERE j.status = 'PUBLISHED'") Integer findMinSalaryPublished(); 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 17fecd3..b97bf26 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java @@ -1,6 +1,7 @@ package com.mydreamjob.job; import jakarta.persistence.criteria.Predicate; +import com.mydreamjob.company.Company; import org.springframework.data.jpa.domain.Specification; import java.util.ArrayList; @@ -41,6 +42,16 @@ public class JobSearchSpecification { public static Specification searchJobs( String keyword, String city, String employmentType, Integer salaryMin, Integer salaryMax, Long companyId) { + return searchJobs(keyword, city, employmentType, salaryMin, salaryMax, companyId, null, null, null); + } + + public static Specification searchJobs( + String keyword, String city, String employmentType, + Integer salaryMin, Integer salaryMax, Long companyId, + String industry, String experience, String education) { + var industries = JobFilterValues.parse(industry, 80); + var experiences = JobFilterValues.parse(experience, 40); + var educations = JobFilterValues.parse(education, 40); return (root, query, cb) -> { List predicates = new ArrayList<>(); @@ -83,6 +94,21 @@ public class JobSearchSpecification { } // 公司筛选 + // EXISTS 不扩大主查询行数,分页 count 和薪资排序保持正确;无企业岗位仅在行业筛选时排除。 + if (!industries.isEmpty()) { + var companyQuery = query.subquery(Long.class); + var company = companyQuery.from(Company.class); + companyQuery.select(company.get("id")).where( + cb.equal(company.get("id"), root.get(COMPANY_ID)), + cb.lower(cb.trim(company.get("industry"))).in(industries)); + predicates.add(cb.exists(companyQuery)); + } + if (!experiences.isEmpty()) { + predicates.add(cb.lower(cb.trim(root.get("experience"))).in(experiences)); + } + if (!educations.isEmpty()) { + predicates.add(cb.lower(cb.trim(root.get("education"))).in(educations)); + } if (companyId != null) { predicates.add(cb.equal(root.get(COMPANY_ID), companyId)); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java index 275a32a..d843953 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java @@ -383,6 +383,14 @@ public class JobService { public Page searchJobs(String keyword, String city, String employmentType, Integer salaryMin, Integer salaryMax, Long companyId, String sort, int page, int size) { + return searchJobs(keyword, city, employmentType, salaryMin, salaryMax, companyId, + sort, page, size, null, null, null); + } + + public Page searchJobs(String keyword, String city, String employmentType, + Integer salaryMin, Integer salaryMax, Long companyId, + String sort, int page, int size, + String industry, String experience, String education) { // 参数校验 if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) { throw new BusinessException(ErrorCode.BAD_REQUEST); @@ -398,7 +406,7 @@ public class JobService { } var spec = JobSearchSpecification.searchJobs(keyword, city, employmentType, - salaryMin, salaryMax, companyId); + salaryMin, salaryMax, companyId, industry, experience, education); Page jobPage; if ("salary".equals(sort)) { @@ -520,6 +528,9 @@ public class JobService { result.put("cities", cities != null ? cities : Collections.emptyList()); result.put("employmentTypes", employmentTypes != null ? employmentTypes : Collections.emptyList()); result.put("salaryRange", salaryRange); + result.put("industries", jobRepository.findDistinctIndustriesPublished()); + result.put("experiences", jobRepository.findDistinctExperiencesPublished()); + result.put("educations", jobRepository.findDistinctEducationsPublished()); return result; } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFilterValuesTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFilterValuesTest.java new file mode 100644 index 0000000..f36a0e6 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFilterValuesTest.java @@ -0,0 +1,35 @@ +package com.mydreamjob.job; + +import com.mydreamjob.common.exception.BusinessException; +import org.junit.jupiter.api.Test; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import static org.assertj.core.api.Assertions.*; + +class JobFilterValuesTest { + @Test void nullAndWhitespaceMeanNoFilter() { + assertThat(JobFilterValues.parse(null,40)).isEmpty(); + assertThat(JobFilterValues.parse(" \t ",40)).isEmpty(); + } + @Test void trimCaseAndDuplicatesNormalizeInFirstSeenOrder() { + assertThat(JobFilterValues.parse(" IT,it, 金融 ",80)).containsExactly("it","金融"); + } + @Test void itemBoundariesAndRawBoundAreEnforced() { + assertThat(JobFilterValues.parse("x".repeat(40),40)).hasSize(1); + assertThatThrownBy(()->JobFilterValues.parse("x".repeat(41),40)).isInstanceOf(BusinessException.class); + assertThat(JobFilterValues.parse("x".repeat(80),80)).hasSize(1); + assertThatThrownBy(()->JobFilterValues.parse("x".repeat(81),80)).isInstanceOf(BusinessException.class); + assertThatThrownBy(()->JobFilterValues.parse("a,".repeat(2048)+"a",80)).isInstanceOf(BusinessException.class); + } + @Test void twentyDistinctAllowedTwentyOneRejectedAfterDeduplication() { + String twenty=IntStream.range(0,20).mapToObj(i->"tag"+i).collect(Collectors.joining(",")); + assertThat(JobFilterValues.parse(twenty,40)).hasSize(20); + assertThatThrownBy(()->JobFilterValues.parse(twenty+",tag20",40)).isInstanceOf(BusinessException.class); + assertThat(JobFilterValues.parse("本科,".repeat(30)+"本科",40)).containsExactly("本科"); + } + @Test void emptyItemsAndControlsAreRejected() { + for(String raw:new String[]{",",",a","a,","a,,b","a, ,b","a\nb","a\u0000b"}) { + assertThatThrownBy(()->JobFilterValues.parse(raw,40)).isInstanceOf(BusinessException.class); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterHttpIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterHttpIntegrationTest.java new file mode 100644 index 0000000..d76bb81 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterHttpIntegrationTest.java @@ -0,0 +1,6 @@ +package com.mydreamjob.job; + +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("integration-test") +class JobMultiFilterHttpIntegrationTest extends JobMultiFilterTestBase { } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterMySqlIntegrationTest.java new file mode 100644 index 0000000..4a4bf56 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterMySqlIntegrationTest.java @@ -0,0 +1,23 @@ +package com.mydreamjob.job; + +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; + +@ActiveProfiles("mysql-it") +@Testcontainers(disabledWithoutDocker = false) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class JobMultiFilterMySqlIntegrationTest extends JobMultiFilterTestBase { + @Container static final MySQLContainer MYSQL=new MySQLContainer<>("mysql:8.4") + .withDatabaseName("qsp_filter_test").withUsername("filter_test").withPassword("isolated-test-only"); + @DynamicPropertySource static void database(DynamicPropertyRegistry r) { + r.add("spring.datasource.url", MYSQL::getJdbcUrl); + r.add("spring.datasource.username", MYSQL::getUsername); + r.add("spring.datasource.password", MYSQL::getPassword); + r.add("spring.jpa.hibernate.ddl-auto", ()->"validate"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterTestBase.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterTestBase.java new file mode 100644 index 0000000..0bd7056 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterTestBase.java @@ -0,0 +1,150 @@ +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.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +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.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import java.util.Set; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 同一套真实 HTTP、数据库筛选断言分别在 H2 和 MySQL 执行。 */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +abstract class JobMultiFilterTestBase { + @MockBean RedisTemplate redisTemplate; + @Autowired MockMvc mvc; + @Autowired ObjectMapper mapper; + @Autowired JobRepository jobs; + @Autowired CompanyRepository companies; + @Autowired UserRepository users; + String city; + Long finance, tech; + Job a, b, c; + + @BeforeEach void fixture() { + city = "筛选" + UUID.randomUUID().toString().substring(0, 8); + finance = company(" 金融 "); + tech = company("互联网/科技"); + a = job(finance, " 3-5年 ", "本科", "PUBLISHED", 20000, 30000); + b = job(finance, "1-3年", "硕士", "PUBLISHED", 10000, 20000); + c = job(tech, "3-5年", "本科", "PUBLISHED", 30000, 40000); + job(tech, null, null, "PUBLISHED", null, null); + job(company("仅草稿" + city), "草稿经验" + city, "草稿学历" + city, "DRAFT", 10000, 20000); + job(tech, "关闭经验" + city, "关闭学历" + city, "CLOSED", 10000, 20000); + job(company(" "), " ", " ", "PUBLISHED", 1000, 2000); + jobs.flush(); + } + + Long company(String industry) { + User user = users.save(User.builder().phone("19" + String.format("%09d", Math.abs(UUID.randomUUID().getMostSignificantBits() % 1000000000))) + .role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).active(true).verified(true).build()); + return companies.saveAndFlush(Company.builder().name(city).ownerUserId(user.getId()).industry(industry).verified(true).build()).getId(); + } + + Job job(Long company, String experience, String education, String state, Integer min, Integer max) { + return jobs.save(Job.builder().companyId(company).title(city + " Java 岗位").description("合成数据") + .city(city).employmentType("FULL_TIME").experience(experience).education(education) + .status(state).salaryMin(min).salaryMax(max).build()); + } + + JsonNode search(String... params) throws Exception { + var values = new java.util.LinkedHashMap(); + values.put("city", city); + values.put("size", "50"); + for (int i=0; i"项"+i).collect(java.util.stream.Collectors.joining(",")); + assertThat(search("education", twenty).path("total").asInt()).isZero(); + mvc.perform(get("/jobs/search").param("education",twenty+",第21项")).andExpect(status().isBadRequest()); + mvc.perform(get("/jobs/search").param("experience","x".repeat(41))).andExpect(status().isBadRequest()); + } + + @Test void filterOptionsReflectOnlyPublishedTrimmedDataAndKeepLegacyKeys() throws Exception { + var data=mapper.readTree(mvc.perform(get("/jobs/filters")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString()).path("data"); + assertThat(data.has("cities") && data.has("employmentTypes") && data.has("salaryRange")).isTrue(); + for(String field: new String[]{"industries","experiences","educations"}) { + var values=mapper.convertValue(data.path(field),new com.fasterxml.jackson.core.type.TypeReference>(){}); + assertThat(values).doesNotHaveDuplicates().allMatch(v->!v.isBlank() && v.equals(v.trim())); + assertThat(values).noneMatch(v->v.contains(city)); + } + assertThat(data.path("industries").toString()).contains("金融","互联网/科技"); + assertThat(data.path("experiences").toString()).contains("3-5年","1-3年"); + assertThat(data.path("educations").toString()).contains("本科","硕士"); + } + + @Test void matchingIsCaseInsensitiveAndDoesNotInterpretEducationAsMinimum() throws Exception { + job(company(" IT "), "Senior", "Bachelor", "PUBLISHED", 10000,20000); + jobs.flush(); + assertThat(search("industry","it","experience"," senior ","education","bachelor").path("total").asInt()).isEqualTo(1); + assertThat(search("education","本科").path("total").asInt()).isEqualTo(2); + } + + @Test void runtimeOpenApiDeclaresAllNewQueryParametersAndOptionNames() throws Exception { + var doc=mapper.readTree(mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString()); + var names=new java.util.HashSet(); + doc.path("paths").path("/jobs/search").path("get").path("parameters").forEach(p->names.add(p.path("name").asText())); + assertThat(names).contains("industry","experience","education","city","salaryMin","salaryMax","page","size","sort"); + String description=doc.path("paths").path("/jobs/filters").path("get").path("description").asText(); + assertThat(description).contains("industries","experiences","educations"); + } +}