feat(api): return skill labels from the skill listing endpoints on request

Skill labels were reachable only one skill at a time, through
/api/{v1,web}/skills/{namespace}/{slug}/labels, so a client rendering a list had
to issue a follow-up request per row.

Add includeLabels=true to GET /api/v1/skills and GET /api/web/skills. The labels
array is populated only when the parameter is set and left out of the payload
otherwise, so existing responses are byte-identical.

Labels for the whole page are resolved by SkillLabelProjectionService in three
queries — assignments, definitions, translations — rather than three per skill.

Closes #710

Signed-off-by: FenjuFu <fufenjupku@gmail.com>
This commit is contained in:
FenjuFu 2026-08-19 03:47:03 +00:00
parent d2403bb591
commit 3329900487
10 changed files with 390 additions and 10 deletions

View file

@ -20,8 +20,10 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.service.SkillLabelProjectionService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.io.IOException;
import java.util.HashMap;
@ -51,6 +53,7 @@ public class ClawHubCompatAppService {
private final CompatSkillLookupService compatSkillLookupService;
private final SkillStarService skillStarService;
private final RequestIdAccessor requestIdAccessor;
private final SkillLabelProjectionService skillLabelProjectionService;
public ClawHubCompatAppService(CanonicalSlugMapper mapper,
SkillSearchAppService skillSearchAppService,
@ -61,7 +64,8 @@ public class ClawHubCompatAppService {
AuditLogService auditLogService,
CompatSkillLookupService compatSkillLookupService,
SkillStarService skillStarService,
RequestIdAccessor requestIdAccessor) {
RequestIdAccessor requestIdAccessor,
SkillLabelProjectionService skillLabelProjectionService) {
this.mapper = mapper;
this.skillSearchAppService = skillSearchAppService;
this.skillQueryService = skillQueryService;
@ -72,6 +76,7 @@ public class ClawHubCompatAppService {
this.compatSkillLookupService = compatSkillLookupService;
this.skillStarService = skillStarService;
this.requestIdAccessor = requestIdAccessor;
this.skillLabelProjectionService = skillLabelProjectionService;
}
public ClawHubSearchResponse search(String q,
@ -195,6 +200,15 @@ public class ClawHubCompatAppService {
String sort,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
return listSkills(page, limit, sort, false, userId, userNsRoles);
}
public ClawHubSkillListResponse listSkills(int page,
int limit,
String sort,
boolean includeLabels,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
String sortBy = sort != null ? sort : "newest";
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
"",
@ -206,8 +220,15 @@ public class ClawHubCompatAppService {
userNsRoles
);
Map<Long, List<SkillLabelDto>> labelsBySkillId = includeLabels
? skillLabelProjectionService.labelsBySkillIds(
response.items().stream().map(SkillSummaryResponse::id).toList())
: Map.of();
List<ClawHubSkillListResponse.SkillListItem> items = response.items().stream()
.map(this::toSkillListItem)
.map(item -> toSkillListItem(
item,
includeLabels ? labelsBySkillId.getOrDefault(item.id(), List.of()) : null))
.toList();
String nextCursor = null;
@ -383,7 +404,8 @@ public class ClawHubCompatAppService {
return new ClawHubResolveResponse(matchVersion, latestVersion);
}
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) {
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item,
List<SkillLabelDto> labels) {
long createdAt = 0;
long updatedAt = item.updatedAt() != null ? item.updatedAt().toEpochMilli() : 0;
@ -413,7 +435,8 @@ public class ClawHubCompatAppService {
stats,
createdAt,
updatedAt,
latestVersion
latestVersion,
labels
);
}

View file

@ -93,9 +93,10 @@ public class ClawHubCompatController {
public ClawHubSkillListResponse listSkills(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int limit,
@RequestParam(required = false) String sort,
@RequestParam(defaultValue = "false") boolean includeLabels,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return clawHubCompatAppService.listSkills(page, limit, sort, userId, userNsRoles);
return clawHubCompatAppService.listSkills(page, limit, sort, includeLabels, userId, userNsRoles);
}
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)

View file

@ -1,5 +1,7 @@
package com.iflytek.skillhub.compat.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.iflytek.skillhub.dto.SkillLabelDto;
import java.util.List;
public record ClawHubSkillListResponse(
@ -14,8 +16,27 @@ public record ClawHubSkillListResponse(
Object stats,
long createdAt,
long updatedAt,
LatestVersion latestVersion
LatestVersion latestVersion,
/**
* Labels attached to the skill, present only when the caller passes
* {@code includeLabels=true}. Omitted otherwise, so the legacy payload is unchanged.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
List<SkillLabelDto> labels
) {
public SkillListItem(
String slug,
String displayName,
String summary,
Object tags,
Object stats,
long createdAt,
long updatedAt,
LatestVersion latestVersion) {
this(slug, displayName, summary, tags, stats, createdAt, updatedAt, latestVersion, null);
}
public record LatestVersion(
String version,
long createdAt,

View file

@ -5,12 +5,16 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.service.SkillLabelProjectionService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
@ -27,11 +31,14 @@ public class SkillSearchController extends BaseApiController {
private static final int DEFAULT_SIZE = 20;
private final SkillSearchAppService skillSearchAppService;
private final SkillLabelProjectionService skillLabelProjectionService;
public SkillSearchController(SkillSearchAppService skillSearchAppService,
SkillLabelProjectionService skillLabelProjectionService,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.skillSearchAppService = skillSearchAppService;
this.skillLabelProjectionService = skillLabelProjectionService;
}
@GetMapping
@ -40,6 +47,8 @@ public class SkillSearchController extends BaseApiController {
@RequestParam(required = false) String q,
@RequestParam(required = false) String namespace,
@RequestParam(name = "label", required = false) java.util.List<String> labels,
@Parameter(description = "Include each skill's labels in the response")
@RequestParam(name = "includeLabels", defaultValue = "false") boolean includeLabels,
@Parameter(schema = @Schema(defaultValue = DEFAULT_SORT))
@RequestParam(required = false) String sort,
@Parameter(schema = @Schema(type = "integer", defaultValue = "0", minimum = "0"))
@ -60,7 +69,18 @@ public class SkillSearchController extends BaseApiController {
userNsRoles
);
return ok("response.success.read", response);
return ok("response.success.read", includeLabels ? withLabels(response) : response);
}
private SkillSearchAppService.SearchResponse withLabels(SkillSearchAppService.SearchResponse response) {
Map<Long, List<SkillLabelDto>> labelsBySkillId = skillLabelProjectionService.labelsBySkillIds(
response.items().stream().map(SkillSummaryResponse::id).toList());
List<SkillSummaryResponse> items = response.items().stream()
.map(item -> item.withLabels(labelsBySkillId.getOrDefault(item.id(), List.of())))
.toList();
return new SkillSearchAppService.SearchResponse(items, response.total(), response.page(), response.size());
}
private String normalizeSort(String sort) {

View file

@ -1,7 +1,9 @@
package com.iflytek.skillhub.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public record SkillSummaryResponse(
Long id,
@ -21,5 +23,46 @@ public record SkillSummaryResponse(
SkillLifecycleVersionResponse publishedVersion,
SkillLifecycleVersionResponse ownerPreviewVersion,
String resolutionMode,
ComplianceSnapshotResponse complianceSnapshot
) {}
ComplianceSnapshotResponse complianceSnapshot,
/**
* Labels attached to the skill, present only when the caller asked for them.
* Left out of the payload otherwise, so responses are unchanged for callers
* that do not opt in.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
List<SkillLabelDto> labels
) {
/**
* Summary without label projection.
*/
public SkillSummaryResponse(
Long id,
String slug,
String displayName,
String summary,
String visibility,
String status,
Long downloadCount,
Integer starCount,
BigDecimal ratingAvg,
Integer ratingCount,
String namespace,
Instant updatedAt,
boolean canSubmitPromotion,
SkillLifecycleVersionResponse headlineVersion,
SkillLifecycleVersionResponse publishedVersion,
SkillLifecycleVersionResponse ownerPreviewVersion,
String resolutionMode,
ComplianceSnapshotResponse complianceSnapshot) {
this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg,
ratingCount, namespace, updatedAt, canSubmitPromotion, headlineVersion, publishedVersion,
ownerPreviewVersion, resolutionMode, complianceSnapshot, null);
}
public SkillSummaryResponse withLabels(List<SkillLabelDto> labels) {
return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount,
starCount, ratingAvg, ratingCount, namespace, updatedAt, canSubmitPromotion, headlineVersion,
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels);
}
}

View file

@ -0,0 +1,94 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.label.LabelDefinition;
import com.iflytek.skillhub.domain.label.LabelDefinitionService;
import com.iflytek.skillhub.domain.label.LabelTranslation;
import com.iflytek.skillhub.domain.label.SkillLabel;
import com.iflytek.skillhub.domain.label.SkillLabelService;
import com.iflytek.skillhub.dto.SkillLabelDto;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
/**
* Projects skill labels for a whole page of skills in a fixed number of queries.
*
* <p>Listing endpoints need labels for every item they return, so resolving them one
* skill at a time would issue three queries per row. This service batches the
* assignment, definition, and translation lookups instead.</p>
*/
@Service
public class SkillLabelProjectionService {
private final SkillLabelService skillLabelService;
private final LabelDefinitionService labelDefinitionService;
private final LabelLocalizationService labelLocalizationService;
public SkillLabelProjectionService(SkillLabelService skillLabelService,
LabelDefinitionService labelDefinitionService,
LabelLocalizationService labelLocalizationService) {
this.skillLabelService = skillLabelService;
this.labelDefinitionService = labelDefinitionService;
this.labelLocalizationService = labelLocalizationService;
}
/**
* Labels for each requested skill, keyed by skill id. Skills without labels are absent
* from the map rather than mapped to an empty list.
*/
public Map<Long, List<SkillLabelDto>> labelsBySkillIds(List<Long> skillIds) {
if (skillIds == null || skillIds.isEmpty()) {
return Map.of();
}
List<Long> distinctSkillIds = skillIds.stream()
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
if (distinctSkillIds.isEmpty()) {
return Map.of();
}
List<SkillLabel> assignments = skillLabelService.listSkillLabelsBySkillIds(distinctSkillIds);
if (assignments.isEmpty()) {
return Map.of();
}
List<Long> labelIds = assignments.stream()
.map(SkillLabel::getLabelId)
.distinct()
.toList();
Map<Long, LabelDefinition> definitionsById = labelDefinitionService.listByIds(labelIds).stream()
.collect(Collectors.toMap(LabelDefinition::getId, Function.identity()));
Map<Long, List<LabelTranslation>> translationsByLabelId =
labelDefinitionService.listTranslationsByLabelIds(labelIds);
return assignments.stream()
.filter(assignment -> definitionsById.containsKey(assignment.getLabelId()))
.collect(Collectors.groupingBy(
SkillLabel::getSkillId,
Collectors.collectingAndThen(
Collectors.toList(),
skillAssignments -> skillAssignments.stream()
.map(assignment -> toDto(
definitionsById.get(assignment.getLabelId()),
translationsByLabelId))
.sorted(Comparator.comparing(SkillLabelDto::type)
.thenComparing(SkillLabelDto::slug))
.toList())));
}
private SkillLabelDto toDto(LabelDefinition definition,
Map<Long, List<LabelTranslation>> translationsByLabelId) {
return new SkillLabelDto(
definition.getSlug(),
definition.getType().name(),
labelLocalizationService.resolveDisplayName(
definition.getSlug(),
translationsByLabelId.getOrDefault(definition.getId(), List.of()))
);
}
}

View file

@ -15,8 +15,13 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.compat.dto.ClawHubSkillListResponse;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.service.SkillLabelProjectionService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
@ -31,6 +36,7 @@ class ClawHubCompatAppServiceTest {
private final AuditLogService auditLogService = mock(AuditLogService.class);
private final CompatSkillLookupService compatSkillLookupService = mock(CompatSkillLookupService.class);
private final SkillStarService skillStarService = mock(SkillStarService.class);
private final SkillLabelProjectionService skillLabelProjectionService = mock(SkillLabelProjectionService.class);
private final ClawHubCompatAppService service = new ClawHubCompatAppService(
new CanonicalSlugMapper(),
@ -42,7 +48,8 @@ class ClawHubCompatAppServiceTest {
auditLogService,
compatSkillLookupService,
skillStarService,
new RequestIdAccessor()
new RequestIdAccessor(),
skillLabelProjectionService
);
@Test
@ -120,4 +127,35 @@ class ClawHubCompatAppServiceTest {
assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/versions/20260707.025847/download");
}
@Test
void listSkills_omitsLabelsByDefault() {
when(skillSearchAppService.search("", null, "newest", 0, 25, null, Map.of()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25));
ClawHubSkillListResponse response = service.listSkills(0, 25, null, null, Map.of());
assertThat(response.items()).hasSize(1);
assertThat(response.items().get(0).labels()).isNull();
}
@Test
void listSkills_returnsLabelsWhenRequested() {
when(skillSearchAppService.search("", null, "newest", 0, 25, null, Map.of()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25));
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L)))
.thenReturn(Map.of(7L, List.of(new SkillLabelDto("automation", "RECOMMENDED", "Automation"))));
ClawHubSkillListResponse response = service.listSkills(0, 25, null, true, null, Map.of());
assertThat(response.items().get(0).labels())
.extracting(SkillLabelDto::slug)
.containsExactly("automation");
}
private static SkillSummaryResponse summary(Long id) {
return new SkillSummaryResponse(
id, "demo-skill", "Demo Skill", "A demo", "PUBLIC", "PUBLISHED",
0L, 0, null, 0, "global", null, false, null, null, null, null, null);
}
}

View file

@ -1,6 +1,9 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.service.SkillLabelProjectionService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -34,6 +37,9 @@ class SkillSearchControllerTest {
@MockBean
private SkillSearchAppService skillSearchAppService;
@MockBean
private SkillLabelProjectionService skillLabelProjectionService;
@Test
void searchShouldUseUnifiedEnvelopeAndItemsField() throws Exception {
when(skillSearchAppService.search(
@ -143,4 +149,50 @@ class SkillSearchControllerTest {
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20));
}
@Test
void searchShouldOmitLabelsUnlessRequested() throws Exception {
when(skillSearchAppService.search(
eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20));
mockMvc.perform(get("/api/web/skills"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].slug").value("demo-skill"))
.andExpect(jsonPath("$.data.items[0].labels").doesNotExist());
}
@Test
void searchShouldReturnLabelsWhenRequested() throws Exception {
when(skillSearchAppService.search(
eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20));
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L)))
.thenReturn(Map.of(7L, List.of(new SkillLabelDto("automation", "TOPIC", "Automation"))));
mockMvc.perform(get("/api/web/skills").param("includeLabels", "true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].labels[0].slug").value("automation"))
.andExpect(jsonPath("$.data.items[0].labels[0].type").value("TOPIC"))
.andExpect(jsonPath("$.data.items[0].labels[0].displayName").value("Automation"));
}
@Test
void searchShouldReturnEmptyLabelArrayForSkillsWithoutLabels() throws Exception {
when(skillSearchAppService.search(
eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20));
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L))).thenReturn(Map.of());
mockMvc.perform(get("/api/web/skills").param("includeLabels", "true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].labels").isArray())
.andExpect(jsonPath("$.data.items[0].labels").isEmpty());
}
private static SkillSummaryResponse summary(Long id) {
return new SkillSummaryResponse(
id, "demo-skill", "Demo Skill", "A demo", "PUBLIC", "PUBLISHED",
0L, 0, null, 0, "global", null, false, null, null, null, null, null);
}
}

View file

@ -0,0 +1,81 @@
package com.iflytek.skillhub.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.label.LabelDefinition;
import com.iflytek.skillhub.domain.label.LabelDefinitionService;
import com.iflytek.skillhub.domain.label.LabelType;
import com.iflytek.skillhub.domain.label.SkillLabel;
import com.iflytek.skillhub.domain.label.SkillLabelService;
import com.iflytek.skillhub.dto.SkillLabelDto;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
class SkillLabelProjectionServiceTest {
private final SkillLabelService skillLabelService = mock(SkillLabelService.class);
private final LabelDefinitionService labelDefinitionService = mock(LabelDefinitionService.class);
private final LabelLocalizationService labelLocalizationService = new LabelLocalizationService();
private final SkillLabelProjectionService service = new SkillLabelProjectionService(
skillLabelService, labelDefinitionService, labelLocalizationService);
@Test
void labelsBySkillIds_groupsLabelsPerSkillInOneBatch() {
LabelDefinition automation = definition(10L, "automation", LabelType.RECOMMENDED);
LabelDefinition audited = definition(11L, "audited", LabelType.PRIVILEGED);
when(skillLabelService.listSkillLabelsBySkillIds(List.of(1L, 2L))).thenReturn(List.of(
new SkillLabel(1L, 10L, "owner-1"),
new SkillLabel(1L, 11L, "owner-1"),
new SkillLabel(2L, 10L, "owner-2")
));
when(labelDefinitionService.listByIds(anyList())).thenReturn(List.of(automation, audited));
when(labelDefinitionService.listTranslationsByLabelIds(anyList())).thenReturn(Map.of());
Map<Long, List<SkillLabelDto>> labels = service.labelsBySkillIds(List.of(1L, 2L));
// sorted by label type, then slug: PRIVILEGED before RECOMMENDED
assertEquals(List.of("audited", "automation"), labels.get(1L).stream().map(SkillLabelDto::slug).toList());
assertEquals(List.of("automation"), labels.get(2L).stream().map(SkillLabelDto::slug).toList());
// One query per lookup for the whole page, not per skill.
verify(skillLabelService, times(1)).listSkillLabelsBySkillIds(anyList());
verify(labelDefinitionService, times(1)).listByIds(anyList());
verify(labelDefinitionService, times(1)).listTranslationsByLabelIds(anyList());
}
@Test
void labelsBySkillIds_skipsAssignmentsWithoutADefinition() {
when(skillLabelService.listSkillLabelsBySkillIds(anyList()))
.thenReturn(List.of(new SkillLabel(1L, 99L, "owner-1")));
when(labelDefinitionService.listByIds(anyList())).thenReturn(List.of());
when(labelDefinitionService.listTranslationsByLabelIds(anyList())).thenReturn(Map.of());
assertTrue(service.labelsBySkillIds(List.of(1L)).isEmpty());
}
@Test
void labelsBySkillIds_touchesNoRepositoryForAnEmptyPage() {
assertTrue(service.labelsBySkillIds(List.of()).isEmpty());
assertTrue(service.labelsBySkillIds(null).isEmpty());
verify(skillLabelService, never()).listSkillLabelsBySkillIds(any());
}
private static LabelDefinition definition(Long id, String slug, LabelType type) {
LabelDefinition definition = new LabelDefinition(slug, type, true, 0, "admin");
ReflectionTestUtils.setField(definition, "id", id);
return definition;
}
}

View file

@ -38,6 +38,13 @@ public class SkillLabelService {
return skillLabelRepository.findBySkillId(skillId);
}
public List<SkillLabel> listSkillLabelsBySkillIds(List<Long> skillIds) {
if (skillIds == null || skillIds.isEmpty()) {
return List.of();
}
return skillLabelRepository.findBySkillIdIn(skillIds);
}
public List<SkillLabel> listByLabelId(Long labelId) {
return skillLabelRepository.findByLabelId(labelId);
}