forked from baskbuddy/zhangshun
Browse Source
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
11 changed files with 339 additions and 6 deletions
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,6 @@ |
|||||
|
package com.mydreamjob.job; |
||||
|
|
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
|
||||
|
@ActiveProfiles("integration-test") |
||||
|
class JobMultiFilterHttpIntegrationTest extends JobMultiFilterTestBase { } |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -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…
Reference in new issue