From e1c773774895e9f63296e9f8e66070f36b88245d Mon Sep 17 00:00:00 2001 From: paradoxgacsd <3154222708@qq.com> Date: Sat, 16 May 2026 14:16:59 +0800 Subject: [PATCH 1/2] Improve skill discovery cards and labels Expose labels in skill summaries, support label attachment during publish, and refresh the public discovery UI. Tested: pnpm exec tsc --noEmit; pnpm run build; git diff --check; scanned staged diff and tracked config files for secrets. --- .../portal/SkillPublishController.java | 56 ++- .../iflytek/skillhub/dto/PublishResponse.java | 5 +- .../skillhub/dto/SkillSummaryResponse.java | 4 +- .../repository/JpaMySkillQueryRepository.java | 3 +- .../service/SkillLabelAppService.java | 78 ++++ .../service/SkillPublishAppService.java | 61 +++ .../service/SkillSearchAppService.java | 19 +- .../compat/ClawHubCompatControllerTest.java | 3 +- .../compat/ClawHubRegistryFacadeTest.java | 3 +- .../skillhub/controller/MeControllerTest.java | 3 +- .../portal/SkillPublishControllerTest.java | 110 +++++- .../service/SkillLabelAppServiceTest.java | 74 ++++ .../service/SkillPublishAppServiceTest.java | 115 ++++++ .../service/SkillSearchAppServiceTest.java | 8 +- .../service/cli/CliSkillAppServiceTest.java | 2 +- .../label/LabelDefinitionRepository.java | 1 + .../domain/label/SkillLabelRepository.java | 1 + .../domain/label/SkillLabelService.java | 88 +++++ .../skill/service/SkillPublishService.java | 25 +- .../domain/label/SkillLabelServiceTest.java | 56 +++ .../jpa/LabelDefinitionJpaRepository.java | 1 + web/src/api/types.ts | 2 + web/src/app/layout.tsx | 82 +--- web/src/features/skill/skill-card.tsx | 122 ++++-- web/src/index.css | 120 ++++-- web/src/pages/landing.tsx | 365 +++++++----------- .../shared/components/landing-quick-start.tsx | 74 +++- web/src/shared/hooks/use-skill-queries.ts | 17 +- 28 files changed, 1082 insertions(+), 416 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPublishAppServiceTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index a061afc1e..2ab465371 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -3,21 +3,25 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.SkillVisibility; -import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; +import com.iflytek.skillhub.service.SkillPublishAppService; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; /** * Upload endpoints for skill packages. @@ -29,18 +33,18 @@ @RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillPublishController extends BaseApiController { - private final SkillPublishService skillPublishService; private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; private final SkillHubMetrics skillHubMetrics; + private final SkillPublishAppService skillPublishAppService; - public SkillPublishController(SkillPublishService skillPublishService, - SkillPackageArchiveExtractor skillPackageArchiveExtractor, + public SkillPublishController(SkillPackageArchiveExtractor skillPackageArchiveExtractor, ApiResponseFactory responseFactory, - SkillHubMetrics skillHubMetrics) { + SkillHubMetrics skillHubMetrics, + SkillPublishAppService skillPublishAppService) { super(responseFactory); - this.skillPublishService = skillPublishService; this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; this.skillHubMetrics = skillHubMetrics; + this.skillPublishAppService = skillPublishAppService; } /** @@ -54,8 +58,14 @@ public ApiResponse publish( @RequestParam("file") MultipartFile file, @RequestParam("visibility") String visibility, @RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings, + @RequestParam(value = "labels", required = false) List labels, + @RequestParam(value = "summary", required = false) String summary, + @RequestParam(value = "description", required = false) String description, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { + List normalizedLabels = normalizeLabels(labels); + String summaryOverride = resolveSummaryOverride(summary, description); SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); List entries; @@ -75,14 +85,18 @@ public ApiResponse publish( String.join("\n", extractionWarnings)); } - SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( + SkillPublishAppService.PublishOutcome publishOutcome = skillPublishAppService.publishFromEntries( namespace, entries, principal.userId(), skillVisibility, principal.platformRoles(), - confirmWarnings + confirmWarnings, + summaryOverride, + normalizedLabels, + userNsRoles == null ? Map.of() : userNsRoles ); + var publishResult = publishOutcome.publishResult(); PublishResponse response = new PublishResponse( publishResult.skillId(), @@ -91,10 +105,34 @@ public ApiResponse publish( publishResult.version().getVersion(), publishResult.version().getStatus().name(), publishResult.version().getFileCount(), - publishResult.version().getTotalSize() + publishResult.version().getTotalSize(), + publishOutcome.labels() ); skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name()); return ok("response.success.published", response); } + + private List normalizeLabels(List labels) { + if (labels == null) { + return Collections.emptyList(); + } + return labels.stream() + .map(String::trim) + .filter(label -> !label.isBlank()) + .collect(java.util.stream.Collectors.collectingAndThen( + java.util.stream.Collectors.toCollection(LinkedHashSet::new), + List::copyOf + )); + } + + private String resolveSummaryOverride(String summary, String description) { + if (summary != null && !summary.isBlank()) { + return summary.trim(); + } + if (description != null && !description.isBlank()) { + return description.trim(); + } + return null; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java index 6b62ccebe..a3fc32835 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.dto; +import java.util.List; + public record PublishResponse( Long skillId, String namespace, @@ -7,5 +9,6 @@ public record PublishResponse( String version, String status, int fileCount, - long totalSize + long totalSize, + List labels ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index 0948756ee..4b452230e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -2,6 +2,7 @@ import java.math.BigDecimal; import java.time.Instant; +import java.util.List; public record SkillSummaryResponse( Long id, @@ -20,5 +21,6 @@ public record SkillSummaryResponse( SkillLifecycleVersionResponse headlineVersion, SkillLifecycleVersionResponse publishedVersion, SkillLifecycleVersionResponse ownerPreviewVersion, - String resolutionMode + String resolutionMode, + List labels ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java index d9c970413..577d55d39 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java @@ -79,7 +79,8 @@ private SkillSummaryResponse toSummaryResponse(Skill skill, toLifecycleVersion(headlineVersion), toLifecycleVersion(publishedVersion), toLifecycleVersion(ownerPreviewVersion), - projection.resolutionMode().name() + projection.resolutionMode().name(), + List.of() ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelAppService.java index 25b92081f..60883165a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelAppService.java @@ -77,6 +77,24 @@ public List listSkillLabelsBySkillId(Long skillId) { return toDtos(skillLabelService.listSkillLabels(skillId)); } + public Map> listSkillLabelsBySkillIds(List skillIds) { + if (skillIds == null || skillIds.isEmpty()) { + return Map.of(); + } + List normalizedSkillIds = skillIds.stream() + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + if (normalizedSkillIds.isEmpty()) { + return Map.of(); + } + + Map> labelsBySkillId = skillLabelService.listSkillLabelsBySkillIds(normalizedSkillIds) + .stream() + .collect(Collectors.groupingBy(SkillLabel::getSkillId)); + return toDtosBySkillId(labelsBySkillId); + } + @Transactional public SkillLabelDto attachLabel(String namespaceSlug, String skillSlug, @@ -97,6 +115,31 @@ public SkillLabelDto attachLabel(String namespaceSlug, return toDtos(List.of(attached)).getFirst(); } + @Transactional + public List attachLabels(String namespaceSlug, + String skillSlug, + List labelSlugs, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + if (labelSlugs == null || labelSlugs.isEmpty()) { + return List.of(); + } + Skill skill = resolveSkill(namespaceSlug, skillSlug, userId); + List attachedLabels = skillLabelService.attachLabels( + skill.getId(), + labelSlugs, + userId, + normalizeRoles(userNsRoles), + platformRoles(userId) + ); + afterCommit(() -> labelSearchSyncService.rebuildSkill(skill.getId())); + for (String labelSlug : labelSlugs) { + recordAudit("SKILL_LABEL_ATTACH", userId, skill.getId(), auditContext, "{\"labelSlug\":\"" + labelSlug + "\"}"); + } + return toDtos(attachedLabels); + } + @Transactional public MessageResponse detachLabel(String namespaceSlug, String skillSlug, @@ -144,6 +187,41 @@ private List toDtos(List skillLabels) { .toList(); } + private Map> toDtosBySkillId(Map> labelsBySkillId) { + if (labelsBySkillId.isEmpty()) { + return Map.of(); + } + List allSkillLabels = labelsBySkillId.values().stream() + .flatMap(List::stream) + .toList(); + List labelIds = allSkillLabels.stream() + .map(SkillLabel::getLabelId) + .distinct() + .toList(); + Map definitionsById = labelDefinitionService.listByIds(labelIds).stream() + .collect(Collectors.toMap(LabelDefinition::getId, Function.identity())); + Map> translationsByLabelId = labelDefinitionService.listTranslationsByLabelIds(labelIds); + + return labelsBySkillId.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> entry.getValue().stream() + .filter(skillLabel -> definitionsById.containsKey(skillLabel.getLabelId())) + .map(skillLabel -> { + LabelDefinition definition = definitionsById.get(skillLabel.getLabelId()); + return new SkillLabelDto( + definition.getSlug(), + definition.getType().name(), + labelLocalizationService.resolveDisplayName( + definition.getSlug(), + translationsByLabelId.getOrDefault(definition.getId(), List.of())) + ); + }) + .sorted(java.util.Comparator.comparing(SkillLabelDto::type).thenComparing(SkillLabelDto::slug)) + .toList() + )); + } + private Skill resolveSkill(String namespaceSlug, String skillSlug, String userId) { Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", namespaceSlug)); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java new file mode 100644 index 000000000..a27c2b914 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java @@ -0,0 +1,61 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SkillPublishAppService { + + private final SkillPublishService skillPublishService; + private final SkillLabelAppService skillLabelAppService; + + public SkillPublishAppService(SkillPublishService skillPublishService, + SkillLabelAppService skillLabelAppService) { + this.skillPublishService = skillPublishService; + this.skillLabelAppService = skillLabelAppService; + } + + @Transactional + public PublishOutcome publishFromEntries(String namespace, + List entries, + String publisherId, + SkillVisibility visibility, + Set platformRoles, + boolean confirmWarnings, + String summaryOverride, + List labelSlugs, + Map userNsRoles) { + SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( + namespace, + entries, + publisherId, + visibility, + platformRoles, + confirmWarnings, + summaryOverride + ); + if (labelSlugs != null && !labelSlugs.isEmpty()) { + skillLabelAppService.attachLabels( + namespace, + publishResult.slug(), + labelSlugs, + publisherId, + userNsRoles, + null + ); + } + return new PublishOutcome(publishResult, labelSlugs == null ? List.of() : labelSlugs); + } + + public record PublishOutcome( + SkillPublishService.PublishResult publishResult, + List labels + ) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index bffa778a9..434a6e8c5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; +import com.iflytek.skillhub.dto.SkillLabelDto; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.search.SearchQuery; import com.iflytek.skillhub.search.SearchQueryService; @@ -37,6 +38,7 @@ public class SkillSearchAppService { private final NamespaceService namespaceService; private final SkillLifecycleProjectionService skillLifecycleProjectionService; private final RbacService rbacService; + private final SkillLabelAppService skillLabelAppService; public SkillSearchAppService( SearchQueryService searchQueryService, @@ -44,13 +46,15 @@ public SkillSearchAppService( NamespaceRepository namespaceRepository, NamespaceService namespaceService, SkillLifecycleProjectionService skillLifecycleProjectionService, - RbacService rbacService) { + RbacService rbacService, + SkillLabelAppService skillLabelAppService) { this.searchQueryService = searchQueryService; this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.namespaceService = namespaceService; this.skillLifecycleProjectionService = skillLifecycleProjectionService; this.rbacService = rbacService; + this.skillLabelAppService = skillLabelAppService; } public record SearchResponse( @@ -179,18 +183,24 @@ private List mapVisibleSkillSummaries(List skillIds) .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getSlug())); Map projectionsBySkillId = skillLifecycleProjectionService.projectPublishedSummaries(matchedSkills); + Map> labelsBySkillId = skillLabelAppService.listSkillLabelsBySkillIds(skillIds); return skillIds.stream() .map(skillsById::get) .filter(java.util.Objects::nonNull) - .map(skill -> toSummaryResponse(skill, namespaceSlugsById, projectionsBySkillId.get(skill.getId()))) + .map(skill -> toSummaryResponse( + skill, + namespaceSlugsById, + projectionsBySkillId.get(skill.getId()), + labelsBySkillId.getOrDefault(skill.getId(), List.of()))) .toList(); } private SkillSummaryResponse toSummaryResponse( Skill skill, Map namespaceSlugsById, - SkillLifecycleProjectionService.Projection projection) { + SkillLifecycleProjectionService.Projection projection, + List labels) { String namespaceSlug = namespaceSlugsById.get(skill.getNamespaceId()); return new SkillSummaryResponse( @@ -210,7 +220,8 @@ private SkillSummaryResponse toSummaryResponse( toLifecycleVersion(projection.headlineVersion()), toLifecycleVersion(projection.publishedVersion()), toLifecycleVersion(projection.ownerPreviewVersion()), - projection.resolutionMode().name() + projection.resolutionMode().name(), + labels ); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index cb4c0fc8c..9df2570c8 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -114,7 +114,8 @@ void search_returns_mapped_results() throws Exception { new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"), new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"), null, - "PUBLISHED")), + "PUBLISHED", + List.of())), 1, 0, 20 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java index 27d825dc8..9c02796d4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java @@ -55,7 +55,8 @@ void search_mapsInstantToEpochMillis() { new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), null, - "PUBLISHED" + "PUBLISHED", + List.of() )), 1, 0, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java index af16f4ddb..81f1571e6 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java @@ -75,7 +75,8 @@ void listMySkills_returns_paginated_items() throws Exception { new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), null, - "PUBLISHED" + "PUBLISHED", + List.of() )), 9, 1, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java index 8b8c714a1..9247c8f87 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java @@ -2,6 +2,7 @@ import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -23,6 +24,7 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.service.SkillPublishAppService; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.util.List; @@ -54,6 +56,9 @@ class SkillPublishControllerTest { @MockBean private SkillPublishService skillPublishService; + @MockBean + private SkillPublishAppService skillPublishAppService; + @MockBean private NamespaceMemberRepository namespaceMemberRepository; @@ -71,14 +76,20 @@ void publish_recordsMetricsAfterSuccess() throws Exception { version.setTotalSize(128L); ReflectionTestUtils.setField(version, "id", 34L); - given(skillPublishService.publishFromEntries( + given(skillPublishAppService.publishFromEntries( eq("global"), ArgumentMatchers.>any(), eq("usr_1"), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), - eq(false))) - .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); + eq(false), + isNull(), + eq(List.of()), + eq(java.util.Map.of()))) + .willReturn(new SkillPublishAppService.PublishOutcome( + new SkillPublishService.PublishResult(12L, "demo-skill", version), + List.of() + )); PlatformPrincipal principal = new PlatformPrincipal( "usr_1", @@ -122,14 +133,20 @@ void publish_passesWarningConfirmationFlag() throws Exception { version.setTotalSize(128L); ReflectionTestUtils.setField(version, "id", 34L); - given(skillPublishService.publishFromEntries( + given(skillPublishAppService.publishFromEntries( eq("global"), ArgumentMatchers.>any(), eq("usr_1"), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), - eq(true))) - .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); + eq(true), + isNull(), + eq(List.of()), + eq(java.util.Map.of()))) + .willReturn(new SkillPublishAppService.PublishOutcome( + new SkillPublishService.PublishResult(12L, "demo-skill", version), + List.of() + )); PlatformPrincipal principal = new PlatformPrincipal( "usr_1", @@ -182,9 +199,9 @@ void publish_nestedSkillMdReturnsWarningForIgnoredFiles() throws Exception { .andExpect(jsonPath("$.msg").value( org.hamcrest.Matchers.containsString("stray.txt"))); - verify(skillPublishService, never()).publishFromEntries( + verify(skillPublishAppService, never()).publishFromEntries( eq("global"), anyList(), eq("usr_1"), - eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false)); + eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false), isNull(), eq(List.of()), eq(java.util.Map.of())); } @Test @@ -195,11 +212,14 @@ void publish_nestedSkillMdSucceedsWithConfirmWarnings() throws Exception { version.setTotalSize(128L); ReflectionTestUtils.setField(version, "id", 34L); - given(skillPublishService.publishFromEntries( + given(skillPublishAppService.publishFromEntries( eq("global"), ArgumentMatchers.>any(), eq("usr_1"), eq(SkillVisibility.PUBLIC), - eq(Set.of("SUPER_ADMIN")), eq(true))) - .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); + eq(Set.of("SUPER_ADMIN")), eq(true), isNull(), eq(List.of()), eq(java.util.Map.of()))) + .willReturn(new SkillPublishAppService.PublishOutcome( + new SkillPublishService.PublishResult(12L, "demo-skill", version), + List.of() + )); PlatformPrincipal principal = new PlatformPrincipal( "usr_1", "publisher", "publisher@example.com", "", "local", Set.of("SUPER_ADMIN")); @@ -220,6 +240,74 @@ void publish_nestedSkillMdSucceedsWithConfirmWarnings() throws Exception { .andExpect(jsonPath("$.code").value(0)); } + @Test + void publish_acceptsLabelsAndSummaryOverride() throws Exception { + SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + version.setFileCount(1); + version.setTotalSize(128L); + ReflectionTestUtils.setField(version, "id", 34L); + + given(skillPublishAppService.publishFromEntries( + eq("global"), + ArgumentMatchers.>any(), + eq("usr_1"), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(false), + eq("Request summary"), + eq(List.of("official", "featured")), + eq(java.util.Map.of()))) + .willReturn(new SkillPublishAppService.PublishOutcome( + new SkillPublishService.PublishResult(12L, "demo-skill", version), + List.of("official", "featured") + )); + + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "publisher", + "publisher@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "skill.zip", + "application/zip", + buildZipBytes() + ); + + mockMvc.perform(multipart("/api/v1/skills/global/publish") + .file(file) + .param("visibility", "PUBLIC") + .param("labels", "official", " official ", "featured") + .param("summary", " Request summary ") + .param("description", "Description fallback") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.labels[0]").value("official")) + .andExpect(jsonPath("$.data.labels[1]").value("featured")); + verify(skillPublishAppService).publishFromEntries( + eq("global"), + ArgumentMatchers.>any(), + eq("usr_1"), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(false), + eq("Request summary"), + eq(List.of("official", "featured")), + eq(java.util.Map.of()) + ); + } + private byte[] buildZipBytes() throws Exception { try (ByteArrayOutputStream output = new ByteArrayOutputStream(); ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelAppServiceTest.java index e51b4e9a6..d7558d19c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelAppServiceTest.java @@ -2,7 +2,9 @@ import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.domain.audit.AuditLogService; +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.LabelTranslation; import com.iflytek.skillhub.domain.label.SkillLabel; import com.iflytek.skillhub.domain.label.SkillLabelService; @@ -29,6 +31,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -124,6 +127,77 @@ void listSkillLabels_shouldStillRejectWhenResolvedSkillIsActuallyNotAccessible() )); } + @Test + void listSkillLabelsBySkillIds_shouldLoadLabelsAndDefinitionsInBatches() throws Exception { + SkillLabel firstSkillOfficial = new SkillLabel(10L, 100L, "user-1"); + SkillLabel secondSkillFeatured = new SkillLabel(11L, 101L, "user-1"); + LabelDefinition official = new LabelDefinition("official", LabelType.RECOMMENDED, true, 1, "admin"); + LabelDefinition featured = new LabelDefinition("featured", LabelType.RECOMMENDED, true, 2, "admin"); + setId(official, 100L); + setId(featured, 101L); + LabelTranslation officialTranslation = new LabelTranslation(100L, "en", "Official"); + LabelTranslation featuredTranslation = new LabelTranslation(101L, "en", "Featured"); + + when(skillLabelService.listSkillLabelsBySkillIds(List.of(10L, 11L))) + .thenReturn(List.of(firstSkillOfficial, secondSkillFeatured)); + when(labelDefinitionService.listByIds(List.of(100L, 101L))).thenReturn(List.of(official, featured)); + when(labelDefinitionService.listTranslationsByLabelIds(List.of(100L, 101L))) + .thenReturn(Map.of( + 100L, List.of(officialTranslation), + 101L, List.of(featuredTranslation) + )); + when(labelLocalizationService.resolveDisplayName("official", List.of(officialTranslation))).thenReturn("Official"); + when(labelLocalizationService.resolveDisplayName("featured", List.of(featuredTranslation))).thenReturn("Featured"); + + Map> result = + service.listSkillLabelsBySkillIds(List.of(10L, 11L, 10L)); + + assertEquals(List.of("official"), result.get(10L).stream().map(com.iflytek.skillhub.dto.SkillLabelDto::slug).toList()); + assertEquals(List.of("featured"), result.get(11L).stream().map(com.iflytek.skillhub.dto.SkillLabelDto::slug).toList()); + verify(skillLabelService, times(1)).listSkillLabelsBySkillIds(List.of(10L, 11L)); + verify(labelDefinitionService, times(1)).listByIds(List.of(100L, 101L)); + verify(labelDefinitionService, times(1)).listTranslationsByLabelIds(List.of(100L, 101L)); + } + + @Test + void attachLabels_shouldResolveSkillOnceAndRebuildSearchOnce() throws Exception { + Namespace namespace = new Namespace("global", "Global", "ns-owner"); + setId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "user-1", SkillVisibility.PUBLIC); + setId(skill, 10L); + + SkillLabel officialLabel = new SkillLabel(10L, 100L, "user-1"); + SkillLabel featuredLabel = new SkillLabel(10L, 101L, "user-1"); + LabelDefinition official = new LabelDefinition("official", LabelType.RECOMMENDED, true, 1, "admin"); + LabelDefinition featured = new LabelDefinition("featured", LabelType.RECOMMENDED, true, 2, "admin"); + setId(official, 100L); + setId(featured, 101L); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).thenReturn(List.of(skill)); + when(rbacService.getUserRoleCodes("user-1")).thenReturn(Set.of("SUPER_ADMIN")); + when(skillLabelService.attachLabels(10L, List.of("official", "featured"), "user-1", Map.of(), Set.of("SUPER_ADMIN"))) + .thenReturn(List.of(officialLabel, featuredLabel)); + when(labelDefinitionService.listByIds(List.of(100L, 101L))).thenReturn(List.of(official, featured)); + when(labelDefinitionService.listTranslationsByLabelIds(List.of(100L, 101L))).thenReturn(Map.of()); + when(labelLocalizationService.resolveDisplayName("official", List.of())).thenReturn("Official"); + when(labelLocalizationService.resolveDisplayName("featured", List.of())).thenReturn("Featured"); + + List result = service.attachLabels( + "global", + "demo-skill", + List.of("official", "featured"), + "user-1", + Map.of(), + null + ); + + assertEquals(List.of("official", "featured"), result.stream().map(com.iflytek.skillhub.dto.SkillLabelDto::slug).toList()); + verify(skillRepository, times(1)).findByNamespaceIdAndSlug(1L, "demo-skill"); + verify(skillLabelService, times(1)).attachLabels(10L, List.of("official", "featured"), "user-1", Map.of(), Set.of("SUPER_ADMIN")); + verify(labelSearchSyncService, times(1)).rebuildSkill(10L); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPublishAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPublishAppServiceTest.java new file mode 100644 index 000000000..b463a8860 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPublishAppServiceTest.java @@ -0,0 +1,115 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SkillPublishAppServiceTest { + + @Mock + private SkillPublishService skillPublishService; + + @Mock + private SkillLabelAppService skillLabelAppService; + + @Test + void publishFromEntries_shouldAttachLabelsInSingleBatchAfterPublish() { + SkillVersion version = new SkillVersion(12L, "1.0.0", "user-1"); + SkillPublishService.PublishResult publishResult = + new SkillPublishService.PublishResult(12L, "demo-skill", version); + List entries = List.of(new PackageEntry("SKILL.md", new byte[0], 0, "text/markdown")); + Map roles = Map.of(1L, NamespaceRole.OWNER); + + when(skillPublishService.publishFromEntries( + "global", + entries, + "user-1", + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + false, + "summary" + )).thenReturn(publishResult); + + SkillPublishAppService service = new SkillPublishAppService(skillPublishService, skillLabelAppService); + + SkillPublishAppService.PublishOutcome outcome = service.publishFromEntries( + "global", + entries, + "user-1", + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + false, + "summary", + List.of("official", "featured"), + roles + ); + + assertEquals(publishResult, outcome.publishResult()); + assertEquals(List.of("official", "featured"), outcome.labels()); + verify(skillLabelAppService).attachLabels( + "global", + "demo-skill", + List.of("official", "featured"), + "user-1", + roles, + null + ); + } + + @Test + void publishFromEntries_shouldSkipAttachWhenLabelsAreEmpty() { + SkillVersion version = new SkillVersion(12L, "1.0.0", "user-1"); + SkillPublishService.PublishResult publishResult = + new SkillPublishService.PublishResult(12L, "demo-skill", version); + List entries = List.of(new PackageEntry("SKILL.md", new byte[0], 0, "text/markdown")); + + when(skillPublishService.publishFromEntries( + "global", + entries, + "user-1", + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + false, + null + )).thenReturn(publishResult); + + SkillPublishAppService service = new SkillPublishAppService(skillPublishService, skillLabelAppService); + + SkillPublishAppService.PublishOutcome outcome = service.publishFromEntries( + "global", + entries, + "user-1", + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + false, + null, + List.of(), + Map.of() + ); + + assertEquals(List.of(), outcome.labels()); + verify(skillLabelAppService, never()).attachLabels( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyList(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyMap(), + org.mockito.ArgumentMatchers.isNull() + ); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index fdac46f83..5d60c016b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -33,6 +33,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; @ExtendWith(MockitoExtension.class) class SkillSearchAppServiceTest { @@ -55,17 +56,22 @@ class SkillSearchAppServiceTest { @Mock private RbacService rbacService; + @Mock + private SkillLabelAppService skillLabelAppService; + private SkillSearchAppService service; @BeforeEach void setUp() { + lenient().when(skillLabelAppService.listSkillLabelsBySkillIds(anyList())).thenReturn(Map.of()); service = new SkillSearchAppService( searchQueryService, skillRepository, namespaceRepository, namespaceService, new SkillLifecycleProjectionService(skillVersionRepository), - rbacService + rbacService, + skillLabelAppService ); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java index b7fbe1d7f..8a8a97460 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java @@ -58,7 +58,7 @@ void search_mapsResultsToCliFormat() { "global", Instant.now(), false, new SkillLifecycleVersionResponse(1L, "1.2.0", "PUBLISHED"), new SkillLifecycleVersionResponse(1L, "1.2.0", "PUBLISHED"), - null, "PUBLISHED" + null, "PUBLISHED", List.of() )), 1L, 0, 20 ); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/LabelDefinitionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/LabelDefinitionRepository.java index 45756b793..36e488561 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/LabelDefinitionRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/LabelDefinitionRepository.java @@ -13,6 +13,7 @@ public interface LabelDefinitionRepository { List findByVisibleInFilterTrueAndTypeOrderBySortOrderAscIdAsc(LabelType type); List findByVisibleInFilterTrueOrderBySortOrderAscSlugAsc(); List findByIdIn(List ids); + List findBySlugIn(List slugs); long count(); LabelDefinition save(LabelDefinition labelDefinition); List saveAll(Iterable labelDefinitions); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillLabelRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillLabelRepository.java index 82e30efde..c6113661f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillLabelRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillLabelRepository.java @@ -10,5 +10,6 @@ public interface SkillLabelRepository { Optional findBySkillIdAndLabelId(Long skillId, Long labelId); long countBySkillId(Long skillId); SkillLabel save(SkillLabel skillLabel); + List saveAll(Iterable skillLabels); void delete(SkillLabel skillLabel); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillLabelService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillLabelService.java index 9c699cb5f..3d85bff92 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillLabelService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillLabelService.java @@ -5,9 +5,12 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,6 +41,20 @@ public List listSkillLabels(Long skillId) { return skillLabelRepository.findBySkillId(skillId); } + public List listSkillLabelsBySkillIds(List skillIds) { + if (skillIds == null || skillIds.isEmpty()) { + return List.of(); + } + List normalizedSkillIds = skillIds.stream() + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + if (normalizedSkillIds.isEmpty()) { + return List.of(); + } + return skillLabelRepository.findBySkillIdIn(normalizedSkillIds); + } + public List listByLabelId(Long labelId) { return skillLabelRepository.findByLabelId(labelId); } @@ -60,6 +77,77 @@ public SkillLabel attachLabel(Long skillId, .orElseGet(() -> skillLabelRepository.save(new SkillLabel(skillId, labelDefinition.getId(), operatorId))); } + @Transactional + public List attachLabels(Long skillId, + List labelSlugs, + String operatorId, + Map userNamespaceRoles, + Set platformRoles) { + if (labelSlugs == null || labelSlugs.isEmpty()) { + return List.of(); + } + + Skill skill = findSkill(skillId); + List normalizedSlugs = labelSlugs.stream() + .map(LabelSlugValidator::normalize) + .distinct() + .toList(); + if (normalizedSlugs.isEmpty()) { + return List.of(); + } + + Map definitionsBySlug = labelDefinitionRepository.findBySlugIn(normalizedSlugs) + .stream() + .collect(Collectors.toMap( + definition -> definition.getSlug().toLowerCase(java.util.Locale.ROOT), + Function.identity() + )); + for (String normalizedSlug : normalizedSlugs) { + if (!definitionsBySlug.containsKey(normalizedSlug.toLowerCase(java.util.Locale.ROOT))) { + throw new DomainBadRequestException("label.not_found", normalizedSlug); + } + } + + List labelDefinitions = normalizedSlugs.stream() + .map(slug -> definitionsBySlug.get(slug.toLowerCase(java.util.Locale.ROOT))) + .toList(); + for (LabelDefinition labelDefinition : labelDefinitions) { + requireSkillLabelPermission(skill, labelDefinition, operatorId, userNamespaceRoles, platformRoles); + } + + List existingLabels = skillLabelRepository.findBySkillId(skillId); + Map existingByLabelId = existingLabels.stream() + .collect(Collectors.toMap(SkillLabel::getLabelId, Function.identity(), (left, right) -> left)); + int newLabelCount = (int) labelDefinitions.stream() + .map(LabelDefinition::getId) + .filter(labelId -> !existingByLabelId.containsKey(labelId)) + .count(); + if (existingLabels.size() + newLabelCount > maxLabelsPerSkill) { + throw new DomainBadRequestException("label.skill.too_many", skillId, maxLabelsPerSkill); + } + + List attachedLabels = new ArrayList<>(labelDefinitions.size()); + List labelsToSave = new ArrayList<>(); + for (LabelDefinition labelDefinition : labelDefinitions) { + SkillLabel existingLabel = existingByLabelId.get(labelDefinition.getId()); + if (existingLabel != null) { + attachedLabels.add(existingLabel); + } else { + SkillLabel newLabel = new SkillLabel(skillId, labelDefinition.getId(), operatorId); + labelsToSave.add(newLabel); + attachedLabels.add(newLabel); + } + } + if (!labelsToSave.isEmpty()) { + Map savedByLabelId = skillLabelRepository.saveAll(labelsToSave).stream() + .collect(Collectors.toMap(SkillLabel::getLabelId, Function.identity(), (left, right) -> left)); + return attachedLabels.stream() + .map(label -> savedByLabelId.getOrDefault(label.getLabelId(), label)) + .toList(); + } + return attachedLabels; + } + @Transactional public void detachLabel(Long skillId, String labelSlug, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 98d8d8093..b1cb6fa73 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -143,7 +143,19 @@ public PublishResult publishFromEntries( SkillVisibility visibility, java.util.Set platformRoles, boolean confirmWarnings) { - return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, confirmWarnings, false, false); + return publishFromEntries(namespaceSlug, entries, publisherId, visibility, platformRoles, confirmWarnings, null); + } + + @Transactional + public PublishResult publishFromEntries( + String namespaceSlug, + List entries, + String publisherId, + SkillVisibility visibility, + java.util.Set platformRoles, + boolean confirmWarnings, + String summaryOverride) { + return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, confirmWarnings, false, false, summaryOverride); } /** @@ -184,7 +196,8 @@ public PublishResult rereleasePublishedVersion( Set.of(), confirmWarnings, // confirmWarnings: honour caller's choice for rerelease false, // forceAutoPublish=false: respect visibility rules - true + true, + null ); } @@ -196,7 +209,8 @@ private PublishResult publishFromEntriesInternal( Set platformRoles, boolean confirmWarnings, boolean forceAutoPublish, - boolean bypassMembershipCheck) { + boolean bypassMembershipCheck, + String summaryOverride) { // 1. Find namespace by slug Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) @@ -411,7 +425,10 @@ private PublishResult publishFromEntriesInternal( // 12. Update skill metadata and move the published pointer for auto-publish flows skill.setDisplayName(metadata.name()); - skill.setSummary(metadata.description()); + String resolvedSummary = summaryOverride != null && !summaryOverride.isBlank() + ? summaryOverride.trim() + : metadata.description(); + skill.setSummary(resolvedSummary); if (autoPublish || visibility == SkillVisibility.PRIVATE) { // Update latestVersionId for autoPublish or PRIVATE skill (UPLOADED status) skill.setLatestVersionId(version.getId()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/label/SkillLabelServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/label/SkillLabelServiceTest.java index ea3db73d9..fdf1d5d8e 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/label/SkillLabelServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/label/SkillLabelServiceTest.java @@ -1,10 +1,19 @@ package com.iflytek.skillhub.domain.label; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.mockito.Mockito.mock; class SkillLabelServiceTest { @@ -26,4 +35,51 @@ void constructorShouldRejectNonPositivePerSkillLimit() { assertEquals("skillhub.label.max-per-skill must be greater than 0", ex.getMessage()); } + + @Test + void attachLabelsShouldLoadDefinitionsAndExistingLabelsInBatches() throws Exception { + SkillLabelService service = new SkillLabelService( + skillRepository, + labelDefinitionRepository, + skillLabelRepository, + labelPermissionChecker, + 10 + ); + Skill skill = new Skill(1L, "demo", "user-1", SkillVisibility.PUBLIC); + setId(skill, 10L); + LabelDefinition official = new LabelDefinition("official", LabelType.RECOMMENDED, true, 1, "admin"); + LabelDefinition featured = new LabelDefinition("featured", LabelType.RECOMMENDED, true, 2, "admin"); + setId(official, 100L); + setId(featured, 101L); + + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + when(labelDefinitionRepository.findBySlugIn(List.of("official", "featured"))).thenReturn(List.of(official, featured)); + when(labelPermissionChecker.canManageSkillLabel(skill, official, "user-1", Map.of(), Set.of("SUPER_ADMIN"))).thenReturn(true); + when(labelPermissionChecker.canManageSkillLabel(skill, featured, "user-1", Map.of(), Set.of("SUPER_ADMIN"))).thenReturn(true); + when(skillLabelRepository.findBySkillId(10L)).thenReturn(List.of()); + when(skillLabelRepository.saveAll(org.mockito.ArgumentMatchers.>any())) + .thenAnswer(invocation -> { + Iterable labels = invocation.getArgument(0); + return java.util.stream.StreamSupport.stream(labels.spliterator(), false).toList(); + }); + + List result = service.attachLabels( + 10L, + List.of("Official", "featured", "official"), + "user-1", + Map.of(), + Set.of("SUPER_ADMIN") + ); + + assertEquals(List.of(100L, 101L), result.stream().map(SkillLabel::getLabelId).toList()); + verify(labelDefinitionRepository).findBySlugIn(List.of("official", "featured")); + verify(skillLabelRepository).findBySkillId(10L); + verify(skillLabelRepository).saveAll(org.mockito.ArgumentMatchers.>any()); + } + + private void setId(Object entity, Long id) throws Exception { + Field idField = entity.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/LabelDefinitionJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/LabelDefinitionJpaRepository.java index 2cf4ef5b2..2774c1e84 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/LabelDefinitionJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/LabelDefinitionJpaRepository.java @@ -19,4 +19,5 @@ public interface LabelDefinitionJpaRepository extends JpaRepository findByVisibleInFilterTrueAndTypeOrderBySortOrderAscIdAsc(LabelType type); List findByVisibleInFilterTrueOrderBySortOrderAscSlugAsc(); List findByIdIn(List ids); + List findBySlugIn(List slugs); } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bde5ec41d..14c1c08ed 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -167,6 +167,7 @@ export interface SkillSummary { ratingAvg?: number ratingCount: number namespace: string + labels?: LabelItem[] updatedAt: string canSubmitPromotion: boolean headlineVersion?: SkillLifecycleVersion @@ -350,6 +351,7 @@ export interface PublishResult { status: string fileCount: number totalSize: number + labels?: string[] } export interface SkillDeleteResult { diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 4b48b25e1..af0db90f7 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -65,14 +65,14 @@ export function Layout() {
{/* Header */}
- + SkillHub @@ -87,8 +87,8 @@ export function Layout() { to={item.to} className={ active - ? 'px-4 py-1.5 rounded-full bg-brand-gradient text-white shadow-sm' - : 'hover:opacity-80 transition-opacity duration-150' + ? 'hover-lift px-4 py-1.5 rounded-full bg-brand-gradient text-white shadow-sm' + : 'hover-lift hover:opacity-80 transition-opacity duration-150' } > {item.label} @@ -106,7 +106,7 @@ export function Layout() { {t('nav.login')} @@ -132,75 +132,11 @@ export function Layout() { {/* Footer */} -