Browse Source

feat: add skill learning path service

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
fca1160c8c
  1. 95
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillLearningPathService.java
  2. 11
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathItem.java
  3. 12
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathResponse.java
  4. 371
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillLearningPathServiceTest.java

95
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<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;
}
}
}
}

11
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
) {
}

12
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<SkillLearningPathItem> items
) {
public SkillLearningPathResponse {
items = List.copyOf(items);
}
}

371
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<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…
Cancel
Save