mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
feat(api): return skill labels from the skill listing endpoints on request (#730)
* 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>
* fix(api): use include parameter for skill labels
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* test(api): reject unsupported include before search
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---------
Signed-off-by: FenjuFu <fufenjupku@gmail.com>
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
1c3e9be9e9
commit
91d0ae1504
16 changed files with 548 additions and 11 deletions
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse;
|
|||
import com.iflytek.skillhub.compat.dto.ClawHubStarResponse;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubUnstarResponse;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse;
|
||||
import com.iflytek.skillhub.controller.support.IncludeOptions;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
|
@ -93,9 +96,12 @@ public class ClawHubCompatController {
|
|||
public ClawHubSkillListResponse listSkills(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int limit,
|
||||
@RequestParam(required = false) String sort,
|
||||
@Parameter(description = "Optional response expansions. Supported value: labels")
|
||||
@RequestParam(name = "include", required = false) List<String> include,
|
||||
@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, IncludeOptions.includesLabels(include), userId, userNsRoles);
|
||||
}
|
||||
|
||||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
|
|
|
|||
|
|
@ -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 include=labels}. 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,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.controller.support.IncludeOptions;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
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 = "Optional response expansions. Supported value: labels")
|
||||
@RequestParam(name = "include", required = false) List<String> include,
|
||||
@Parameter(schema = @Schema(defaultValue = DEFAULT_SORT))
|
||||
@RequestParam(required = false) String sort,
|
||||
@Parameter(schema = @Schema(type = "integer", defaultValue = "0", minimum = "0"))
|
||||
|
|
@ -49,6 +58,7 @@ public class SkillSearchController extends BaseApiController {
|
|||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
||||
boolean includeLabels = IncludeOptions.includesLabels(include);
|
||||
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
|
||||
q,
|
||||
namespace,
|
||||
|
|
@ -60,7 +70,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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
package com.iflytek.skillhub.controller.support;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Parses optional response expansions from {@code include=...} query parameters.
|
||||
*/
|
||||
public final class IncludeOptions {
|
||||
|
||||
private static final String LABELS = "labels";
|
||||
private static final Set<String> SUPPORTED = Set.of(LABELS);
|
||||
|
||||
private IncludeOptions() {
|
||||
}
|
||||
|
||||
public static boolean includesLabels(List<String> include) {
|
||||
if (include == null || include.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean requested = false;
|
||||
for (String rawValue : include) {
|
||||
if (rawValue == null || rawValue.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
for (String rawOption : rawValue.split(",")) {
|
||||
String option = rawOption.trim().toLowerCase(Locale.ROOT);
|
||||
if (option.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (!SUPPORTED.contains(option)) {
|
||||
throw new DomainBadRequestException("error.request.include.unsupported", option);
|
||||
}
|
||||
requested = true;
|
||||
}
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -54,6 +54,7 @@ error.forbidden=Forbidden
|
|||
error.apiToken.scope.missing=API token is missing required scope: {0}
|
||||
error.apiToken.endpoint.unsupported=API token cannot access endpoint: {0}
|
||||
error.request.timeout=Request timed out
|
||||
error.request.include.unsupported=Unsupported include option: {0}
|
||||
error.rateLimit.exceeded=Rate limit exceeded
|
||||
error.storage.unavailable=Object storage is temporarily unavailable. Please try again later.
|
||||
error.internal=An unexpected error occurred
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ error.forbidden=没有权限执行该操作
|
|||
error.apiToken.scope.missing=API 令牌缺少所需权限范围:{0}
|
||||
error.apiToken.endpoint.unsupported=API 令牌无法访问接口:{0}
|
||||
error.request.timeout=请求超时
|
||||
error.request.include.unsupported=不支持的 include 参数:{0}
|
||||
error.rateLimit.exceeded=请求过于频繁,请稍后再试
|
||||
error.storage.unavailable=对象存储暂时不可用,请稍后再试
|
||||
error.internal=服务器内部错误
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
|||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
|
||||
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 java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
|
@ -48,6 +50,7 @@ import static org.mockito.ArgumentMatchers.anyMap;
|
|||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
|
@ -72,6 +75,9 @@ class ClawHubCompatControllerTest {
|
|||
@MockBean
|
||||
private SkillSearchAppService skillSearchAppService;
|
||||
|
||||
@MockBean
|
||||
private SkillLabelProjectionService skillLabelProjectionService;
|
||||
|
||||
@MockBean
|
||||
private SkillQueryService skillQueryService;
|
||||
|
||||
|
|
@ -154,6 +160,41 @@ class ClawHubCompatControllerTest {
|
|||
verify(apiTokenService).touchLastUsed(same(token));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listSkills_shouldOmitLabelsByDefault() throws Exception {
|
||||
when(skillSearchAppService.search(eq(""), isNull(), eq("newest"), eq(0), eq(25), isNull(), isNull()))
|
||||
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25));
|
||||
|
||||
mockMvc.perform(get("/api/v1/skills"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.items[0].slug").value("demo-skill"))
|
||||
.andExpect(jsonPath("$.items[0].labels").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listSkills_shouldReturnLabelsWhenIncluded() throws Exception {
|
||||
when(skillSearchAppService.search(eq(""), isNull(), eq("newest"), eq(0), eq(25), isNull(), isNull()))
|
||||
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25));
|
||||
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L)))
|
||||
.thenReturn(java.util.Map.of(
|
||||
7L,
|
||||
List.of(new SkillLabelDto("automation", "RECOMMENDED", "Automation"))));
|
||||
|
||||
mockMvc.perform(get("/api/v1/skills").param("include", "labels"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.items[0].labels[0].slug").value("automation"))
|
||||
.andExpect(jsonPath("$.items[0].labels[0].type").value("RECOMMENDED"))
|
||||
.andExpect(jsonPath("$.items[0].labels[0].displayName").value("Automation"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listSkills_shouldRejectUnsupportedIncludeOptions() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/skills").param("include", "labels,stats"))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(skillSearchAppService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadQuery_withBearerToken_shouldProjectNamespaceRolesIntoRequestContext() throws Exception {
|
||||
ApiToken token = new ApiToken("user-7", "cli", "sk_test", "hash", "[]");
|
||||
|
|
@ -401,6 +442,28 @@ class ClawHubCompatControllerTest {
|
|||
return version;
|
||||
}
|
||||
|
||||
private 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);
|
||||
}
|
||||
|
||||
private UsernamePasswordAuthenticationToken superAdminAuth() {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -15,6 +18,7 @@ import java.util.Map;
|
|||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
|
|
@ -34,6 +38,9 @@ class SkillSearchControllerTest {
|
|||
@MockBean
|
||||
private SkillSearchAppService skillSearchAppService;
|
||||
|
||||
@MockBean
|
||||
private SkillLabelProjectionService skillLabelProjectionService;
|
||||
|
||||
@Test
|
||||
void searchShouldUseUnifiedEnvelopeAndItemsField() throws Exception {
|
||||
when(skillSearchAppService.search(
|
||||
|
|
@ -143,4 +150,59 @@ 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("include", "labels"))
|
||||
.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("include", "labels"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].labels").isArray())
|
||||
.andExpect(jsonPath("$.data.items[0].labels").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchShouldRejectUnsupportedIncludeOptions() throws Exception {
|
||||
mockMvc.perform(get("/api/web/skills").param("include", "labels,stats"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(400));
|
||||
|
||||
verifyNoInteractions(skillSearchAppService);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
package com.iflytek.skillhub.controller.support;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class IncludeOptionsTest {
|
||||
|
||||
@Test
|
||||
void includesLabels_acceptsAbsentBlankRepeatedAndCommaSeparatedInputs() {
|
||||
assertThat(IncludeOptions.includesLabels(null)).isFalse();
|
||||
assertThat(IncludeOptions.includesLabels(List.of("", " "))).isFalse();
|
||||
assertThat(IncludeOptions.includesLabels(List.of("labels"))).isTrue();
|
||||
assertThat(IncludeOptions.includesLabels(List.of(" LABELS "))).isTrue();
|
||||
assertThat(IncludeOptions.includesLabels(List.of("labels,"))).isTrue();
|
||||
assertThat(IncludeOptions.includesLabels(List.of("", "labels"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void includesLabels_rejectsUnsupportedOptions() {
|
||||
assertThatThrownBy(() -> IncludeOptions.includesLabels(List.of("labels,stats")))
|
||||
.isInstanceOf(DomainBadRequestException.class)
|
||||
.hasMessage("error.request.include.unsupported");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
6
web/src/api/generated/schema.d.ts
vendored
6
web/src/api/generated/schema.d.ts
vendored
|
|
@ -4353,6 +4353,7 @@ export interface components {
|
|||
ownerPreviewVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
|
||||
resolutionMode?: string;
|
||||
complianceSnapshot?: components["schemas"]["ComplianceSnapshotResponse"];
|
||||
labels?: components["schemas"]["SkillLabelDto"][];
|
||||
};
|
||||
ApiResponseBoolean: {
|
||||
/** Format: int32 */
|
||||
|
|
@ -4980,6 +4981,7 @@ export interface components {
|
|||
/** Format: int64 */
|
||||
updatedAt?: number;
|
||||
latestVersion?: components["schemas"]["LatestVersion"];
|
||||
labels?: components["schemas"]["SkillLabelDto"][];
|
||||
};
|
||||
ApiResponseListSecurityAuditResponse: {
|
||||
/** Format: int32 */
|
||||
|
|
@ -8002,6 +8004,8 @@ export interface operations {
|
|||
page?: number;
|
||||
limit?: number;
|
||||
sort?: string;
|
||||
/** @description Optional response expansions. Supported value: labels */
|
||||
include?: string[];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -9039,6 +9043,8 @@ export interface operations {
|
|||
q?: string;
|
||||
namespace?: string;
|
||||
label?: string[];
|
||||
/** @description Optional response expansions. Supported value: labels */
|
||||
include?: string[];
|
||||
sort?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue