feat(suite): show viewer-safe membership hints on skill cards

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-26 15:47:44 +08:00
parent 0771f2c14b
commit baaba6a1aa
22 changed files with 594 additions and 16 deletions

View file

@ -120,8 +120,15 @@ SMOKE_ADMIN_PASSWORD='<configured-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 的独立安装能力保持不变。
## 部署顺序

View file

@ -87,6 +87,11 @@ npx clawhub install my-team--pdf-parser
点击技能包卡片,查看详细信息、版本历史、文件列表。
如果技能被当前用户可见的套件收录,卡片会显示“关联套件”。点击卡片进入技能详情后,可以
查看关联套件、入口或普通成员身份,以及可见的配套技能,并跳转到套件详情了解整体用法和
安装指引。一个技能可以关联多个套件;这个标识不表示必须整套安装,也不改变单独安装技能的
方式。不可访问的套件不会显示关联提示。
6. **安装使用**
找到合适的技能包后,使用 CLI 命令安装或点击「下载」按钮。

View file

@ -35,9 +35,31 @@ public record SkillSummaryResponse(
List<SkillLabelDto> 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<SkillLabelDto> 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);
}
}

View file

@ -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<Long> findSkillIdsWithVisibleMemberships(
List<Long> skillIds,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> 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<SkillSuiteReferenceResponse> findVisibleEntryReferences(

View file

@ -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<SkillSummaryResponse> withSuiteMemberships(List<SkillSummaryResponse> items, String userId) {
return suiteMembershipProjection == null ? items : suiteMembershipProjection.enrich(items, userId);
}
private Page<Skill> listHiddenSkills(int page,
@ -143,7 +166,7 @@ public class MySkillAppService {
Page<Skill> skills = starredSkillQueryRepository.findAccessible(
userId, namespaceRoles, platformRoles, PageRequest.of(page, size));
List<SkillSummaryResponse> 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<SkillSummaryResponse> listMySubscriptions(String userId, int page, int size) {
@ -167,7 +190,7 @@ public class MySkillAppService {
.toList();
List<SkillSummaryResponse> 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<Skill> filterSkills(String userId,

View file

@ -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<SkillSummaryResponse> pageItems = mapVisibleSkillSummaries(result.skillIds(), scope.userId());
if (suiteMembershipProjection != null) {
pageItems = suiteMembershipProjection.enrich(pageItems, scope.userId());
}
return new SearchResponse(pageItems, result.total(), page, size);
}

View file

@ -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<SkillSummaryResponse> enrich(List<SkillSummaryResponse> summaries, String userId) {
if (summaries.isEmpty()) {
return summaries;
}
Map<Long, NamespaceRole> roles = userId == null ? Map.of()
: namespaceMembers.findByUserId(userId).stream().collect(Collectors.toMap(
NamespaceMember::getNamespaceId, NamespaceMember::getRole));
Set<String> platformRoles = userId == null ? Set.of() : rbacService.getUserRoleCodes(userId);
Set<Long> 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();
}
}

View file

@ -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

View file

@ -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<Long> 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<Long> 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,

View file

@ -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.<List<com.iflytek.skillhub.dto.SkillSummaryResponse>>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);

View file

@ -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.<List<com.iflytek.skillhub.dto.SkillSummaryResponse>>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());

View file

@ -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);
}
}

View file

@ -9186,6 +9186,7 @@ export interface components {
labels?: components["schemas"]["SkillLabelDto"][];
avatarUrl?: string;
managementState?: components["schemas"]["ManagementState"];
hasVisibleSuites?: boolean;
};
ApiResponseBoolean: {
/** Format: int32 */

View file

@ -253,6 +253,7 @@ export interface BatchMemberResponse {
// Skill types
export interface SkillSummary {
hasVisibleSuites?: boolean
managementState?: components['schemas']['ManagementState'] | null
id: number
slug: string

View file

@ -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()
}
})
})

View file

@ -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()
}
})
})

View file

@ -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'] })

View file

@ -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}
</h3>
<div className="mt-2 grid justify-items-start">
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
<NamespaceBadge
className="block max-w-full truncate"
type="TEAM"
name={`@${skill.namespace}`}
title={`@${skill.namespace}`}
/>
{skill.hasVisibleSuites === true ? (
<span
className="inline-flex max-w-full shrink-0 items-center gap-1 rounded-full border border-border bg-secondary/60 px-2 py-0.5 text-xs font-medium text-secondary-foreground"
title={t('skillCard.relatedSuitesHint')}
>
<Boxes aria-hidden="true" className="h-3 w-3 shrink-0" />
{t('skillCard.relatedSuites')}
</span>
) : null}
</div>
</div>
</div>

View file

@ -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}}",

View file

@ -2484,6 +2484,8 @@
}
},
"skillCard": {
"relatedSuites": "Связанные наборы",
"relatedSuitesHint": "Этот навык входит в доступные вам наборы. Откройте его страницу, чтобы узнать о связанных навыках.",
"downloads": "Загрузки: {{value}}",
"stars": "Звёзды: {{count}}",
"rating": "Рейтинг {{rating}}",

View file

@ -2113,6 +2113,8 @@
"subscribe": "订阅"
},
"skillCard": {
"relatedSuites": "关联套件",
"relatedSuitesHint": "此技能被你可见的套件收录,查看详情了解配套关系。",
"downloads": "下载 {{value}}",
"stars": "收藏 {{count}}",
"rating": "评分 {{rating}}",

View file

@ -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()
}
})
})