diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java new file mode 100644 index 0000000..22c59c3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java @@ -0,0 +1,158 @@ +package com.mydreamjob.skill; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.skill.domain.SkillCategory; +import com.mydreamjob.skill.domain.SkillIndustry; +import com.mydreamjob.skill.domain.SkillOccupation; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillCloudNode; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillCloudSkillProjection; +import com.mydreamjob.skill.repository.OccupationSkillRepository; +import com.mydreamjob.skill.repository.SkillCategoryRepository; +import com.mydreamjob.skill.repository.SkillIndustryRepository; +import com.mydreamjob.skill.repository.SkillOccupationRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.Comparator; +import java.util.List; +import java.util.stream.IntStream; + +@Service +@Transactional(readOnly = true) +public class SkillCloudQueryService { + + private static final Comparator SKILL_ORDER = (left, right) -> { + int heatComparison = compareHeat(left.importance(), right.importance()); + if (heatComparison != 0) { + return heatComparison; + } + + int nameComparison = compareNullable(left.nameZh(), right.nameZh()); + if (nameComparison != 0) { + return nameComparison; + } + + return compareNullable(left.skillId(), right.skillId()); + }; + + private final SkillIndustryRepository industryRepository; + private final SkillOccupationRepository occupationRepository; + private final SkillCategoryRepository categoryRepository; + private final OccupationSkillRepository occupationSkillRepository; + + public SkillCloudQueryService(SkillIndustryRepository industryRepository, + SkillOccupationRepository occupationRepository, + SkillCategoryRepository categoryRepository, + OccupationSkillRepository occupationSkillRepository) { + this.industryRepository = industryRepository; + this.occupationRepository = occupationRepository; + this.categoryRepository = categoryRepository; + this.occupationSkillRepository = occupationSkillRepository; + } + + public SkillCloudResponse query(Long industryId, Long occupationId, Long categoryId) { + long validatedIndustryId = requirePositive(industryId, "industryId"); + long validatedOccupationId = requirePositive(occupationId, "occupationId"); + Long validatedCategoryId = categoryId == null ? null : requirePositive(categoryId, "categoryId"); + + SkillIndustry industry = industryRepository.findById(validatedIndustryId) + .orElseThrow(() -> badRequest("industry not found: industryId=" + validatedIndustryId)); + SkillOccupation occupation = occupationRepository.findById(validatedOccupationId) + .orElseThrow(() -> badRequest("occupation not found: occupationId=" + validatedOccupationId)); + if (!occupation.getIndustryId().equals(validatedIndustryId)) { + throw badRequest("occupation does not belong to industry: occupationId=" + + validatedOccupationId + ", industryId=" + validatedIndustryId); + } + + SkillCategory category = null; + if (validatedCategoryId != null) { + category = categoryRepository.findById(validatedCategoryId) + .orElseThrow(() -> badRequest("category not found: categoryId=" + validatedCategoryId)); + if (!category.getOccupationId().equals(validatedOccupationId)) { + throw badRequest("category does not belong to occupation: categoryId=" + + validatedCategoryId + ", occupationId=" + validatedOccupationId); + } + } + + List projections = validatedCategoryId == null + ? occupationSkillRepository.findSkillProjectionsByOccupationId(validatedOccupationId) + : occupationSkillRepository.findSkillProjectionsByOccupationIdAndCategoryId( + validatedOccupationId, validatedCategoryId); + + List sortedProjections = projections.stream() + .sorted(SKILL_ORDER) + .toList(); + List items = IntStream.range(0, sortedProjections.size()) + .mapToObj(index -> toItem(sortedProjections.get(index), index + 1)) + .toList(); + + return new SkillCloudResponse( + toNode(industry.getId(), industry.getCode(), industry.getNameZh()), + toNode(occupation.getId(), occupation.getCode(), occupation.getNameZh()), + category == null ? null : toNode(category.getId(), category.getCode(), category.getNameZh()), + items.size(), + items + ); + } + + private SkillCloudItem toItem(SkillCloudSkillProjection projection, int rank) { + return new SkillCloudItem( + rank, + projection.skillId(), + projection.skillCode(), + projection.nameZh(), + projection.nameEn(), + projection.categoryId(), + projection.categoryName(), + projection.importance(), + projection.importanceSourceCode(), + projection.importanceElementId(), + projection.importanceIsProxy() + ); + } + + private static int compareHeat(BigDecimal left, BigDecimal right) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return 1; + } + if (right == null) { + return -1; + } + return right.compareTo(left); + } + + private static > int compareNullable(T left, T right) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return 1; + } + if (right == null) { + return -1; + } + return left.compareTo(right); + } + + private static SkillCloudNode toNode(Long id, String code, String name) { + return new SkillCloudNode(id, code, name); + } + + private static long requirePositive(Long value, String parameterName) { + if (value == null || value <= 0) { + throw badRequest(parameterName + " must be a positive integer"); + } + return value; + } + + private static BusinessException badRequest(String detail) { + return new BusinessException(ErrorCode.BAD_REQUEST, detail); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudItem.java new file mode 100644 index 0000000..63caefe --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudItem.java @@ -0,0 +1,18 @@ +package com.mydreamjob.skill.dto; + +import java.math.BigDecimal; + +public record SkillCloudItem( + int rank, + Long skillId, + String skillCode, + String nameZh, + String nameEn, + Long categoryId, + String categoryName, + BigDecimal heat, + String heatSourceCode, + String heatElementId, + Boolean heatProxy +) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudNode.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudNode.java new file mode 100644 index 0000000..55a28fd --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudNode.java @@ -0,0 +1,8 @@ +package com.mydreamjob.skill.dto; + +public record SkillCloudNode( + Long id, + String code, + String name +) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudResponse.java new file mode 100644 index 0000000..326076e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudResponse.java @@ -0,0 +1,15 @@ +package com.mydreamjob.skill.dto; + +import java.util.List; + +public record SkillCloudResponse( + SkillCloudNode industry, + SkillCloudNode occupation, + SkillCloudNode category, + int total, + List items +) { + public SkillCloudResponse { + items = List.copyOf(items); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java new file mode 100644 index 0000000..57286fc --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java @@ -0,0 +1,305 @@ +package com.mydreamjob.skill; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.skill.domain.SkillIndustry; +import com.mydreamjob.skill.domain.SkillOccupation; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillCloudSkillProjection; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +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.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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import com.mydreamjob.skill.repository.OccupationSkillRepository; +import com.mydreamjob.skill.repository.SkillCategoryRepository; +import com.mydreamjob.skill.repository.SkillIndustryRepository; +import com.mydreamjob.skill.repository.SkillOccupationRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:integrationdb_skill_query;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.SkillCloudQueryServiceTest$SkillQuerySchemaFilterProvider" +}) +@ActiveProfiles("integration-test") +@Transactional +class SkillCloudQueryServiceTest { + + @Autowired + private SkillCloudQueryService service; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void returnsAllSeedSkillsForIndustryAndOccupationWithContinuousRanks() { + SkillCloudResponse response = service.query(1L, 1L, null); + + assertThat(response.industry().id()).isEqualTo(1L); + assertThat(response.industry().code()).isEqualTo("INTERNET"); + assertThat(response.industry().name()).isEqualTo("互联网"); + assertThat(response.occupation().id()).isEqualTo(1L); + assertThat(response.occupation().code()).isEqualTo("FRONTEND_DEVELOPER"); + assertThat(response.occupation().name()).isEqualTo("前端开发工程师"); + assertThat(response.category()).isNull(); + assertThat(response.total()).isEqualTo(15); + assertThat(response.items()).hasSize(15); + assertThat(response.items()) + .extracting(SkillCloudItem::rank) + .containsExactlyElementsOf(IntStream.rangeClosed(1, 15).boxed().toList()); + assertThat(response.items()) + .extracting(SkillCloudItem::skillId) + .doesNotHaveDuplicates(); + } + + @Test + void sortsByHeatThenNameAndKeepsNullHeatLast() { + SkillCloudResponse response = service.query(1L, 1L, 104L); + + assertThat(response.category().id()).isEqualTo(104L); + assertThat(response.category().code()).isEqualTo("TOOLS"); + assertThat(response.category().name()).isEqualTo("工程化工具"); + assertThat(response.items()) + .extracting(SkillCloudItem::nameZh) + .containsExactly("Gradle", "Node.js", "Figma"); + assertThat(response.items()) + .extracting(SkillCloudItem::heat) + .containsExactly(new BigDecimal("4.88"), new BigDecimal("4.88"), null); + assertThat(response.items().get(response.items().size() - 1).heat()).isNull(); + + SkillCloudResponse all = service.query(1L, 1L, null); + assertThat(all.items().stream() + .map(SkillCloudItem::heat) + .filter(java.util.Objects::nonNull) + .toList()) + .isSortedAccordingTo(java.util.Comparator.reverseOrder()); + assertThat(all.items().get(all.items().size() - 1).heat()).isNull(); + assertThat(all.items().subList(0, all.items().size() - 1)) + .allSatisfy(item -> assertThat(item.heat()).isNotNull()); + } + + @Test + void comparatorUsesSkillIdWhenEqualKeysArriveInReverseOrder() { + SkillIndustry industry = new SkillIndustry(); + industry.setId(1L); + industry.setCode("INTERNET"); + industry.setNameZh("互联网"); + + SkillOccupation occupation = new SkillOccupation(); + occupation.setId(1L); + occupation.setIndustryId(1L); + occupation.setCode("FRONTEND_DEVELOPER"); + occupation.setNameZh("前端开发工程师"); + + SkillIndustryRepository industryRepository = mock(SkillIndustryRepository.class); + when(industryRepository.findById(1L)).thenReturn(Optional.of(industry)); + + SkillOccupationRepository occupationRepository = mock(SkillOccupationRepository.class); + when(occupationRepository.findById(1L)).thenReturn(Optional.of(occupation)); + + OccupationSkillRepository occupationSkillRepository = mock(OccupationSkillRepository.class); + when(occupationSkillRepository.findSkillProjectionsByOccupationId(1L)) + .thenReturn(List.of( + projection(9002L, "TIE_SKILL_HIGH"), + projection(9001L, "TIE_SKILL_LOW"))); + + SkillCloudQueryService isolatedService = new SkillCloudQueryService( + industryRepository, + occupationRepository, + mock(SkillCategoryRepository.class), + occupationSkillRepository); + + SkillCloudResponse response = isolatedService.query(1L, 1L, null); + + assertThat(response.items()) + .extracting(SkillCloudItem::skillId) + .containsExactly(9001L, 9002L); + } + + private SkillCloudSkillProjection projection(Long skillId, String skillCode) { + return new SkillCloudSkillProjection( + skillId, + skillCode, + "同名技能", + "Tie Skill", + "O*NET", + "TEST|Tie Skill", + 104L, + "工程化工具", + new BigDecimal("4.88"), + "15-1254.00", + "4.A.3.b.1", + true, + 1, + 1 + ); + } + + @Test + void categoryFilterReturnsOnlyTheRequestedCategoryAndOmittedCategoryReturnsMore() { + SkillCloudResponse all = service.query(1L, 1L, null); + SkillCloudResponse filtered = service.query(1L, 1L, 101L); + + assertThat(filtered.total()).isEqualTo(2); + assertThat(filtered.items()) + .extracting(SkillCloudItem::categoryId) + .containsOnly(101L); + assertThat(filtered.items()) + .extracting(SkillCloudItem::skillId) + .containsExactlyInAnyOrder(3L, 4L); + assertThat(all.total()).isGreaterThan(filtered.total()); + } + + @Test + void returnsEmptySuccessForLegalCategoryWithoutSkills() { + jdbcTemplate.update(""" + INSERT INTO skill_categories + (id, occupation_id, code, name_zh, suggestion_template_code, sort_order) + VALUES + (9101, 1, 'EMPTY_CATEGORY', '空分类', 'GENERAL', 99) + """); + + SkillCloudResponse response = service.query(1L, 1L, 9101L); + + assertThat(response.category().id()).isEqualTo(9101L); + assertThat(response.category().name()).isEqualTo("空分类"); + assertThat(response.total()).isZero(); + assertThat(response.items()).isEmpty(); + } + + @Test + void mapsProvenanceFieldsFromTheDatabase() { + SkillCloudResponse response = service.query(1L, 1L, 103L); + SkillCloudItem react = response.items().stream() + .filter(item -> item.skillId().equals(26L)) + .findFirst() + .orElseThrow(); + + Map row = jdbcTemplate.queryForMap(""" + SELECT os.importance, os.importance_source_code, os.importance_element_id, + os.importance_is_proxy + FROM occupation_skills os + WHERE os.occupation_id = 1 AND os.skill_id = 26 + """); + + assertThat(react.rank()).isEqualTo(1); + assertThat(react.skillCode()).isEqualTo("REACT"); + assertThat(react.nameZh()).isEqualTo("React"); + assertThat(react.nameEn()).isEqualTo("React"); + assertThat(react.categoryId()).isEqualTo(103L); + assertThat(react.categoryName()).isEqualTo("前端框架"); + assertThat(react.heat()).isEqualByComparingTo((BigDecimal) row.get("importance")); + assertThat(react.heatSourceCode()).isEqualTo(row.get("importance_source_code")); + assertThat(react.heatElementId()).isEqualTo(row.get("importance_element_id")); + assertThat(react.heatProxy()).isEqualTo(((Number) row.get("importance_is_proxy")).intValue() == 1); + } + + @Test + void rejectsMissingOrNonPositiveIdentifiersWithBadRequest() { + assertBadRequest(() -> service.query(null, 1L, null), "industryId"); + assertBadRequest(() -> service.query(0L, 1L, null), "industryId"); + assertBadRequest(() -> service.query(-1L, 1L, null), "industryId"); + assertBadRequest(() -> service.query(1L, null, null), "occupationId"); + assertBadRequest(() -> service.query(1L, 0L, null), "occupationId"); + assertBadRequest(() -> service.query(1L, -1L, null), "occupationId"); + assertBadRequest(() -> service.query(1L, 1L, 0L), "categoryId"); + assertBadRequest(() -> service.query(1L, 1L, -1L), "categoryId"); + } + + @Test + void rejectsUnknownOrMismatchedTaxonomyNodesWithBadRequest() { + assertBadRequest(() -> service.query(999L, 1L, null), "industry not found"); + assertBadRequest(() -> service.query(1L, 999L, null), "occupation not found"); + assertBadRequest(() -> service.query(2L, 1L, null), "occupation does not belong"); + assertBadRequest(() -> service.query(1L, 1L, 201L), "category does not belong"); + assertBadRequest(() -> service.query(1L, 1L, 9999L), "category not found"); + } + + private void assertBadRequest(ThrowingCallable action, String detailFragment) { + BusinessException exception = catchThrowableOfType(action, BusinessException.class); + assertThat(exception).isNotNull(); + assertThat(exception.getErrorCodeInt()).isEqualTo(ErrorCode.BAD_REQUEST.getCode()); + assertThat(exception.getErrorMessage()).contains(detailFragment); + } + + public static class SkillQuerySchemaFilterProvider 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; + } + }; + } + } +}