4 changed files with 489 additions and 0 deletions
@ -0,0 +1,95 @@ |
|||
package com.mydreamjob.skill; |
|||
|
|||
import com.mydreamjob.skill.domain.SkillCategory; |
|||
import com.mydreamjob.skill.dto.SkillCloudItem; |
|||
import com.mydreamjob.skill.dto.SkillLearningPathItem; |
|||
import com.mydreamjob.skill.dto.SkillLearningPathResponse; |
|||
import com.mydreamjob.skill.dto.SkillMatchResponse; |
|||
import com.mydreamjob.skill.repository.SkillCategoryRepository; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.util.List; |
|||
import java.util.Locale; |
|||
import java.util.stream.IntStream; |
|||
|
|||
@Service |
|||
@Transactional(readOnly = true) |
|||
public class SkillLearningPathService { |
|||
|
|||
private final SkillMatchService skillMatchService; |
|||
private final SkillCategoryRepository skillCategoryRepository; |
|||
|
|||
public SkillLearningPathService(SkillMatchService skillMatchService, |
|||
SkillCategoryRepository skillCategoryRepository) { |
|||
this.skillMatchService = skillMatchService; |
|||
this.skillCategoryRepository = skillCategoryRepository; |
|||
} |
|||
|
|||
public SkillLearningPathResponse generate(Long studentUserId, |
|||
Long industryId, |
|||
Long occupationId, |
|||
Long categoryId) { |
|||
SkillMatchResponse matchResponse = skillMatchService.match( |
|||
studentUserId, industryId, occupationId, categoryId); |
|||
List<SkillCloudItem> missingSkills = matchResponse.missingSkills(); |
|||
List<SkillLearningPathItem> items = IntStream.range(0, missingSkills.size()) |
|||
.mapToObj(index -> toItem(missingSkills.get(index), index + 1, occupationId)) |
|||
.toList(); |
|||
|
|||
return new SkillLearningPathResponse(items.size(), items); |
|||
} |
|||
|
|||
private SkillLearningPathItem toItem(SkillCloudItem skill, int priority, Long occupationId) { |
|||
SuggestionTemplate template = resolveTemplate(skill, occupationId); |
|||
String skillName = skill.nameZh(); |
|||
return new SkillLearningPathItem( |
|||
priority, |
|||
skill.skillId(), |
|||
skillName, |
|||
skill.categoryId(), |
|||
skill.categoryName(), |
|||
template.render(skillName) |
|||
); |
|||
} |
|||
|
|||
private SuggestionTemplate resolveTemplate(SkillCloudItem skill, Long occupationId) { |
|||
if (skill.categoryId() == null) { |
|||
return SuggestionTemplate.GENERAL; |
|||
} |
|||
|
|||
String templateCode = skillCategoryRepository |
|||
.findByIdAndOccupationId(skill.categoryId(), occupationId) |
|||
.map(SkillCategory::getSuggestionTemplateCode) |
|||
.orElse(null); |
|||
return SuggestionTemplate.fromCode(templateCode); |
|||
} |
|||
|
|||
private enum SuggestionTemplate { |
|||
PROGRAMMING_LANGUAGE("先掌握{skill}的基础语法和常见数据结构,再完成至少一个编码练习。"), |
|||
FRAMEWORK("先理解{skill}的核心概念与生命周期,再通过一个小型项目练习集成。"), |
|||
TOOL("先完成{skill}的安装与基础配置,再结合现有工程完成一次实际操作。"), |
|||
GENERAL("先了解{skill}的核心概念和常见应用场景,再通过一个简短实践巩固。"); |
|||
|
|||
private final String template; |
|||
|
|||
SuggestionTemplate(String template) { |
|||
this.template = template; |
|||
} |
|||
|
|||
String render(String skillName) { |
|||
return template.replace("{skill}", skillName); |
|||
} |
|||
|
|||
static SuggestionTemplate fromCode(String code) { |
|||
if (code == null || code.isBlank()) { |
|||
return GENERAL; |
|||
} |
|||
try { |
|||
return valueOf(code.trim().toUpperCase(Locale.ROOT)); |
|||
} catch (IllegalArgumentException exception) { |
|||
return GENERAL; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package com.mydreamjob.skill.dto; |
|||
|
|||
public record SkillLearningPathItem( |
|||
int priority, |
|||
Long skillId, |
|||
String skillName, |
|||
Long categoryId, |
|||
String categoryName, |
|||
String suggestion |
|||
) { |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.mydreamjob.skill.dto; |
|||
|
|||
import java.util.List; |
|||
|
|||
public record SkillLearningPathResponse( |
|||
int total, |
|||
List<SkillLearningPathItem> items |
|||
) { |
|||
public SkillLearningPathResponse { |
|||
items = List.copyOf(items); |
|||
} |
|||
} |
|||
@ -0,0 +1,371 @@ |
|||
package com.mydreamjob.skill; |
|||
|
|||
import com.mydreamjob.skill.domain.SkillCategory; |
|||
import com.mydreamjob.skill.dto.SkillCloudItem; |
|||
import com.mydreamjob.skill.dto.SkillLearningPathItem; |
|||
import com.mydreamjob.skill.dto.SkillLearningPathResponse; |
|||
import com.mydreamjob.skill.dto.SkillMatchResponse; |
|||
import com.mydreamjob.skill.repository.SkillCategoryRepository; |
|||
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.Nested; |
|||
import org.junit.jupiter.api.Test; |
|||
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.data.redis.core.RedisTemplate; |
|||
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 static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.verifyNoInteractions; |
|||
import static org.mockito.Mockito.verifyNoMoreInteractions; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@SpringBootTest |
|||
@TestPropertySource(properties = { |
|||
"spring.datasource.url=jdbc:h2:mem:integrationdb_skill_learning_path;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.SkillLearningPathServiceTest$SkillLearningPathSchemaFilterProvider" |
|||
}) |
|||
@ActiveProfiles("integration-test") |
|||
@Transactional |
|||
class SkillLearningPathServiceTest { |
|||
|
|||
private static final Long USER_ID = 7001L; |
|||
private static final Long INDUSTRY_ID = 1L; |
|||
private static final Long OCCUPATION_ID = 1L; |
|||
private static final Long CATEGORY_ID = 101L; |
|||
|
|||
@Autowired |
|||
private SkillLearningPathService learningPathService; |
|||
|
|||
@Autowired |
|||
private SkillMatchService matchService; |
|||
|
|||
@MockBean |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Test |
|||
void v36LearningPathReusesMatchMissingSkillsWithoutReordering() { |
|||
SkillMatchResponse matchResponse = matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); |
|||
SkillLearningPathResponse response = learningPathService.generate( |
|||
USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); |
|||
|
|||
assertThat(matchResponse.missingSkills()).hasSize(10); |
|||
assertThat(response.total()).isEqualTo(matchResponse.missingSkills().size()); |
|||
assertThat(response.items()) |
|||
.extracting(SkillLearningPathItem::skillId) |
|||
.containsExactlyElementsOf(matchResponse.missingSkills().stream() |
|||
.map(SkillCloudItem::skillId) |
|||
.toList()); |
|||
assertThat(response.items()) |
|||
.extracting(SkillLearningPathItem::priority) |
|||
.containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); |
|||
assertThat(response.items()).allSatisfy(item -> { |
|||
assertThat(item.skillName()).isNotBlank(); |
|||
assertThat(item.categoryId()).isNotNull(); |
|||
assertThat(item.categoryName()).isNotBlank(); |
|||
assertThat(item.suggestion()).contains(item.skillName()); |
|||
assertNoForbiddenContent(item.suggestion()); |
|||
}); |
|||
} |
|||
|
|||
@Nested |
|||
class TemplateAndOrderingTests { |
|||
|
|||
private final SkillMatchService matchService = mock(SkillMatchService.class); |
|||
private final SkillCategoryRepository categoryRepository = mock(SkillCategoryRepository.class); |
|||
private final SkillLearningPathService service = |
|||
new SkillLearningPathService(matchService, categoryRepository); |
|||
|
|||
@Test |
|||
void usesOnlyMissingSkillsAndPreservesMissingOrderWithContiguousPriorities() { |
|||
SkillCloudItem matched = skill(11L, "Matched", "Matched", CATEGORY_ID, |
|||
"编程语言", new BigDecimal("9.00")); |
|||
List<SkillCloudItem> missing = List.of( |
|||
skill(31L, "First", "First", 101L, "编程语言", new BigDecimal("1.00")), |
|||
skill(32L, "Second", "Second", 102L, "框架", new BigDecimal("10.00")), |
|||
skill(33L, "Third", "Third", 103L, "工具", new BigDecimal("5.00")) |
|||
); |
|||
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) |
|||
.thenReturn(matchResponse(List.of(matched), missing)); |
|||
stubCategory(101L, "PROGRAMMING_LANGUAGE"); |
|||
stubCategory(102L, "FRAMEWORK"); |
|||
stubCategory(103L, "TOOL"); |
|||
|
|||
SkillLearningPathResponse response = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); |
|||
|
|||
assertThat(response.total()).isEqualTo(3); |
|||
assertThat(response.items()) |
|||
.extracting(SkillLearningPathItem::skillId) |
|||
.containsExactly(31L, 32L, 33L); |
|||
assertThat(response.items()) |
|||
.extracting(SkillLearningPathItem::priority) |
|||
.containsExactly(1, 2, 3); |
|||
assertThat(response.items()) |
|||
.extracting(SkillLearningPathItem::skillName) |
|||
.containsExactly("First", "Second", "Third"); |
|||
assertThat(response.items()) |
|||
.noneMatch(item -> item.skillId().equals(matched.skillId())); |
|||
verify(matchService).match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); |
|||
verifyNoMoreInteractions(matchService); |
|||
verify(categoryRepository).findByIdAndOccupationId(101L, OCCUPATION_ID); |
|||
verify(categoryRepository).findByIdAndOccupationId(102L, OCCUPATION_ID); |
|||
verify(categoryRepository).findByIdAndOccupationId(103L, OCCUPATION_ID); |
|||
verifyNoMoreInteractions(categoryRepository); |
|||
} |
|||
|
|||
@Test |
|||
void generatesProgrammingLanguageSuggestion() { |
|||
SkillCloudItem missing = skill(41L, "Java", "Java", CATEGORY_ID, |
|||
"编程语言", BigDecimal.ONE); |
|||
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) |
|||
.thenReturn(matchResponse(List.of(), List.of(missing))); |
|||
stubCategory(CATEGORY_ID, "PROGRAMMING_LANGUAGE"); |
|||
|
|||
SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null) |
|||
.items().get(0); |
|||
|
|||
assertThat(item.suggestion()).isEqualTo( |
|||
"先掌握Java的基础语法和常见数据结构,再完成至少一个编码练习。"); |
|||
assertNoForbiddenContent(item.suggestion()); |
|||
} |
|||
|
|||
@Test |
|||
void generatesFrameworkSuggestion() { |
|||
SkillCloudItem missing = skill(42L, "Spring Boot", "Spring Boot", CATEGORY_ID, |
|||
"框架", BigDecimal.ONE); |
|||
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) |
|||
.thenReturn(matchResponse(List.of(), List.of(missing))); |
|||
stubCategory(CATEGORY_ID, "FRAMEWORK"); |
|||
|
|||
SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null) |
|||
.items().get(0); |
|||
|
|||
assertThat(item.suggestion()).isEqualTo( |
|||
"先理解Spring Boot的核心概念与生命周期,再通过一个小型项目练习集成。"); |
|||
assertNoForbiddenContent(item.suggestion()); |
|||
} |
|||
|
|||
@Test |
|||
void generatesToolSuggestion() { |
|||
SkillCloudItem missing = skill(43L, "Docker", "Docker", CATEGORY_ID, |
|||
"工具", BigDecimal.ONE); |
|||
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) |
|||
.thenReturn(matchResponse(List.of(), List.of(missing))); |
|||
stubCategory(CATEGORY_ID, "TOOL"); |
|||
|
|||
SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null) |
|||
.items().get(0); |
|||
|
|||
assertThat(item.suggestion()).isEqualTo( |
|||
"先完成Docker的安装与基础配置,再结合现有工程完成一次实际操作。"); |
|||
assertNoForbiddenContent(item.suggestion()); |
|||
} |
|||
|
|||
@Test |
|||
void generatesGeneralSuggestion() { |
|||
SkillCloudItem missing = skill(44L, "沟通能力", "Communication", CATEGORY_ID, |
|||
"通用能力", BigDecimal.ONE); |
|||
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) |
|||
.thenReturn(matchResponse(List.of(), List.of(missing))); |
|||
stubCategory(CATEGORY_ID, "GENERAL"); |
|||
|
|||
SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null) |
|||
.items().get(0); |
|||
|
|||
assertThat(item.suggestion()).isEqualTo( |
|||
"先了解沟通能力的核心概念和常见应用场景,再通过一个简短实践巩固。"); |
|||
assertNoForbiddenContent(item.suggestion()); |
|||
} |
|||
|
|||
@Test |
|||
void fallsBackToGeneralForUnknownNullAndMissingTemplateCodes() { |
|||
List<SkillCloudItem> missing = List.of( |
|||
skill(51L, "Unknown", "Unknown", 101L, "分类 A", BigDecimal.ONE), |
|||
skill(52L, "NullCode", "NullCode", 102L, "分类 B", BigDecimal.ONE), |
|||
skill(53L, "MissingCategory", "MissingCategory", 103L, "分类 C", BigDecimal.ONE) |
|||
); |
|||
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) |
|||
.thenReturn(matchResponse(List.of(), missing)); |
|||
when(categoryRepository.findByIdAndOccupationId(101L, OCCUPATION_ID)) |
|||
.thenReturn(Optional.of(category(101L, "UNKNOWN_TEMPLATE"))); |
|||
when(categoryRepository.findByIdAndOccupationId(102L, OCCUPATION_ID)) |
|||
.thenReturn(Optional.of(category(102L, null))); |
|||
when(categoryRepository.findByIdAndOccupationId(103L, OCCUPATION_ID)) |
|||
.thenReturn(Optional.empty()); |
|||
|
|||
SkillLearningPathResponse response = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); |
|||
|
|||
assertThat(response.items()) |
|||
.extracting(SkillLearningPathItem::suggestion) |
|||
.containsExactly( |
|||
"先了解Unknown的核心概念和常见应用场景,再通过一个简短实践巩固。", |
|||
"先了解NullCode的核心概念和常见应用场景,再通过一个简短实践巩固。", |
|||
"先了解MissingCategory的核心概念和常见应用场景,再通过一个简短实践巩固。" |
|||
); |
|||
response.items().forEach(item -> assertNoForbiddenContent(item.suggestion())); |
|||
} |
|||
|
|||
@Test |
|||
void returnsEmptyPathAndDoesNotReadCategoriesWhenThereAreNoGaps() { |
|||
SkillCloudItem matched = skill(61L, "Java", "Java", CATEGORY_ID, |
|||
"编程语言", BigDecimal.ONE); |
|||
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) |
|||
.thenReturn(matchResponse(List.of(matched), List.of())); |
|||
|
|||
SkillLearningPathResponse response = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); |
|||
|
|||
assertThat(response.total()).isZero(); |
|||
assertThat(response.items()).isEmpty(); |
|||
verifyNoInteractions(categoryRepository); |
|||
} |
|||
|
|||
@Test |
|||
void repeatedCallsProduceIdenticalOrderPrioritiesAndText() { |
|||
List<SkillCloudItem> missing = List.of( |
|||
skill(71L, "Java", "Java", 101L, "编程语言", BigDecimal.ONE), |
|||
skill(72L, "Docker", "Docker", 102L, "工具", BigDecimal.ONE) |
|||
); |
|||
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) |
|||
.thenReturn(matchResponse(List.of(), missing)); |
|||
stubCategory(101L, "PROGRAMMING_LANGUAGE"); |
|||
stubCategory(102L, "TOOL"); |
|||
|
|||
SkillLearningPathResponse first = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); |
|||
SkillLearningPathResponse second = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); |
|||
|
|||
assertThat(second).isEqualTo(first); |
|||
assertThat(second.items()) |
|||
.extracting(SkillLearningPathItem::skillId) |
|||
.containsExactly(71L, 72L); |
|||
assertThat(second.items()) |
|||
.extracting(SkillLearningPathItem::priority) |
|||
.containsExactly(1, 2); |
|||
assertThat(second.items()) |
|||
.extracting(SkillLearningPathItem::suggestion) |
|||
.containsExactly( |
|||
"先掌握Java的基础语法和常见数据结构,再完成至少一个编码练习。", |
|||
"先完成Docker的安装与基础配置,再结合现有工程完成一次实际操作。" |
|||
); |
|||
} |
|||
|
|||
private void stubCategory(Long categoryId, String templateCode) { |
|||
when(categoryRepository.findByIdAndOccupationId(categoryId, OCCUPATION_ID)) |
|||
.thenReturn(Optional.of(category(categoryId, templateCode))); |
|||
} |
|||
} |
|||
|
|||
private static SkillMatchResponse matchResponse(List<SkillCloudItem> matched, |
|||
List<SkillCloudItem> missing) { |
|||
int total = matched.size() + missing.size(); |
|||
int score = total == 0 ? 0 : (int) Math.round(matched.size() * 100.0 / total); |
|||
return new SkillMatchResponse(score, matched.size(), total, matched, missing); |
|||
} |
|||
|
|||
private static SkillCloudItem skill(Long skillId, |
|||
String nameZh, |
|||
String nameEn, |
|||
Long categoryId, |
|||
String categoryName, |
|||
BigDecimal heat) { |
|||
return new SkillCloudItem( |
|||
skillId.intValue(), |
|||
skillId, |
|||
"SKILL_" + skillId, |
|||
nameZh, |
|||
nameEn, |
|||
"ESCO", |
|||
"source-" + skillId, |
|||
categoryId, |
|||
categoryName, |
|||
heat, |
|||
"15-1254.00", |
|||
"2.E.7.h", |
|||
false |
|||
); |
|||
} |
|||
|
|||
private static SkillCategory category(Long categoryId, String templateCode) { |
|||
return SkillCategory.builder() |
|||
.id(categoryId) |
|||
.occupationId(OCCUPATION_ID) |
|||
.code("CATEGORY_" + categoryId) |
|||
.nameZh("分类 " + categoryId) |
|||
.suggestionTemplateCode(templateCode) |
|||
.sortOrder(categoryId.intValue()) |
|||
.build(); |
|||
} |
|||
|
|||
private static void assertNoForbiddenContent(String suggestion) { |
|||
assertThat(suggestion) |
|||
.doesNotContain("http://", "https://", "课程", "讲师", "价格", "时长", |
|||
"分钟", "小时", "完成度", "AI"); |
|||
} |
|||
|
|||
public static class SkillLearningPathSchemaFilterProvider 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