diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillLearningPathService.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillLearningPathService.java new file mode 100644 index 0000000..bac87b6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillLearningPathService.java @@ -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 missingSkills = matchResponse.missingSkills(); + List 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; + } + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathItem.java new file mode 100644 index 0000000..28b6935 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathItem.java @@ -0,0 +1,11 @@ +package com.mydreamjob.skill.dto; + +public record SkillLearningPathItem( + int priority, + Long skillId, + String skillName, + Long categoryId, + String categoryName, + String suggestion +) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathResponse.java new file mode 100644 index 0000000..11ce18c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathResponse.java @@ -0,0 +1,12 @@ +package com.mydreamjob.skill.dto; + +import java.util.List; + +public record SkillLearningPathResponse( + int total, + List items +) { + public SkillLearningPathResponse { + items = List.copyOf(items); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillLearningPathServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillLearningPathServiceTest.java new file mode 100644 index 0000000..c66c110 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillLearningPathServiceTest.java @@ -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 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 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 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 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 matched, + List 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 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; + } + }; + } + } +}