diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index bc294609..059da89b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -70,7 +70,8 @@ public class SkillController extends BaseApiController { detail.ratingCount(), detail.hidden(), detail.latestVersion(), - namespace + namespace, + detail.canManageLifecycle() ); return ok("response.success.read", response); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index 12d0019a..09a67b51 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -15,5 +15,6 @@ public record SkillDetailResponse( Integer ratingCount, boolean hidden, String latestVersion, - String namespace + String namespace, + boolean canManageLifecycle ) {} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java index cdc1d8f1..c40164d0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java @@ -147,7 +147,8 @@ class ClawHubRegistryControllerTest { 0, false, "1.2.0", - 1L + 1L, + false )); Skill skill = new Skill(1L, "global-skill", "owner-1", SkillVisibility.PUBLIC); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index f496dd61..5314d705 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -65,7 +65,8 @@ public class SkillQueryService { Integer ratingCount, boolean hidden, String latestVersion, - Long namespaceId + Long namespaceId, + boolean canManageLifecycle ) {} public record SkillVersionDetailDTO( @@ -127,7 +128,8 @@ public class SkillQueryService { skill.getRatingCount(), skill.isHidden(), latestVersion, - skill.getNamespaceId() + skill.getNamespaceId(), + canManageRestrictedSkill(skill, currentUserId, userNsRoles) ); } @@ -243,16 +245,31 @@ public class SkillQueryService { Pageable pageable) { Skill skill = findSkill(namespaceSlug, skillSlug); assertPublishedAccessible(skill, currentUserId, userNsRoles); - - List publishedVersions = skillVersionRepository.findBySkillIdAndStatus( - skill.getId(), SkillVersionStatus.PUBLISHED); + List visibleVersions; + if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { + visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream() + .filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED + || version.getStatus() == SkillVersionStatus.DRAFT + || version.getStatus() == SkillVersionStatus.REJECTED) + .sorted(Comparator + .comparingInt((SkillVersion version) -> lifecycleListPriority(version.getStatus())) + .thenComparing(SkillVersion::getPublishedAt, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(SkillVersion::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(SkillVersion::getId, Comparator.reverseOrder())) + .toList(); + } else { + visibleVersions = skillVersionRepository.findBySkillIdAndStatus( + skill.getId(), SkillVersionStatus.PUBLISHED); + } // Manual pagination - int start = (int) pageable.getOffset(); - int end = Math.min(start + pageable.getPageSize(), publishedVersions.size()); - List pageContent = publishedVersions.subList(start, end); + int start = Math.min((int) pageable.getOffset(), visibleVersions.size()); + int end = Math.min(start + pageable.getPageSize(), visibleVersions.size()); + List pageContent = visibleVersions.subList(start, end); - return new PageImpl<>(pageContent, pageable, publishedVersions.size()); + return new PageImpl<>(pageContent, pageable, visibleVersions.size()); } public ResolvedVersionDTO resolveVersion( @@ -404,6 +421,16 @@ public class SkillQueryService { || role == NamespaceRole.OWNER; } + private int lifecycleListPriority(SkillVersionStatus status) { + if (status == SkillVersionStatus.PUBLISHED) { + return 0; + } + if (status == SkillVersionStatus.REJECTED) { + return 1; + } + return 2; + } + private void assertPublishedVersion(SkillVersion version, String versionStr) { if (version.getStatus() != SkillVersionStatus.PUBLISHED) { throw new DomainBadRequestException("error.skill.version.notPublished", versionStr); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 91b2cac9..00d4adef 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -351,6 +351,124 @@ class SkillQueryServiceTest { assertEquals("/api/v1/skills/global/smoke-skill-two/versions/1.0.0%20beta/download", result.downloadUrl()); } + @Test + void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", userId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + assertTrue(result.canManageLifecycle()); + } + + @Test + void testGetSkillDetail_ShouldNotFlagLifecyclePermissionForRegularViewer() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "viewer-1"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + assertFalse(result.canManageLifecycle()); + } + + @Test + void testListVersions_ShouldIncludeDraftAndRejectedForLifecycleManagers() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", userId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + SkillVersion published = new SkillVersion(1L, "1.0.0", userId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion draft = new SkillVersion(1L, "1.1.0", userId); + setId(draft, 12L); + draft.setStatus(SkillVersionStatus.DRAFT); + SkillVersion rejected = new SkillVersion(1L, "1.2.0", userId); + setId(rejected, 13L); + rejected.setStatus(SkillVersionStatus.REJECTED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published)); + + Page result = service.listVersions( + namespaceSlug, + skillSlug, + userId, + userNsRoles, + PageRequest.of(0, 10) + ); + + assertEquals(List.of("1.0.0", "1.2.0", "1.1.0"), + result.getContent().stream().map(SkillVersion::getVersion).toList()); + } + + @Test + void testListVersions_ShouldOnlyReturnPublishedForRegularViewers() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "viewer-1"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published)); + + Page result = service.listVersions( + namespaceSlug, + skillSlug, + userId, + userNsRoles, + PageRequest.of(0, 10) + ); + + assertEquals(List.of("1.0.0"), + result.getContent().stream().map(SkillVersion::getVersion).toList()); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index be7dae30..b06c6a0a 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -68,6 +68,9 @@ public class PostgresFullTextQueryService implements SearchQueryService { && searchDocumentRepository != null && searchEmbeddingService != null; int requestedOffset = query.page() * query.size(); + if (useSemanticRerank && requestedOffset + query.size() > maxCandidates) { + useSemanticRerank = false; + } int sqlLimit = query.size(); int sqlOffset = requestedOffset; if (useSemanticRerank) { diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index bc7f0e21..cfdc44f6 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -215,4 +215,45 @@ class PostgresFullTextQueryServiceTest { verify(nativeQuery).setParameter("offset", 0); assertThat(result.skillIds()).containsExactly(1L, 2L); } + + @Test + void deepSemanticPagesShouldFallBackToDatabasePagination() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + SkillSearchDocumentJpaRepository repository = mock(SkillSearchDocumentJpaRepository.class); + HashingSearchEmbeddingService embeddingService = new HashingSearchEmbeddingService(); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of(201L, 202L)); + when(countQuery.getSingleResult()).thenReturn(1000L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService( + entityManager, + repository, + embeddingService, + true, + 0.6D, + 8, + 120 + ); + + var result = service.search(new SearchQuery( + "self improvement", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 20, + 10 + )); + + verify(nativeQuery).setParameter("limit", 10); + verify(nativeQuery).setParameter("offset", 200); + verify(repository, never()).findBySkillIdIn(org.mockito.ArgumentMatchers.anyList()); + assertThat(result.skillIds()).containsExactly(201L, 202L); + assertThat(result.total()).isEqualTo(1000L); + } } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 2b165998..827078db 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -132,6 +132,7 @@ export interface SkillDetail { hidden: boolean latestVersion?: string namespace: string + canManageLifecycle: boolean } export interface SkillVersion { diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index d72fa1a5..5dea9ee1 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -314,7 +314,7 @@ export function SkillDetailPage() { {formatLocalDateTime(version.publishedAt, i18n.language)} - {canDeleteVersion(version.status) && ( + {skill.canManageLifecycle && canDeleteVersion(version.status) && (