Browse Source

feat: add skill cloud query service

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
e3eecfe1a6
  1. 158
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java
  2. 18
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudItem.java
  3. 8
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudNode.java
  4. 15
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudResponse.java
  5. 305
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java

158
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<SkillCloudSkillProjection> 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<SkillCloudSkillProjection> projections = validatedCategoryId == null
? occupationSkillRepository.findSkillProjectionsByOccupationId(validatedOccupationId)
: occupationSkillRepository.findSkillProjectionsByOccupationIdAndCategoryId(
validatedOccupationId, validatedCategoryId);
List<SkillCloudSkillProjection> sortedProjections = projections.stream()
.sorted(SKILL_ORDER)
.toList();
List<SkillCloudItem> 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 <T extends Comparable<? super T>> 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);
}
}

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

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

15
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<SkillCloudItem> items
) {
public SkillCloudResponse {
items = List.copyOf(items);
}
}

305
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<String, Object> 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<String, Object> 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<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