diff --git a/docs/25-skill-suites.md b/docs/25-skill-suites.md index b03850f4e..6d3e4d490 100644 --- a/docs/25-skill-suites.md +++ b/docs/25-skill-suites.md @@ -120,8 +120,15 @@ SMOKE_ADMIN_PASSWORD='' \ - 原有 Skill 搜索接口继续只返回 Skill。 类型化发现结果通过 `resourceType=SKILL|SUITE` 区分同名资源。Suite 详情返回固定版本、按顺序排列 -的成员快照、Entry Skill、实时可安装状态和阻塞原因。普通 Skill 详情会列出当前用户可见、以该 Skill -作为 Entry 的最新已发布 SuiteVersion,并链接到完整 Suite;Skill 的独立安装能力保持不变。 +的成员快照、Entry Skill、实时可安装状态和阻塞原因。普通 Skill 详情会列出当前用户可见、包含该 +Skill 的最新已发布 SuiteVersion,区分 Entry 和普通成员,并链接到完整 Suite。 + +市场、首页及收藏等复用技能卡片的列表通过 `hasVisibleSuites` 展示轻量的“关联套件”标识。 +该字段只依据当前用户可见、ACTIVE 且未隐藏的 Suite 当前已发布版本计算,以批量查询返回, +不会为每张卡片单独请求套件详情。卡片仍进入原 Skill 详情,在详情中查看一个或多个关联套件及 +可见的配套成员;不可访问的套件不会产生标识。Suite 隐藏、归档或新发布版本移除该 Skill 后, +重新读取列表时按最新关系计算。标识仅表示被套件收录,不代表强制依赖、唯一父套件或必须整套 +安装;Skill 的独立安装能力保持不变。 ## 部署顺序 diff --git a/docs/skillhub/guide/skill-discovery.md b/docs/skillhub/guide/skill-discovery.md index 638661d6e..8b72adb6d 100644 --- a/docs/skillhub/guide/skill-discovery.md +++ b/docs/skillhub/guide/skill-discovery.md @@ -87,6 +87,11 @@ npx clawhub install my-team--pdf-parser 点击技能包卡片,查看详细信息、版本历史、文件列表。 +如果技能被当前用户可见的套件收录,卡片会显示“关联套件”。点击卡片进入技能详情后,可以 +查看关联套件、入口或普通成员身份,以及可见的配套技能,并跳转到套件详情了解整体用法和 +安装指引。一个技能可以关联多个套件;这个标识不表示必须整套安装,也不改变单独安装技能的 +方式。不可访问的套件不会显示关联提示。 + 6. **安装使用** 找到合适的技能包后,使用 CLI 命令安装或点击「下载」按钮。 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 80a915279..ba3989087 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 @@ -35,9 +35,31 @@ public record SkillSummaryResponse( List labels, String avatarUrl, @JsonInclude(JsonInclude.Include.NON_NULL) - ManagementState managementState + ManagementState managementState, + boolean hasVisibleSuites ) { + 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, String ownerId, String ownerDisplayName, + boolean canSubmitPromotion, SkillLifecycleVersionResponse headlineVersion, + SkillLifecycleVersionResponse publishedVersion, SkillLifecycleVersionResponse ownerPreviewVersion, + String resolutionMode, ComplianceSnapshotResponse complianceSnapshot, List labels, + String avatarUrl, ManagementState managementState) { + this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg, + ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion, + headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, + labels, avatarUrl, managementState, false); + } + + public SkillSummaryResponse withVisibleSuites(boolean hasVisibleSuites) { + return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount, + starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, + canSubmitPromotion, headlineVersion, publishedVersion, ownerPreviewVersion, + resolutionMode, complianceSnapshot, labels, avatarUrl, managementState, hasVisibleSuites); + } + /** Owner workspace projection, never populated by public discovery or social lists. */ public record ManagementState(String requestedVisibility, Long reviewTaskId, String promotionRole, String relatedNamespace, String relatedSlug, @@ -66,7 +88,7 @@ public record SkillSummaryResponse( return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion && (state == null || !state.ownerReadOnly()), headlineVersion, publishedVersion, ownerPreviewVersion, - resolutionMode, complianceSnapshot, labels, avatarUrl, state); + resolutionMode, complianceSnapshot, labels, avatarUrl, state, hasVisibleSuites); } public SkillSummaryResponse( @@ -140,6 +162,6 @@ public record SkillSummaryResponse( return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion, headlineVersion, - publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels, avatarUrl, managementState); + publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels, avatarUrl, managementState, hasVisibleSuites); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteReferenceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteReferenceQueryRepository.java index 7c37a5761..0689e3acd 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteReferenceQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteReferenceQueryRepository.java @@ -33,6 +33,31 @@ public class SkillSuiteReferenceQueryRepository { this.jdbcTemplate = jdbcTemplate; } + /** Page-bounded existence projection; shares the detail projection's visibility policy. */ + @Transactional(readOnly = true) + public Set findSkillIdsWithVisibleMemberships( + List skillIds, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + if (skillIds.isEmpty()) { + return Set.of(); + } + MapSqlParameterSource parameters = viewerParameters(null, userId, namespaceRoles, platformRoles) + .addValue("skillIds", skillIds); + return Set.copyOf(jdbcTemplate.queryForList(""" + SELECT DISTINCT current_member.skill_id + FROM skill_suite suite + JOIN namespace suite_namespace ON suite_namespace.id = suite.namespace_id + JOIN skill_suite_version version ON version.id = suite.latest_version_id + JOIN skill_suite_version_member current_member + ON current_member.suite_version_id = version.id + WHERE current_member.skill_id IN (:skillIds) + AND %s + """.formatted(visibleSuitePredicate()), parameters, Long.class)); + } + /** Legacy entry-only projection retained for older clients. */ @Transactional(readOnly = true) public List findVisibleEntryReferences( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index e756d3469..8858ce540 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -46,6 +46,7 @@ public class MySkillAppService { private final HiddenSkillQueryRepository hiddenSkillQueryRepository; private final SkillLifecycleProjectionService skillLifecycleProjectionService; private final NamespaceRepository namespaceRepository; + private final SkillSuiteMembershipProjectionService suiteMembershipProjection; public MySkillAppService( SkillOwnerAccessPolicy ownerAccess, @@ -57,6 +58,24 @@ public class MySkillAppService { HiddenSkillQueryRepository hiddenSkillQueryRepository, SkillLifecycleProjectionService skillLifecycleProjectionService, NamespaceRepository namespaceRepository) { + this(ownerAccess, skillRepository, skillVersionRepository, starredSkillQueryRepository, + skillSubscriptionRepository, mySkillQueryRepository, hiddenSkillQueryRepository, + skillLifecycleProjectionService, namespaceRepository, null); + } + + @org.springframework.beans.factory.annotation.Autowired + public MySkillAppService( + SkillOwnerAccessPolicy ownerAccess, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + StarredSkillQueryRepository starredSkillQueryRepository, + SkillSubscriptionRepository skillSubscriptionRepository, + MySkillQueryRepository mySkillQueryRepository, + HiddenSkillQueryRepository hiddenSkillQueryRepository, + SkillLifecycleProjectionService skillLifecycleProjectionService, + NamespaceRepository namespaceRepository, + SkillSuiteMembershipProjectionService suiteMembershipProjection) { + this.suiteMembershipProjection = suiteMembershipProjection; this.ownerAccess = ownerAccess; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -121,7 +140,11 @@ public class MySkillAppService { state == null ? null : state.relatedSlug(), readOnly)); }).toList(); } - return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize()); + return new PageResponse<>(withSuiteMemberships(items, userId), skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize()); + } + + private List withSuiteMemberships(List items, String userId) { + return suiteMembershipProjection == null ? items : suiteMembershipProjection.enrich(items, userId); } private Page listHiddenSkills(int page, @@ -143,7 +166,7 @@ public class MySkillAppService { Page skills = starredSkillQueryRepository.findAccessible( userId, namespaceRoles, platformRoles, PageRequest.of(page, size)); List items = mySkillQueryRepository.getSkillSummaries(skills.getContent(), userId); - return new PageResponse<>(items, skills.getTotalElements(), skills.getNumber(), skills.getSize()); + return new PageResponse<>(withSuiteMemberships(items, userId), skills.getTotalElements(), skills.getNumber(), skills.getSize()); } public PageResponse listMySubscriptions(String userId, int page, int size) { @@ -167,7 +190,7 @@ public class MySkillAppService { .toList(); List items = mySkillQueryRepository.getSkillSummaries(orderedSkills, userId); - return new PageResponse<>(items, subPage.getTotalElements(), subPage.getNumber(), subPage.getSize()); + return new PageResponse<>(withSuiteMemberships(items, userId), subPage.getTotalElements(), subPage.getNumber(), subPage.getSize()); } private Page filterSkills(String userId, 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 e12ae9fa0..69f8b5a18 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 @@ -42,6 +42,7 @@ public class SkillSearchAppService { private final ComplianceSnapshotProjectionService complianceSnapshotProjectionService; private final RbacService rbacService; private final UserAccountRepository userAccountRepository; + private final SkillSuiteMembershipProjectionService suiteMembershipProjection; public SkillSearchAppService( SearchQueryService searchQueryService, @@ -62,7 +63,6 @@ public class SkillSearchAppService { ); } - @Autowired public SkillSearchAppService( SearchQueryService searchQueryService, SkillRepository skillRepository, @@ -72,6 +72,23 @@ public class SkillSearchAppService { ComplianceSnapshotProjectionService complianceSnapshotProjectionService, RbacService rbacService, UserAccountRepository userAccountRepository) { + this(searchQueryService, skillRepository, namespaceRepository, namespaceService, + skillLifecycleProjectionService, complianceSnapshotProjectionService, rbacService, + userAccountRepository, null); + } + + @Autowired + public SkillSearchAppService( + SearchQueryService searchQueryService, + SkillRepository skillRepository, + NamespaceRepository namespaceRepository, + NamespaceService namespaceService, + SkillLifecycleProjectionService skillLifecycleProjectionService, + ComplianceSnapshotProjectionService complianceSnapshotProjectionService, + RbacService rbacService, + UserAccountRepository userAccountRepository, + SkillSuiteMembershipProjectionService suiteMembershipProjection) { + this.suiteMembershipProjection = suiteMembershipProjection; this.searchQueryService = searchQueryService; this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; @@ -188,6 +205,9 @@ public class SkillSearchAppService { requireInstallableLatest )); List pageItems = mapVisibleSkillSummaries(result.skillIds(), scope.userId()); + if (suiteMembershipProjection != null) { + pageItems = suiteMembershipProjection.enrich(pageItems, scope.userId()); + } return new SearchResponse(pageItems, result.total(), page, size); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteMembershipProjectionService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteMembershipProjectionService.java new file mode 100644 index 000000000..ea7daf568 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteMembershipProjectionService.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; + +/** Adds viewer-specific Suite membership to one page, without per-card queries or search-index state. */ +@Service +public class SkillSuiteMembershipProjectionService { + private final SkillSuiteReferenceQueryRepository references; + private final NamespaceMemberRepository namespaceMembers; + private final RbacService rbacService; + + public SkillSuiteMembershipProjectionService(SkillSuiteReferenceQueryRepository references, + NamespaceMemberRepository namespaceMembers, + RbacService rbacService) { + this.references = references; + this.namespaceMembers = namespaceMembers; + this.rbacService = rbacService; + } + + public List enrich(List summaries, String userId) { + if (summaries.isEmpty()) { + return summaries; + } + Map roles = userId == null ? Map.of() + : namespaceMembers.findByUserId(userId).stream().collect(Collectors.toMap( + NamespaceMember::getNamespaceId, NamespaceMember::getRole)); + Set platformRoles = userId == null ? Set.of() : rbacService.getUserRoleCodes(userId); + Set related = references.findSkillIdsWithVisibleMemberships( + summaries.stream().map(SkillSummaryResponse::id).distinct().toList(), + userId, roles, platformRoles); + return summaries.stream().map(summary -> summary.withVisibleSuites(related.contains(summary.id()))).toList(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 9d60469f5..baff7d3b4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -127,7 +127,7 @@ class SkillControllerTest { .andExpect(jsonPath("$.components.schemas.CliResolveResponse.required.length()").value(6)) .andExpect(jsonPath("$.components.schemas.SkillVersionResponse.properties.changelog.nullable").value(true)) .andReturn().getResponse(); - Files.writeString(Path.of("target", "registry-openapi.json"), response.getContentAsString()); + Files.writeString(Path.of("target", "registry-openapi.json"), response.getContentAsString(StandardCharsets.UTF_8)); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java index a2a769b8c..a932fb7fb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java @@ -100,6 +100,107 @@ class SuiteDiscoveryIntegrationTest { entityManager.flush(); } + @Test + void batchesVisibleMembershipFlagsForOrdinaryMembersWithoutDuplicates() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("badge-team", "Badge Team", "owner")); + PublishedSkill entry = publishedSkill(namespace, "entry", "owner", SkillVisibility.PUBLIC, "Entry"); + PublishedSkill member = publishedSkill(namespace, "member", "owner", SkillVisibility.PUBLIC, "Member"); + PublishedSkill standalone = publishedSkill(namespace, "standalone", "owner", SkillVisibility.PUBLIC, "Standalone"); + for (int index = 0; index < 2; index++) { + SkillSuite suite = entityManager.persistFlushFind(new SkillSuite( + namespace.getId(), "pack-" + index, "Pack", "owner")); + SkillSuiteVersion version = entityManager.persistFlushFind(new SkillSuiteVersion( + suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "owner")); + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + persistMember(version, entry, 0, true); + persistMember(version, member, 1, false); + suite.setLatestVersionId(version.getId()); + } + entityManager.flush(); + entityManager.clear(); + + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships( + List.of(entry.skill().getId(), member.skill().getId(), standalone.skill().getId()), + null, Map.of(), Set.of())) + .containsExactlyInAnyOrder(entry.skill().getId(), member.skill().getId()); + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships( + List.of(), null, Map.of(), Set.of())).isEmpty(); + } + + @Test + void membershipFlagsApplyTheSamePrivateAndNamespaceVisibilityAsDetail() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("badge-visibility", "Badge Visibility", "owner")); + PublishedSkill privateMember = publishedSkill( + namespace, "private-pack-member", "owner", SkillVisibility.PUBLIC, "Private Pack Member"); + PublishedSkill teamMember = publishedSkill( + namespace, "team-pack-member", "owner", SkillVisibility.PUBLIC, "Team Pack Member"); + publishedSuite(namespace, "private-pack", "author", SkillVisibility.PRIVATE, privateMember); + publishedSuite(namespace, "team-pack", "author", SkillVisibility.NAMESPACE_ONLY, teamMember); + entityManager.flush(); + entityManager.clear(); + List ids = List.of(privateMember.skill().getId(), teamMember.skill().getId()); + + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships( + ids, null, Map.of(), Set.of())).isEmpty(); + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships( + ids, "other-author", Map.of(), Set.of())).isEmpty(); + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships( + ids, "other-author", Map.of(namespace.getId(), NamespaceRole.MEMBER), Set.of())) + .containsExactly(teamMember.skill().getId()); + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships( + ids, "author", Map.of(namespace.getId(), NamespaceRole.MEMBER), Set.of())) + .containsExactlyInAnyOrderElementsOf(ids); + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships( + ids, "author", Map.of(), Set.of())).isEmpty(); + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships( + ids, "owner", Map.of(namespace.getId(), NamespaceRole.ADMIN), Set.of())) + .containsExactlyInAnyOrderElementsOf(ids); + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships( + ids, "owner", Map.of(), Set.of("SUPER_ADMIN"))) + .containsExactlyInAnyOrderElementsOf(ids); + } + + @Test + void membershipFlagsUseOnlyActiveVisibleSuitesAndCurrentPublishedSnapshot() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("badge-state", "Badge State", "owner")); + PublishedSkill member = publishedSkill(namespace, "member", "owner", SkillVisibility.PUBLIC, "Member"); + PublishedSkill replacement = publishedSkill(namespace, "replacement", "owner", SkillVisibility.PUBLIC, "Replacement"); + SkillSuite suite = publishedSuite(namespace, "pack", "owner", SkillVisibility.PUBLIC, member); + entityManager.flush(); + List ids = List.of(member.skill().getId(), replacement.skill().getId()); + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(ids, null, Map.of(), Set.of())) + .containsExactly(member.skill().getId()); + + suite.setHidden(true); + entityManager.flush(); + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(ids, null, Map.of(), Set.of())) + .isEmpty(); + suite.setHidden(false); + suite.setStatus(SkillSuiteStatus.ARCHIVED); + entityManager.flush(); + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(ids, null, Map.of(), Set.of())) + .isEmpty(); + suite.setStatus(SkillSuiteStatus.ACTIVE); + SkillSuiteVersion latest = entityManager.persistFlushFind(new SkillSuiteVersion( + suite.getId(), "2.0.0", SkillVisibility.PUBLIC, "owner")); + persistMember(latest, replacement, 0, true); + suite.setLatestVersionId(latest.getId()); + entityManager.flush(); + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(ids, null, Map.of(), Set.of())) + .isEmpty(); + latest.setStatus(SkillSuiteVersionStatus.PUBLISHED); + entityManager.flush(); + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(ids, null, Map.of(), Set.of())) + .containsExactly(replacement.skill().getId()); + namespace.setStatus(com.iflytek.skillhub.domain.namespace.NamespaceStatus.FROZEN); + entityManager.flush(); + assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(ids, null, Map.of(), Set.of())) + .isEmpty(); + } + @Test void returnsSkillAndSuiteWithTheSameCoordinateAsDistinctResourceTypes() { Namespace namespace = entityManager.persistFlushFind( @@ -541,6 +642,19 @@ class SuiteDiscoveryIntegrationTest { }); } + private SkillSuite publishedSuite(Namespace namespace, String slug, String ownerId, + SkillVisibility visibility, PublishedSkill member) { + SkillSuite suite = entityManager.persistFlushFind( + new SkillSuite(namespace.getId(), slug, slug, ownerId)); + SkillSuiteVersion version = entityManager.persistFlushFind(new SkillSuiteVersion( + suite.getId(), "1.0.0", visibility, ownerId)); + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + persistMember(version, member, 0, true); + suite.setLatestVersionId(version.getId()); + entityManager.flush(); + return suite; + } + private PublishedSkill publishedSkill( Namespace namespace, String slug, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index b64f469d8..fea13c11e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -108,7 +108,14 @@ class MySkillAppServiceTest { given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of()); given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1"))); + var suiteProjection = org.mockito.Mockito.mock(SkillSuiteMembershipProjectionService.class); + ReflectionTestUtils.setField(service, "suiteMembershipProjection", suiteProjection); + given(suiteProjection.enrich(org.mockito.ArgumentMatchers.anyList(), eq("user-1"))) + .willAnswer(invocation -> invocation.>getArgument(0) + .stream().map(item -> item.withVisibleSuites(true)).toList()); var stars = service.listMyStars("user-1", 1, 1, Map.of(), Set.of()); + assertThat(stars.items().getFirst().hasVisibleSuites()).isTrue(); + org.mockito.Mockito.verify(suiteProjection).enrich(org.mockito.ArgumentMatchers.anyList(), eq("user-1")); assertThat(stars.total()).isEqualTo(2); assertThat(stars.page()).isEqualTo(1); 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 51f36230a..9f35c8144 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 @@ -110,7 +110,14 @@ class SkillSearchAppServiceTest { when(userAccountRepository.findByIdIn(List.of("owner-1"))) .thenReturn(List.of(new UserAccount("owner-1", "Alice", "alice@example.com", null))); + var suiteProjection = org.mockito.Mockito.mock(SkillSuiteMembershipProjectionService.class); + setField(service, "suiteMembershipProjection", suiteProjection); + when(suiteProjection.enrich(anyList(), org.mockito.ArgumentMatchers.isNull())) + .thenAnswer(invocation -> invocation.>getArgument(0) + .stream().map(item -> item.withVisibleSuites(true)).toList()); SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 1, null, null); + assertEquals(true, response.items().getFirst().hasVisibleSuites()); + verify(suiteProjection, times(1)).enrich(anyList(), org.mockito.ArgumentMatchers.isNull()); assertEquals(1, response.items().size()); assertEquals("visible-skill", response.items().getFirst().slug()); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteMembershipProjectionServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteMembershipProjectionServiceTest.java new file mode 100644 index 000000000..2918873d8 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteMembershipProjectionServiceTest.java @@ -0,0 +1,92 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository; +import java.math.BigDecimal; +import java.time.Instant; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SkillSuiteMembershipProjectionServiceTest { + @Mock private SkillSuiteReferenceQueryRepository references; + @Mock private NamespaceMemberRepository namespaceMembers; + @Mock private RbacService rbacService; + @InjectMocks private SkillSuiteMembershipProjectionService projection; + + @Test + void batchesDistinctSkillIdsAndKeepsPageOrderUsingTheCurrentViewer() { + when(namespaceMembers.findByUserId("viewer")) + .thenReturn(List.of(new NamespaceMember(7L, "viewer", NamespaceRole.MEMBER))); + when(rbacService.getUserRoleCodes("viewer")).thenReturn(Set.of("USER")); + when(references.findSkillIdsWithVisibleMemberships( + List.of(2L, 1L), "viewer", Map.of(7L, NamespaceRole.MEMBER), Set.of("USER"))) + .thenReturn(Set.of(1L)); + + var result = projection.enrich(List.of(summary(2L), summary(1L), summary(2L)), "viewer"); + + assertThat(result).extracting(SkillSummaryResponse::id).containsExactly(2L, 1L, 2L); + assertThat(result).extracting(SkillSummaryResponse::hasVisibleSuites).containsExactly(false, true, false); + verify(references).findSkillIdsWithVisibleMemberships( + List.of(2L, 1L), "viewer", Map.of(7L, NamespaceRole.MEMBER), Set.of("USER")); + verify(namespaceMembers).findByUserId("viewer"); + verify(rbacService).getUserRoleCodes("viewer"); + } + + @Test + void anonymousProjectionDoesNotLoadAnyIdentityOrMemberships() { + when(references.findSkillIdsWithVisibleMemberships(List.of(1L), null, Map.of(), Set.of())) + .thenReturn(Set.of(1L)); + assertThat(projection.enrich(List.of(summary(1L)), null)).singleElement() + .extracting(SkillSummaryResponse::hasVisibleSuites).isEqualTo(true); + verifyNoInteractions(namespaceMembers, rbacService); + } + + @Test + void emptyPageDoesNotQueryAnyDependency() { + assertThat(projection.enrich(List.of(), "viewer")).isEmpty(); + verifyNoInteractions(references, namespaceMembers, rbacService); + } + + @Test + void visibilityQueryFailureIsNotConvertedToACachedFalseFlag() { + when(references.findSkillIdsWithVisibleMemberships(any(), eq(null), any(), any())) + .thenThrow(new IllegalStateException("database unavailable")); + assertThatThrownBy(() -> projection.enrich(List.of(summary(1L)), null)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void labelAndManagementCopiesPreserveTheViewerSpecificFlag() { + SkillSummaryResponse source = summary(1L).withVisibleSuites(true); + SkillSummaryResponse copied = source.withLabels(List.of()).withManagementState( + new SkillSummaryResponse.ManagementState(null, null, null, null, null, true)); + assertThat(copied.hasVisibleSuites()).isTrue(); + assertThat(copied.managementState().ownerReadOnly()).isTrue(); + assertThat(source.withVisibleSuites(false).hasVisibleSuites()).isFalse(); + } + + private SkillSummaryResponse summary(Long id) { + return new SkillSummaryResponse(id, "skill-" + id, "Skill", "Summary", "PUBLIC", "ACTIVE", + 0L, 0, BigDecimal.ZERO, 0, "global", Instant.EPOCH, false, + null, null, null, "PUBLISHED", null); + } +} diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 527ef414c..39f3f1f4f 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -9186,6 +9186,7 @@ export interface components { labels?: components["schemas"]["SkillLabelDto"][]; avatarUrl?: string; managementState?: components["schemas"]["ManagementState"]; + hasVisibleSuites?: boolean; }; ApiResponseBoolean: { /** Format: int32 */ diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 7b95767b0..5058bb530 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -253,6 +253,7 @@ export interface BatchMemberResponse { // Skill types export interface SkillSummary { + hasVisibleSuites?: boolean managementState?: components['schemas']['ManagementState'] | null id: number slug: string diff --git a/web/src/features/auth/skill-suite-session-cache.test.tsx b/web/src/features/auth/skill-suite-session-cache.test.tsx new file mode 100644 index 000000000..3306b6a0f --- /dev/null +++ b/web/src/features/auth/skill-suite-session-cache.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom + +import { createElement, type ReactNode } from 'react' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useSearchSkills } from '@/shared/hooks/use-skill-queries' +import { useMySubscriptionsPage } from '@/shared/hooks/use-user-queries' +import { usePasswordLogin } from './use-password-login' + +const state = vi.hoisted(() => ({ + viewer: 'guest', + nextViewer: 'member', + direct: false, + search: vi.fn(), + subscriptions: vi.fn(), + localLogin: vi.fn(), + directLogin: vi.fn(), +})) + +vi.mock('@/api/client', () => ({ + WEB_API_PREFIX: '/api/web', + fetchJson: state.search, + meApi: { getSubscriptionsPage: state.subscriptions }, + authApi: { localLogin: state.localLogin, directLogin: state.directLogin }, + getDirectAuthRuntimeConfig: () => ({ enabled: state.direct, provider: state.direct ? 'local' : '' }), +})) + +beforeEach(() => { + vi.clearAllMocks() + state.direct = false + state.search.mockImplementation(async () => ({ items: [{ hasVisibleSuites: state.viewer === 'member' }] })) + state.subscriptions.mockImplementation(async () => ({ items: [{ hasVisibleSuites: state.viewer === 'member' }] })) + const login = async () => { + state.viewer = state.nextViewer + return { userId: state.viewer } + } + state.localLogin.mockImplementation(login) + state.directLogin.mockImplementation(login) +}) + +afterEach(cleanup) + +describe('Suite membership summaries across successful authentication', () => { + it.each([ + { from: 'guest', to: 'member', direct: false, expected: true }, + { from: 'member', to: 'other-user', direct: true, expected: false }, + ])('reloads the same mounted lists after $from logs in as $to', async ({ from, to, direct, expected }) => { + state.viewer = from + state.nextViewer = to + state.direct = direct + const client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: false } } }) + const wrapper = ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client }, children) + const hook = renderHook(() => ({ + search: useSearchSkills({ q: 'workflow' }), + subscriptions: useMySubscriptionsPage({ page: 0 }), + login: usePasswordLogin(), + }), { wrapper }) + try { + await waitFor(() => { + expect(hook.result.current.search.data?.items[0].hasVisibleSuites).toBe(from === 'member') + expect(hook.result.current.subscriptions.data?.items[0].hasVisibleSuites).toBe(from === 'member') + }) + expect(state.search).toHaveBeenCalledTimes(1) + expect(state.subscriptions).toHaveBeenCalledTimes(1) + + await act(async () => { + await hook.result.current.login.mutateAsync({ username: to, password: 'test-password' }) + }) + await waitFor(() => { + expect(hook.result.current.search.data?.items[0].hasVisibleSuites).toBe(expected) + expect(hook.result.current.subscriptions.data?.items[0].hasVisibleSuites).toBe(expected) + }) + expect(state.search).toHaveBeenCalledTimes(2) + expect(state.subscriptions).toHaveBeenCalledTimes(2) + expect(direct ? state.directLogin : state.localLogin).toHaveBeenCalledTimes(1) + expect(client.getQueryData(['auth', 'me'])).toEqual({ userId: to }) + } finally { + hook.unmount() + client.clear() + } + }) +}) diff --git a/web/src/features/notification/notification-session.test.ts b/web/src/features/notification/notification-session.test.ts index 0d02e257e..cf8760ece 100644 --- a/web/src/features/notification/notification-session.test.ts +++ b/web/src/features/notification/notification-session.test.ts @@ -1,5 +1,5 @@ -import { QueryClient } from '@tanstack/react-query' -import { describe, expect, it } from 'vitest' +import { QueryClient, QueryObserver } from '@tanstack/react-query' +import { describe, expect, it, vi } from 'vitest' import { clearSessionScopedQueries, getNotificationQueryKeyScope } from './notification-session' describe('getNotificationQueryKeyScope', () => { @@ -15,18 +15,113 @@ describe('getNotificationQueryKeyScope', () => { }) describe('clearSessionScopedQueries', () => { - it('removes user-scoped notification and dashboard caches without touching public search caches', () => { + it('clears viewer-specific skill summaries while keeping unrelated public data', () => { const queryClient = new QueryClient() queryClient.setQueryData(['notifications', 'user-a', 'unread-count'], { count: 3 }) queryClient.setQueryData(['labels', 'visible'], [{ slug: 'official' }]) queryClient.setQueryData(['skills', 'my', { page: 0, size: 12 }], { items: [] }) queryClient.setQueryData(['skills', 'search', { q: '', sort: 'relevance', page: 0, size: 12, starredOnly: false }], { items: [] }) + queryClient.setQueryData(['skills', 'subscriptions'], [{ hasVisibleSuites: true }]) + queryClient.setQueryData(['skills', 'subscriptions', 'page', { page: 0 }], { items: [{ hasVisibleSuites: true }] }) + queryClient.setQueryData(['catalog', 'metadata'], { title: 'SkillHub' }) clearSessionScopedQueries(queryClient) expect(queryClient.getQueryData(['notifications', 'user-a', 'unread-count'])).toBeUndefined() expect(queryClient.getQueryData(['labels', 'visible'])).toBeUndefined() expect(queryClient.getQueryData(['skills', 'my', { page: 0, size: 12 }])).toBeUndefined() - expect(queryClient.getQueryData(['skills', 'search', { q: '', sort: 'relevance', page: 0, size: 12, starredOnly: false }])).toEqual({ items: [] }) + expect(queryClient.getQueryData(['skills', 'search', { q: '', sort: 'relevance', page: 0, size: 12, starredOnly: false }])).toBeUndefined() + expect(queryClient.getQueryData(['skills', 'subscriptions'])).toBeUndefined() + expect(queryClient.getQueryData(['skills', 'subscriptions', 'page', { page: 0 }])).toBeUndefined() + expect(queryClient.getQueryData(['catalog', 'metadata'])).toEqual({ title: 'SkillHub' }) + queryClient.clear() + }) + + it.each([ + { from: 'guest', to: 'member', expected: true }, + { from: 'member', to: 'other-user', expected: false }, + { from: 'member', to: 'guest', expected: false }, + ])('refreshes mounted search and subscription observers when $from becomes $to', async ({ from, to, expected }) => { + const client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: false } } }) + let viewer = from + const searchLoader = vi.fn(async () => ({ items: [{ hasVisibleSuites: viewer === 'member' }] })) + const subscriptionsLoader = vi.fn(async () => ({ items: [{ hasVisibleSuites: viewer === 'member' }] })) + const search = new QueryObserver(client, { + queryKey: ['skills', 'search', { q: 'workflow' }], + queryFn: searchLoader, + }) + const subscriptions = new QueryObserver(client, { + queryKey: ['skills', 'subscriptions', 'page', { page: 0 }], + queryFn: subscriptionsLoader, + }) + const stopSearch = search.subscribe(() => {}) + const stopSubscriptions = subscriptions.subscribe(() => {}) + try { + await Promise.all([search.refetch(), subscriptions.refetch()]) + expect(search.getCurrentResult().data?.items[0].hasVisibleSuites).toBe(from === 'member') + expect(searchLoader).toHaveBeenCalledTimes(1) + expect(subscriptionsLoader).toHaveBeenCalledTimes(1) + + viewer = to + clearSessionScopedQueries(client) + expect(search.getCurrentResult().data).toBeUndefined() + expect(subscriptions.getCurrentResult().data).toBeUndefined() + await vi.waitFor(() => { + expect(search.getCurrentResult().data?.items[0].hasVisibleSuites).toBe(expected) + expect(subscriptions.getCurrentResult().data?.items[0].hasVisibleSuites).toBe(expected) + }) + expect(searchLoader).toHaveBeenCalledTimes(2) + expect(subscriptionsLoader).toHaveBeenCalledTimes(2) + } finally { + stopSearch() + stopSubscriptions() + client.clear() + } + }) + + it('ignores a late response started under the previous viewer', async () => { + const client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: false } } }) + let finishPreviousRequest: (value: { hasVisibleSuites: boolean }) => void = () => {} + const previousRequest = new Promise<{ hasVisibleSuites: boolean }>((resolve) => { + finishPreviousRequest = resolve + }) + const loader = vi.fn() + .mockReturnValueOnce(previousRequest) + .mockResolvedValue({ hasVisibleSuites: false }) + const observer = new QueryObserver(client, { queryKey: ['skills', 'search', { q: 'workflow' }], queryFn: loader }) + const unsubscribe = observer.subscribe(() => {}) + try { + expect(loader).toHaveBeenCalledTimes(1) + clearSessionScopedQueries(client) + await vi.waitFor(() => expect(observer.getCurrentResult().data).toEqual({ hasVisibleSuites: false })) + finishPreviousRequest({ hasVisibleSuites: true }) + await previousRequest + await Promise.resolve() + expect(observer.getCurrentResult().data).toEqual({ hasVisibleSuites: false }) + expect(loader).toHaveBeenCalledTimes(2) + } finally { + unsubscribe() + client.clear() + } + }) + + it('does not restore the previous membership hint when the new viewer request fails', async () => { + const client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: false } } }) + const loader = vi.fn() + .mockResolvedValueOnce({ hasVisibleSuites: true }) + .mockRejectedValue(new Error('Request rejected for the new viewer')) + const observer = new QueryObserver(client, { queryKey: ['skills', 'search', { q: 'workflow' }], queryFn: loader }) + const unsubscribe = observer.subscribe(() => {}) + try { + await observer.refetch() + expect(observer.getCurrentResult().data).toEqual({ hasVisibleSuites: true }) + clearSessionScopedQueries(client) + await vi.waitFor(() => expect(observer.getCurrentResult().isError).toBe(true)) + expect(observer.getCurrentResult().data).toBeUndefined() + expect(loader).toHaveBeenCalledTimes(2) + } finally { + unsubscribe() + client.clear() + } }) }) diff --git a/web/src/features/notification/notification-session.ts b/web/src/features/notification/notification-session.ts index 2195e40ca..87ccf294a 100644 --- a/web/src/features/notification/notification-session.ts +++ b/web/src/features/notification/notification-session.ts @@ -9,6 +9,10 @@ export function clearSessionScopedQueries(queryClient: QueryClient) { queryClient.removeQueries({ queryKey: ['labels'] }) queryClient.removeQueries({ queryKey: ['skills', 'my'] }) queryClient.removeQueries({ queryKey: ['skills', 'stars'] }) + // Public Skill summaries also contain viewer-specific Suite visibility. Reset + // mounted observers so they drop the previous identity's data and refetch. + void queryClient.resetQueries({ queryKey: ['skills', 'search'] }) + void queryClient.resetQueries({ queryKey: ['skills', 'subscriptions'] }) queryClient.removeQueries({ queryKey: ['namespaces', 'my'] }) queryClient.removeQueries({ queryKey: ['governance'] }) queryClient.removeQueries({ queryKey: ['reviews'] }) diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index 7b872b5e6..3a43e7673 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -8,7 +8,7 @@ import { SkillAvatar } from './skill-avatar' import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle' import { formatCompactCount } from '@/shared/lib/number-format' import { formatRelativeTime } from '@/shared/lib/format-relative-time' -import { Bookmark, ShieldCheck, User, Clock } from 'lucide-react' +import { Bookmark, Boxes, ShieldCheck, User, Clock } from 'lucide-react' interface SkillCardProps { skill: SkillSummary @@ -61,13 +61,22 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard > {skill.displayName} -
+
+ {skill.hasVisibleSuites === true ? ( + + + ) : null}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5e6697977..44127ab4c 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2114,6 +2114,8 @@ "subscribe": "Subscribe" }, "skillCard": { + "relatedSuites": "Related suites", + "relatedSuitesHint": "This skill is included in suites you can access. Open its details to explore related skills.", "downloads": "{{value}} downloads", "stars": "{{count}} stars", "rating": "Rating {{rating}}", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 2d03b2d65..76e78701d 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2484,6 +2484,8 @@ } }, "skillCard": { + "relatedSuites": "Связанные наборы", + "relatedSuitesHint": "Этот навык входит в доступные вам наборы. Откройте его страницу, чтобы узнать о связанных навыках.", "downloads": "Загрузки: {{value}}", "stars": "Звёзды: {{count}}", "rating": "Рейтинг {{rating}}", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ca079e3fd..535f635ea 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2113,6 +2113,8 @@ "subscribe": "订阅" }, "skillCard": { + "relatedSuites": "关联套件", + "relatedSuitesHint": "此技能被你可见的套件收录,查看详情了解配套关系。", "downloads": "下载 {{value}}", "stars": "收藏 {{count}}", "rating": "评分 {{rating}}", diff --git a/web/src/i18n/skill-card-suite-locale.test.ts b/web/src/i18n/skill-card-suite-locale.test.ts new file mode 100644 index 000000000..6ebb20401 --- /dev/null +++ b/web/src/i18n/skill-card-suite-locale.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import en from './locales/en.json' +import ru from './locales/ru.json' +import zh from './locales/zh.json' + +describe('Skill card Suite membership locales', () => { + it('defines the passive membership label and explanation in every supported locale', () => { + expect(zh.skillCard.relatedSuites).toBe('关联套件') + for (const locale of [zh, en, ru]) { + expect(locale.skillCard.relatedSuites).toBeTruthy() + expect(locale.skillCard.relatedSuitesHint).toBeTruthy() + } + }) +})