Browse Source

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.
fix/mysql57-module-migrations-20261009
Zhang Shun 1 week ago
parent
commit
41ab7ba9d4
  1. 31
      docs/openapi-rc.json
  2. 8
      docs/verify_openapi.py
  3. 13
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java
  4. 27
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobFilterValues.java
  5. 13
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java
  6. 26
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java
  7. 13
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java
  8. 35
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobFilterValuesTest.java
  9. 6
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterHttpIntegrationTest.java
  10. 23
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterMySqlIntegrationTest.java
  11. 150
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobMultiFilterTestBase.java

31
docs/openapi-rc.json

@ -3314,7 +3314,7 @@
"岗位" "岗位"
], ],
"summary": "岗位筛选选项", "summary": "岗位筛选选项",
"description": "返回当前 PUBLISHED 岗位可用的城市、用工类型和薪资范围", "description": "返回当前 PUBLISHED 岗位可用的 cities、employmentTypes、salaryRange,以及 industries、experiences、educations 字符串数组。新增选项按实际数据去重排序、排除空值,不是固定枚举;不含仅草稿/关闭岗位的值;行业来自有已发布岗位的企业。选项值原样作为搜索标签,不发送不限。",
"operationId": "getFilters", "operationId": "getFilters",
"responses": { "responses": {
"200": { "200": {
@ -3369,7 +3369,7 @@
"岗位" "岗位"
], ],
"summary": "岗位高级搜索", "summary": "岗位高级搜索",
"description": "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资、公司筛选、排序和分页", "description": "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资、公司、行业、经验、学历筛选、排序和分页;industry/experience/education 逗号分隔多选,同维度 OR、跨维度 AND,trim 后忽略大小写精确匹配,空值不筛选;筛选选项见 /jobs/filters。每项原始参数最多4096字符。",
"operationId": "searchJobs", "operationId": "searchJobs",
"parameters": [ "parameters": [
{ {
@ -3423,6 +3423,33 @@
"format": "int64" "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", "name": "sort",
"in": "query", "in": "query",

8
docs/verify_openapi.py

@ -878,5 +878,13 @@ data = json.dumps(d)
assert '"" : "#/components/schemas/' not in data, "Found empty ref key" assert '"" : "#/components/schemas/' not in data, "Found empty ref key"
print("No empty ref keys found") 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("Valid JSON: True")
print("All checks passed!") print("All checks passed!")

13
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.job.dto.JobUpdateRequest;
import com.mydreamjob.security.CurrentUserAccessor; import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation; 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.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag; 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") @GetMapping("/search")
public ResponseEntity<ApiResponse<Map<String, Object>>> searchJobs( public ResponseEntity<ApiResponse<Map<String, Object>>> searchJobs(
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@ -159,13 +160,19 @@ public class JobController {
@RequestParam(required = false) Integer salaryMin, @RequestParam(required = false) Integer salaryMin,
@RequestParam(required = false) Integer salaryMax, @RequestParam(required = false) Integer salaryMax,
@RequestParam(required = false) Long companyId, @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(required = false) String sort,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
HttpServletRequest request) { HttpServletRequest request) {
Page<JobSearchItem> result = jobService.searchJobs( Page<JobSearchItem> result = jobService.searchJobs(
keyword, city, employmentType, salaryMin, salaryMax, companyId, keyword, city, employmentType, salaryMin, salaryMax, companyId,
sort, page, size); sort, page, size, industry, experience, education);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("items", result.getContent()); data.put("items", result.getContent());
data.put("total", result.getTotalElements()); 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") @GetMapping("/filters")
public ResponseEntity<ApiResponse<Map<String, Object>>> getFilters( public ResponseEntity<ApiResponse<Map<String, Object>>> getFilters(
HttpServletRequest request) { HttpServletRequest request) {

27
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<String> 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<String>();
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);
}
}

13
mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java

@ -46,6 +46,19 @@ public interface JobRepository extends JpaRepository<Job, Long>, JpaSpecificatio
@Query("SELECT DISTINCT j.employmentType FROM Job j WHERE j.status = 'PUBLISHED' ORDER BY j.employmentType ASC") @Query("SELECT DISTINCT j.employmentType FROM Job j WHERE j.status = 'PUBLISHED' ORDER BY j.employmentType ASC")
List<String> findDistinctEmploymentTypesPublished(); List<String> 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<String> 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<String> 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<String> findDistinctEducationsPublished();
@Query("SELECT MIN(j.salaryMin) FROM Job j WHERE j.status = 'PUBLISHED'") @Query("SELECT MIN(j.salaryMin) FROM Job j WHERE j.status = 'PUBLISHED'")
Integer findMinSalaryPublished(); Integer findMinSalaryPublished();

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

@ -1,6 +1,7 @@
package com.mydreamjob.job; package com.mydreamjob.job;
import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Predicate;
import com.mydreamjob.company.Company;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import java.util.ArrayList; import java.util.ArrayList;
@ -41,6 +42,16 @@ public class JobSearchSpecification {
public static Specification<Job> searchJobs( public static Specification<Job> searchJobs(
String keyword, String city, String employmentType, String keyword, String city, String employmentType,
Integer salaryMin, Integer salaryMax, Long companyId) { Integer salaryMin, Integer salaryMax, Long companyId) {
return searchJobs(keyword, city, employmentType, salaryMin, salaryMax, companyId, null, null, null);
}
public static Specification<Job> 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) -> { return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>(); List<Predicate> 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.<String>get("industry"))).in(industries));
predicates.add(cb.exists(companyQuery));
}
if (!experiences.isEmpty()) {
predicates.add(cb.lower(cb.trim(root.<String>get("experience"))).in(experiences));
}
if (!educations.isEmpty()) {
predicates.add(cb.lower(cb.trim(root.<String>get("education"))).in(educations));
}
if (companyId != null) { if (companyId != null) {
predicates.add(cb.equal(root.get(COMPANY_ID), companyId)); predicates.add(cb.equal(root.get(COMPANY_ID), companyId));
} }

13
mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java

@ -383,6 +383,14 @@ public class JobService {
public Page<JobSearchItem> searchJobs(String keyword, String city, String employmentType, public Page<JobSearchItem> searchJobs(String keyword, String city, String employmentType,
Integer salaryMin, Integer salaryMax, Long companyId, Integer salaryMin, Integer salaryMax, Long companyId,
String sort, int page, int size) { String sort, int page, int size) {
return searchJobs(keyword, city, employmentType, salaryMin, salaryMax, companyId,
sort, page, size, null, null, null);
}
public Page<JobSearchItem> 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) { if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) {
throw new BusinessException(ErrorCode.BAD_REQUEST); throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -398,7 +406,7 @@ public class JobService {
} }
var spec = JobSearchSpecification.searchJobs(keyword, city, employmentType, var spec = JobSearchSpecification.searchJobs(keyword, city, employmentType,
salaryMin, salaryMax, companyId); salaryMin, salaryMax, companyId, industry, experience, education);
Page<Job> jobPage; Page<Job> jobPage;
if ("salary".equals(sort)) { if ("salary".equals(sort)) {
@ -520,6 +528,9 @@ public class JobService {
result.put("cities", cities != null ? cities : Collections.emptyList()); result.put("cities", cities != null ? cities : Collections.emptyList());
result.put("employmentTypes", employmentTypes != null ? employmentTypes : Collections.emptyList()); result.put("employmentTypes", employmentTypes != null ? employmentTypes : Collections.emptyList());
result.put("salaryRange", salaryRange); result.put("salaryRange", salaryRange);
result.put("industries", jobRepository.findDistinctIndustriesPublished());
result.put("experiences", jobRepository.findDistinctExperiencesPublished());
result.put("educations", jobRepository.findDistinctEducationsPublished());
return result; return result;
} }
} }

35
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);
}
}
}

6
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 { }

23
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");
}
}

150
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<String, Object> 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<String, String>();
values.put("city", city);
values.put("size", "50");
for (int i=0; i<params.length; i+=2) values.put(params[i], params[i+1]);
var req = get("/jobs/search");
values.forEach(req::param);
return mapper.readTree(mvc.perform(req).andExpect(status().isOk()).andReturn().getResponse().getContentAsString()).path("data");
}
@Test void dimensionsFilterIndependentlyAndUseCompanyIndustry() throws Exception {
assertThat(search("industry", "金融").path("total").asInt()).isEqualTo(2);
assertThat(search("experience", "3-5年").path("total").asInt()).isEqualTo(2);
assertThat(search("education", "硕士").path("total").asInt()).isEqualTo(1);
assertThat(search("industry", "金融", "experience", "3-5年", "education", "本科").path("items").get(0).path("id").asLong()).isEqualTo(a.getId());
assertThat(search("industry", "金融", "experience", "3-5年", "education", "硕士").path("total").asInt()).isZero();
}
@Test void multiSelectOrAcrossDimensionsAndDuplicateWhitespace() throws Exception {
assertThat(search("industry", " 金融,金融,互联网/科技 ", "experience", "1-3年, 3-5年", "education", "本科,硕士").path("total").asInt()).isEqualTo(3);
assertThat(search("industry", "金融,互联网/科技", "education", "硕士").path("total").asInt()).isEqualTo(1);
}
@Test void emptyUnknownNullAndSqlLikeValuesDoNotBroadenResults() throws Exception {
assertThat(search().path("total").asInt()).isEqualTo(5);
assertThat(search("industry", " ", "experience", "", "education", " ").path("total").asInt()).isEqualTo(5);
for (String field : new String[]{"industry", "experience", "education"}) {
assertThat(search(field, "不存在").path("total").asInt()).isZero();
assertThat(search(field, "%' OR 1=1 --").path("total").asInt()).isZero();
}
assertThat(search("experience", "3-5年").path("total").asInt()).isEqualTo(2);
}
@Test void paginationCountSalaryOverlapAndCompanyRemainCompatible() throws Exception {
var first=search("industry", "金融,互联网/科技", "sort", "salary", "size", "1", "page", "0");
var second=search("industry", "金融,互联网/科技", "sort", "salary", "size", "1", "page", "1");
assertThat(first.path("total").asInt()).isEqualTo(4);
assertThat(first.path("totalPages").asInt()).isEqualTo(4);
assertThat(first.path("items").get(0).path("id").asLong()).isEqualTo(c.getId());
assertThat(second.path("items").get(0).path("id").asLong()).isEqualTo(a.getId());
assertThat(search("industry", "金融", "salaryMin", "20000", "salaryMax", "30000", "employmentType", "FULL_TIME", "keyword", "java", "companyId", finance.toString()).path("total").asInt()).isEqualTo(2);
assertThat(search("industry", "互联网/科技", "companyId", finance.toString()).path("total").asInt()).isZero();
assertThat(search("industry", "金融", "page", "20").path("items").isEmpty()).isTrue();
}
@Test void malformedAndBoundedInputReturn400() throws Exception {
for (String field : new String[]{"industry", "experience", "education"}) {
for (String raw : new String[]{",", "本科,", "本科,,硕士", "x".repeat(81), "a\nb"}) {
mvc.perform(get("/jobs/search").param(field,raw)).andExpect(status().isBadRequest());
}
}
String twenty=java.util.stream.IntStream.range(0,20).mapToObj(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<java.util.List<String>>(){});
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<String>();
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");
}
}
Loading…
Cancel
Save