5 changed files with 504 additions and 0 deletions
@ -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); |
|||
} |
|||
} |
|||
@ -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 |
|||
) { |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
package com.mydreamjob.skill.dto; |
|||
|
|||
public record SkillCloudNode( |
|||
Long id, |
|||
String code, |
|||
String name |
|||
) { |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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…
Reference in new issue