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 topSkills = cloud.items().subList(0, actualTopSize); Set studentSkills = loadStudentSkills(studentUserId); Map> aliasesBySkillId = loadAliases(topSkills); List matchedSkills = new ArrayList<>(); List 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 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 parsed = objectMapper.readValue( profile.getSkillsJson(), new TypeReference>() {} ); if (parsed == null || parsed.isEmpty()) { return Set.of(); } Set 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> loadAliases(List topSkills) { Collection skillIds = topSkills.stream() .map(SkillCloudItem::skillId) .filter(java.util.Objects::nonNull) .toList(); if (skillIds.isEmpty()) { return Map.of(); } Map> 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 studentSkills, List 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 studentSkills, String candidate) { if (candidate == null) { return false; } String normalized = skillNameNormalizer.normalize(candidate); return !normalized.isBlank() && studentSkills.contains(normalized); } }