4 changed files with 678 additions and 0 deletions
@ -0,0 +1,152 @@ |
|||
package com.mydreamjob.skill; |
|||
|
|||
import com.fasterxml.jackson.core.type.TypeReference; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.profile.StudentProfile; |
|||
import com.mydreamjob.profile.StudentProfileRepository; |
|||
import com.mydreamjob.skill.domain.SkillAlias; |
|||
import com.mydreamjob.skill.dto.SkillCloudItem; |
|||
import com.mydreamjob.skill.dto.SkillCloudResponse; |
|||
import com.mydreamjob.skill.dto.SkillMatchResponse; |
|||
import com.mydreamjob.skill.repository.SkillAliasRepository; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.Collection; |
|||
import java.util.HashMap; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
|
|||
@Slf4j |
|||
@Service |
|||
@Transactional(readOnly = true) |
|||
public class SkillMatchService { |
|||
|
|||
private static final int TOP_LIMIT = 10; |
|||
|
|||
private final SkillCloudQueryService skillCloudQueryService; |
|||
private final StudentProfileRepository studentProfileRepository; |
|||
private final SkillAliasRepository skillAliasRepository; |
|||
private final SkillNameNormalizer skillNameNormalizer; |
|||
private final ObjectMapper objectMapper; |
|||
|
|||
public SkillMatchService(SkillCloudQueryService skillCloudQueryService, |
|||
StudentProfileRepository studentProfileRepository, |
|||
SkillAliasRepository skillAliasRepository, |
|||
SkillNameNormalizer skillNameNormalizer, |
|||
ObjectMapper objectMapper) { |
|||
this.skillCloudQueryService = skillCloudQueryService; |
|||
this.studentProfileRepository = studentProfileRepository; |
|||
this.skillAliasRepository = skillAliasRepository; |
|||
this.skillNameNormalizer = skillNameNormalizer; |
|||
this.objectMapper = objectMapper; |
|||
} |
|||
|
|||
public SkillMatchResponse match(Long studentUserId, |
|||
Long industryId, |
|||
Long occupationId, |
|||
Long categoryId) { |
|||
SkillCloudResponse cloud = skillCloudQueryService.query(industryId, occupationId, categoryId); |
|||
int actualTopSize = Math.min(TOP_LIMIT, cloud.items().size()); |
|||
List<SkillCloudItem> topSkills = cloud.items().subList(0, actualTopSize); |
|||
Set<String> studentSkills = loadStudentSkills(studentUserId); |
|||
Map<Long, List<SkillAlias>> aliasesBySkillId = loadAliases(topSkills); |
|||
|
|||
List<SkillCloudItem> matchedSkills = new ArrayList<>(); |
|||
List<SkillCloudItem> missingSkills = new ArrayList<>(); |
|||
for (SkillCloudItem skill : topSkills) { |
|||
if (matches(skill, studentSkills, aliasesBySkillId.getOrDefault(skill.skillId(), List.of()))) { |
|||
matchedSkills.add(skill); |
|||
} else { |
|||
missingSkills.add(skill); |
|||
} |
|||
} |
|||
|
|||
int matchScore = actualTopSize == 0 |
|||
? 0 |
|||
: (int) Math.round(matchedSkills.size() * 100.0 / actualTopSize); |
|||
return new SkillMatchResponse( |
|||
matchScore, |
|||
matchedSkills.size(), |
|||
actualTopSize, |
|||
matchedSkills, |
|||
missingSkills |
|||
); |
|||
} |
|||
|
|||
private Set<String> loadStudentSkills(Long studentUserId) { |
|||
if (studentUserId == null) { |
|||
return Set.of(); |
|||
} |
|||
|
|||
StudentProfile profile = studentProfileRepository.findByUserId(studentUserId).orElse(null); |
|||
if (profile == null || profile.getSkillsJson() == null || profile.getSkillsJson().isBlank()) { |
|||
return Set.of(); |
|||
} |
|||
|
|||
try { |
|||
List<String> parsed = objectMapper.readValue( |
|||
profile.getSkillsJson(), |
|||
new TypeReference<List<String>>() {} |
|||
); |
|||
if (parsed == null || parsed.isEmpty()) { |
|||
return Set.of(); |
|||
} |
|||
|
|||
Set<String> normalizedSkills = new HashSet<>(); |
|||
for (String skill : parsed) { |
|||
if (skill == null) { |
|||
continue; |
|||
} |
|||
String normalized = skillNameNormalizer.normalize(skill); |
|||
if (!normalized.isBlank()) { |
|||
normalizedSkills.add(normalized); |
|||
} |
|||
} |
|||
return Set.copyOf(normalizedSkills); |
|||
} catch (Exception exception) { |
|||
log.warn("Failed to parse student skills JSON: profileId={}, errorType={}", |
|||
profile.getId(), exception.getClass().getSimpleName()); |
|||
return Set.of(); |
|||
} |
|||
} |
|||
|
|||
private Map<Long, List<SkillAlias>> loadAliases(List<SkillCloudItem> topSkills) { |
|||
Collection<Long> skillIds = topSkills.stream() |
|||
.map(SkillCloudItem::skillId) |
|||
.filter(java.util.Objects::nonNull) |
|||
.toList(); |
|||
if (skillIds.isEmpty()) { |
|||
return Map.of(); |
|||
} |
|||
|
|||
Map<Long, List<SkillAlias>> aliasesBySkillId = new HashMap<>(); |
|||
for (SkillAlias alias : skillAliasRepository.findAllBySkillIdInOrderByIdAsc(skillIds)) { |
|||
aliasesBySkillId.computeIfAbsent(alias.getSkillId(), ignored -> new ArrayList<>()).add(alias); |
|||
} |
|||
return aliasesBySkillId; |
|||
} |
|||
|
|||
private boolean matches(SkillCloudItem skill, |
|||
Set<String> studentSkills, |
|||
List<SkillAlias> aliases) { |
|||
if (containsNormalized(studentSkills, skill.nameZh()) |
|||
|| containsNormalized(studentSkills, skill.nameEn()) |
|||
|| containsNormalized(studentSkills, skill.skillCode())) { |
|||
return true; |
|||
} |
|||
return aliases.stream().anyMatch(alias -> containsNormalized(studentSkills, alias.getAlias())); |
|||
} |
|||
|
|||
private boolean containsNormalized(Set<String> studentSkills, String candidate) { |
|||
if (candidate == null) { |
|||
return false; |
|||
} |
|||
String normalized = skillNameNormalizer.normalize(candidate); |
|||
return !normalized.isBlank() && studentSkills.contains(normalized); |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
package com.mydreamjob.skill; |
|||
|
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.text.Normalizer; |
|||
import java.util.Locale; |
|||
import java.util.regex.Pattern; |
|||
|
|||
@Component |
|||
public class SkillNameNormalizer { |
|||
|
|||
private static final Pattern UNICODE_HYPHENS = Pattern.compile("\\p{Pd}"); |
|||
private static final Pattern WHITESPACE = Pattern.compile("\\s+", Pattern.UNICODE_CHARACTER_CLASS); |
|||
|
|||
public String normalize(String value) { |
|||
if (value == null) { |
|||
return ""; |
|||
} |
|||
|
|||
String normalized = Normalizer.normalize(value, Normalizer.Form.NFKC) |
|||
.trim() |
|||
.toLowerCase(Locale.ROOT); |
|||
normalized = UNICODE_HYPHENS.matcher(normalized).replaceAll(" "); |
|||
normalized = normalized.replace(".", ""); |
|||
normalized = WHITESPACE.matcher(normalized).replaceAll(" "); |
|||
return normalized.trim(); |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
package com.mydreamjob.skill.dto; |
|||
|
|||
import java.util.List; |
|||
|
|||
public record SkillMatchResponse( |
|||
int matchScore, |
|||
int matchedCount, |
|||
int totalCount, |
|||
List<SkillCloudItem> matchedSkills, |
|||
List<SkillCloudItem> missingSkills |
|||
) { |
|||
public SkillMatchResponse { |
|||
matchedSkills = List.copyOf(matchedSkills); |
|||
missingSkills = List.copyOf(missingSkills); |
|||
} |
|||
} |
|||
@ -0,0 +1,482 @@ |
|||
package com.mydreamjob.skill; |
|||
|
|||
import com.mydreamjob.profile.StudentProfile; |
|||
import com.mydreamjob.profile.StudentProfileRepository; |
|||
import com.mydreamjob.skill.domain.SkillAlias; |
|||
import com.mydreamjob.skill.dto.SkillCloudItem; |
|||
import com.mydreamjob.skill.dto.SkillCloudNode; |
|||
import com.mydreamjob.skill.dto.SkillCloudResponse; |
|||
import com.mydreamjob.skill.dto.SkillMatchResponse; |
|||
import com.mydreamjob.skill.repository.SkillAliasRepository; |
|||
import org.hibernate.boot.model.relational.Namespace; |
|||
import org.hibernate.boot.model.relational.Sequence; |
|||
import org.hibernate.mapping.Table; |
|||
import org.hibernate.tool.schema.spi.SchemaFilter; |
|||
import org.hibernate.tool.schema.spi.SchemaFilterProvider; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.boot.test.mock.mockito.SpyBean; |
|||
import org.springframework.boot.test.system.CapturedOutput; |
|||
import org.springframework.boot.test.system.OutputCaptureExtension; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.context.TestPropertySource; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.util.List; |
|||
import java.util.Locale; |
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
import java.util.stream.IntStream; |
|||
import java.util.stream.Stream; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyCollection; |
|||
import static org.mockito.Mockito.doReturn; |
|||
|
|||
@SpringBootTest |
|||
@ExtendWith(OutputCaptureExtension.class) |
|||
@TestPropertySource(properties = { |
|||
"spring.datasource.url=jdbc:h2:mem:integrationdb_skill_match;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", |
|||
"spring.jpa.hibernate.ddl-auto=validate", |
|||
"spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.skill.SkillMatchServiceTest$SkillMatchSchemaFilterProvider" |
|||
}) |
|||
@ActiveProfiles("integration-test") |
|||
@Transactional |
|||
class SkillMatchServiceTest { |
|||
|
|||
private static final Long USER_ID = 7001L; |
|||
|
|||
@Autowired |
|||
private SkillMatchService service; |
|||
|
|||
@SpyBean |
|||
private SkillCloudQueryService queryService; |
|||
|
|||
@SpyBean |
|||
private StudentProfileRepository profileRepository; |
|||
|
|||
@SpyBean |
|||
private SkillAliasRepository aliasRepository; |
|||
|
|||
@Autowired |
|||
private JdbcTemplate jdbcTemplate; |
|||
|
|||
@MockBean |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Test |
|||
void normalizerAppliesUnicodeRulesAndPreservesProgrammingSymbols() { |
|||
SkillNameNormalizer normalizer = new SkillNameNormalizer(); |
|||
|
|||
assertThat(normalizer.normalize(" Spring‐Boot. ")).isEqualTo("spring boot"); |
|||
assertThat(normalizer.normalize(" JAVA\n\tSCRIPT ")).isEqualTo("java script"); |
|||
assertThat(normalizer.normalize("Node.js")).isEqualTo("nodejs"); |
|||
assertThat(normalizer.normalize("C++")).isEqualTo("c++"); |
|||
assertThat(normalizer.normalize("C#")).isEqualTo("c#"); |
|||
} |
|||
|
|||
@Test |
|||
void usesOnlyTopTenWhenMoreThanTenSkillsExist() { |
|||
List<SkillCloudItem> items = IntStream.rangeClosed(1, 11) |
|||
.mapToObj(index -> item((long) index, "Skill " + index, "Skill " + index, "SKILL_" + index)) |
|||
.toList(); |
|||
stubCloud(cloud(items)); |
|||
saveProfile(USER_ID, json(items.stream().map(SkillCloudItem::nameZh).toList())); |
|||
|
|||
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); |
|||
|
|||
assertThat(result.totalCount()).isEqualTo(10); |
|||
assertThat(result.matchedCount()).isEqualTo(10); |
|||
assertThat(result.matchScore()).isEqualTo(100); |
|||
assertThat(result.matchedSkills()) |
|||
.extracting(SkillCloudItem::skillId) |
|||
.containsExactlyElementsOf(IntStream.rangeClosed(1, 10).mapToLong(Long::valueOf).boxed().toList()); |
|||
assertThat(result.missingSkills()).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
void usesTenAsDenominatorWhenExactlyTenSkillsExist() { |
|||
List<SkillCloudItem> items = IntStream.rangeClosed(1, 10) |
|||
.mapToObj(index -> item((long) index, "Skill " + index, "Skill " + index, "SKILL_" + index)) |
|||
.toList(); |
|||
stubCloud(cloud(items)); |
|||
saveProfile(USER_ID, json(items.stream().limit(5).map(SkillCloudItem::nameZh).toList())); |
|||
|
|||
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); |
|||
|
|||
assertThat(result.totalCount()).isEqualTo(10); |
|||
assertThat(result.matchedCount()).isEqualTo(5); |
|||
assertThat(result.matchScore()).isEqualTo(50); |
|||
assertThat(result.missingSkills()).hasSize(5); |
|||
} |
|||
|
|||
@Test |
|||
void usesActualSizeAsDenominatorAndRoundsOneThirdToThirtyThree() { |
|||
List<SkillCloudItem> items = List.of( |
|||
item(1L, "Java", "Java", "JAVA"), |
|||
item(2L, "Python", "Python", "PYTHON"), |
|||
item(3L, "SQL", "SQL", "SQL") |
|||
); |
|||
stubCloud(cloud(items)); |
|||
saveProfile(USER_ID, json(List.of("Java"))); |
|||
|
|||
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); |
|||
|
|||
assertThat(result.totalCount()).isEqualTo(3); |
|||
assertThat(result.matchedCount()).isEqualTo(1); |
|||
assertThat(result.matchScore()).isEqualTo(33); |
|||
assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L); |
|||
assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(2L, 3L); |
|||
} |
|||
|
|||
@Test |
|||
void returnsZeroAndEmptyListsWhenCloudIsEmpty() { |
|||
stubCloud(cloud(List.of())); |
|||
saveProfile(USER_ID, json(List.of("Java"))); |
|||
|
|||
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); |
|||
|
|||
assertThat(result.matchScore()).isZero(); |
|||
assertThat(result.matchedCount()).isZero(); |
|||
assertThat(result.totalCount()).isZero(); |
|||
assertThat(result.matchedSkills()).isEmpty(); |
|||
assertThat(result.missingSkills()).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
void treatsMissingProfileAsNoSkills() { |
|||
stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA")))); |
|||
|
|||
SkillMatchResponse result = service.match(9999L, 1L, 1L, null); |
|||
|
|||
assertThat(result.matchScore()).isZero(); |
|||
assertThat(result.matchedSkills()).isEmpty(); |
|||
assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L); |
|||
} |
|||
|
|||
@Test |
|||
void treatsBlankAndEmptyArrayAsNoSkills() { |
|||
stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA"), item(2L, "SQL", "SQL", "SQL")))); |
|||
doReturn(Optional.of(StudentProfile.builder() |
|||
.id(100L) |
|||
.userId(USER_ID) |
|||
.skillsJson(" ") |
|||
.build())) |
|||
.when(profileRepository).findByUserId(USER_ID); |
|||
|
|||
SkillMatchResponse blank = service.match(USER_ID, 1L, 1L, null); |
|||
|
|||
assertThat(blank.matchScore()).isZero(); |
|||
assertThat(blank.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L); |
|||
|
|||
doReturn(Optional.of(StudentProfile.builder() |
|||
.id(101L) |
|||
.userId(USER_ID) |
|||
.skillsJson("[]") |
|||
.build())) |
|||
.when(profileRepository).findByUserId(USER_ID); |
|||
|
|||
SkillMatchResponse empty = service.match(USER_ID, 1L, 1L, null); |
|||
|
|||
assertThat(empty.matchScore()).isZero(); |
|||
assertThat(empty.matchedSkills()).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
void treatsMalformedJsonAsNoSkillsWithoutLeakingRawJson(CapturedOutput output) { |
|||
String malformedJson = "{\"skills\":[\"Java\"]"; |
|||
stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA")))); |
|||
doReturn(Optional.of(StudentProfile.builder() |
|||
.id(102L) |
|||
.userId(USER_ID) |
|||
.skillsJson(malformedJson) |
|||
.build())) |
|||
.when(profileRepository).findByUserId(USER_ID); |
|||
|
|||
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); |
|||
|
|||
assertThat(result.matchScore()).isZero(); |
|||
assertThat(result.matchedSkills()).isEmpty(); |
|||
assertThat(output.getAll()).contains("Failed to parse student skills JSON"); |
|||
assertThat(output.getAll()).doesNotContain(malformedJson); |
|||
} |
|||
|
|||
@Test |
|||
void matchesNameZhNameEnSkillCodeAndAliasAndDeduplicatesStudentSkills() { |
|||
List<SkillCloudItem> items = List.of( |
|||
item(1L, "数据库", "Database Systems", "DATA_STORE_CLASS"), |
|||
item(2L, "中文技能", "database technology", "UNRELATED_CODE"), |
|||
item(3L, "代码技能", "Code English", "DATABASE_ENGINE") |
|||
); |
|||
stubCloud(cloud(items)); |
|||
doReturn(List.of(alias(1L, "JVM Language", "jvm language"))) |
|||
.when(aliasRepository).findAllBySkillIdInOrderByIdAsc(anyCollection()); |
|||
saveProfile(USER_ID, json(List.of( |
|||
"jvm-language", |
|||
"JVM LANGUAGE", |
|||
"数据库", |
|||
"DATABASE TECHNOLOGY", |
|||
"database_engine" |
|||
))); |
|||
|
|||
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); |
|||
|
|||
assertThat(result.matchScore()).isEqualTo(100); |
|||
assertThat(result.matchedCount()).isEqualTo(3); |
|||
assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L, 3L); |
|||
assertThat(result.missingSkills()).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
void matchesOnlyNameZhForV36JavaWhenOtherIdentityFieldsDoNotMatch() { |
|||
jdbcTemplate.update("UPDATE skills SET code = ? WHERE id = ?", "JAVA_NAME_ZH_ISOLATED", 1L); |
|||
saveProfile(USER_ID, json(List.of("Java"))); |
|||
|
|||
SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L); |
|||
|
|||
assertThat(result.totalCount()).isEqualTo(2); |
|||
assertThat(result.matchScore()).isEqualTo(50); |
|||
assertThat(result.matchedSkills()) |
|||
.extracting(SkillCloudItem::skillId) |
|||
.containsExactly(1L); |
|||
assertThat(result.missingSkills()) |
|||
.extracting(SkillCloudItem::skillId) |
|||
.containsExactly(2L); |
|||
} |
|||
|
|||
@Test |
|||
void matchesOnlyInsertedAliasForV36JavaWhenAliasIsTheOnlyIdentityMatch() { |
|||
jdbcTemplate.update(""" |
|||
INSERT INTO skill_aliases (id, skill_id, alias, normalized_alias) |
|||
VALUES (?, ?, ?, ?) |
|||
""", 10001L, 1L, "ONLY.ALIAS-JVM-XYZ", "only alias jvm xyz"); |
|||
saveProfile(USER_ID, json(List.of("ONLY.ALIAS-JVM-XYZ"))); |
|||
|
|||
SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L); |
|||
|
|||
assertThat(result.totalCount()).isEqualTo(2); |
|||
assertThat(result.matchScore()).isEqualTo(50); |
|||
assertThat(result.matchedSkills()) |
|||
.extracting(SkillCloudItem::skillId) |
|||
.containsExactly(1L); |
|||
assertThat(result.missingSkills()) |
|||
.extracting(SkillCloudItem::skillId) |
|||
.containsExactly(2L); |
|||
} |
|||
|
|||
@Test |
|||
void doesNotApplyAliasOwnedByAnotherTopSkillToV36Java() { |
|||
jdbcTemplate.update(""" |
|||
INSERT INTO skill_aliases (id, skill_id, alias, normalized_alias) |
|||
VALUES (?, ?, ?, ?) |
|||
""", 10002L, 2L, "ONLY.ALIAS-OWNER-SKILL-2", "only alias owner skill 2"); |
|||
saveProfile(USER_ID, json(List.of("ONLY.ALIAS-OWNER-SKILL-2"))); |
|||
|
|||
SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L); |
|||
|
|||
assertThat(result.totalCount()).isEqualTo(2); |
|||
assertThat(result.matchScore()).isEqualTo(50); |
|||
assertThat(result.matchedSkills()) |
|||
.extracting(SkillCloudItem::skillId) |
|||
.containsExactly(2L); |
|||
assertThat(result.missingSkills()) |
|||
.extracting(SkillCloudItem::skillId) |
|||
.containsExactly(1L); |
|||
} |
|||
|
|||
@Test |
|||
void keepsMatchedAndMissingListsOrderedAndTheirUnionEqualToTopTen() { |
|||
List<SkillCloudItem> items = List.of( |
|||
item(1L, "Java", "Java", "JAVA"), |
|||
item(2L, "Python", "Python", "PYTHON"), |
|||
item(3L, "SQL", "SQL", "SQL") |
|||
); |
|||
stubCloud(cloud(items)); |
|||
saveProfile(USER_ID, json(List.of("Python"))); |
|||
|
|||
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); |
|||
List<Long> union = Stream.concat(result.matchedSkills().stream(), result.missingSkills().stream()) |
|||
.map(SkillCloudItem::skillId) |
|||
.toList(); |
|||
|
|||
assertThat(result.matchScore()).isEqualTo(33); |
|||
assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(2L); |
|||
assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 3L); |
|||
assertThat(union).containsExactlyInAnyOrder(1L, 2L, 3L); |
|||
} |
|||
|
|||
@Test |
|||
void returnsZeroWhenNoTopSkillMatches() { |
|||
List<SkillCloudItem> items = List.of( |
|||
item(1L, "Java", "Java", "JAVA"), |
|||
item(2L, "SQL", "SQL", "SQL") |
|||
); |
|||
stubCloud(cloud(items)); |
|||
saveProfile(USER_ID, json(List.of("Marketing"))); |
|||
|
|||
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); |
|||
|
|||
assertThat(result.matchScore()).isZero(); |
|||
assertThat(result.matchedSkills()).isEmpty(); |
|||
assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L); |
|||
} |
|||
|
|||
@Test |
|||
void returnsOneHundredAndNoMissingSkillsWhenAllTopSkillsMatch() { |
|||
List<SkillCloudItem> items = List.of( |
|||
item(1L, "Java", "Java", "JAVA"), |
|||
item(2L, "SQL", "SQL", "SQL") |
|||
); |
|||
stubCloud(cloud(items)); |
|||
saveProfile(USER_ID, json(List.of("Java", "SQL"))); |
|||
|
|||
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); |
|||
|
|||
assertThat(result.matchScore()).isEqualTo(100); |
|||
assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L); |
|||
assertThat(result.missingSkills()).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
void excludesCrossOccupationCategoryMismatchFromMatchSet() { |
|||
jdbcTemplate.update(""" |
|||
INSERT INTO occupation_skills ( |
|||
id, occupation_id, skill_id, category_id, importance, |
|||
importance_source_code, importance_element_id, importance_is_proxy, |
|||
relationship_type, source_version_id |
|||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
|||
""", |
|||
10000L, 1L, 1L, 201L, new BigDecimal("5.00"), |
|||
"15-1254.00", "2.E.7.h", true, "ESSENTIAL", 2L); |
|||
saveProfile(USER_ID, json(List.of("Java"))); |
|||
|
|||
SkillCloudResponse cloud = queryService.query(1L, 1L, null); |
|||
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); |
|||
List<Long> expectedTop = cloud.items().stream().limit(10).map(SkillCloudItem::skillId).toList(); |
|||
List<Long> actualUnion = Stream.concat(result.matchedSkills().stream(), result.missingSkills().stream()) |
|||
.map(SkillCloudItem::skillId) |
|||
.toList(); |
|||
|
|||
assertThat(result.matchedCount()).isZero(); |
|||
assertThat(actualUnion).containsExactlyElementsOf(expectedTop); |
|||
assertThat(actualUnion).doesNotContain(1L); |
|||
} |
|||
|
|||
private void stubCloud(SkillCloudResponse response) { |
|||
doReturn(response).when(queryService).query(any(), any(), any()); |
|||
} |
|||
|
|||
private StudentProfile saveProfile(Long userId, String skillsJson) { |
|||
jdbcTemplate.update(""" |
|||
INSERT INTO users (id, phone, role, is_active, is_verified, created_at, updated_at) |
|||
VALUES (?, ?, 'STUDENT', TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) |
|||
""", userId, String.valueOf(userId)); |
|||
return profileRepository.saveAndFlush(StudentProfile.builder() |
|||
.userId(userId) |
|||
.skillsJson(skillsJson) |
|||
.build()); |
|||
} |
|||
|
|||
private SkillCloudResponse cloud(List<SkillCloudItem> items) { |
|||
return new SkillCloudResponse( |
|||
new SkillCloudNode(1L, "INTERNET", "互联网"), |
|||
new SkillCloudNode(1L, "FRONTEND_DEVELOPER", "前端开发工程师"), |
|||
null, |
|||
items.size(), |
|||
items |
|||
); |
|||
} |
|||
|
|||
private SkillCloudItem item(Long id, String nameZh, String nameEn, String code) { |
|||
return new SkillCloudItem( |
|||
id.intValue(), |
|||
id, |
|||
code, |
|||
nameZh, |
|||
nameEn, |
|||
"O*NET", |
|||
"test-source", |
|||
1L, |
|||
"测试分类", |
|||
BigDecimal.TEN, |
|||
null, |
|||
null, |
|||
false |
|||
); |
|||
} |
|||
|
|||
private SkillAlias alias(Long skillId, String alias, String normalizedAlias) { |
|||
return SkillAlias.builder() |
|||
.id(1L) |
|||
.skillId(skillId) |
|||
.alias(alias) |
|||
.normalizedAlias(normalizedAlias) |
|||
.build(); |
|||
} |
|||
|
|||
private String json(List<String> values) { |
|||
return values.stream() |
|||
.map(value -> "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"") |
|||
.collect(java.util.stream.Collectors.joining(",", "[", "]")); |
|||
} |
|||
|
|||
public static class SkillMatchSchemaFilterProvider implements SchemaFilterProvider { |
|||
|
|||
private static final Set<String> VALIDATED_TABLES = Set.of( |
|||
"skill_source_versions", |
|||
"skill_industries", |
|||
"skill_occupations", |
|||
"skill_categories", |
|||
"skills", |
|||
"skill_aliases", |
|||
"occupation_skills" |
|||
); |
|||
|
|||
@Override |
|||
public SchemaFilter getCreateFilter() { |
|||
return SchemaFilter.ALL; |
|||
} |
|||
|
|||
@Override |
|||
public SchemaFilter getDropFilter() { |
|||
return SchemaFilter.ALL; |
|||
} |
|||
|
|||
@Override |
|||
public SchemaFilter getTruncatorFilter() { |
|||
return SchemaFilter.ALL; |
|||
} |
|||
|
|||
@Override |
|||
public SchemaFilter getMigrateFilter() { |
|||
return SchemaFilter.ALL; |
|||
} |
|||
|
|||
@Override |
|||
public SchemaFilter getValidateFilter() { |
|||
return new SchemaFilter() { |
|||
@Override |
|||
public boolean includeNamespace(Namespace namespace) { |
|||
return true; |
|||
} |
|||
|
|||
@Override |
|||
public boolean includeTable(Table table) { |
|||
return VALIDATED_TABLES.contains(table.getName().toLowerCase(Locale.ROOT)); |
|||
} |
|||
|
|||
@Override |
|||
public boolean includeSequence(Sequence sequence) { |
|||
return false; |
|||
} |
|||
}; |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue