From a55264b130dd50a005561ef3fc72e8825dd5dca1 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 27 Jul 2026 16:00:28 +0800 Subject: [PATCH] fix(namespace): bound filtered namespace queries Signed-off-by: dongmucat <1127093059@qq.com> --- .../NamespacePortalQueryAppService.java | 51 +++++++ .../NamespacePortalQueryAppServiceTest.java | 124 ++++++++++++++++++ .../domain/namespace/NamespaceRepository.java | 8 ++ .../infra/jpa/NamespaceJpaRepository.java | 38 ++++++ 4 files changed, 221 insertions(+) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index f2597960..5a0767dc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -163,6 +163,50 @@ public class NamespacePortalQueryAppService { return PageResponse.from(responsePage); } + @Transactional(readOnly = true) + public PageResponse listMyNamespaces(Pageable pageable, + Map userNamespaceRoles, + Set platformRoles, + NamespaceStatus status, + String query, + String slug, + Set roles) { + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + Set requestedRoles = roles != null ? roles : Set.of(); + Pageable boundedPageable = normalizeMyNamespacesPageable(pageable); + String normalizedQuery = normalizeFilter(query); + String normalizedSlug = normalizeFilter(slug); + + if (isSuperAdmin(platformRoles) && requestedRoles.isEmpty()) { + Page visibleNamespaces = namespaceRepository.search( + status, + normalizedQuery, + normalizedSlug, + boundedPageable + ); + return PageResponse.from(visibleNamespaces.map(namespace -> myNamespaceResponse(namespace, namespaceRoles))); + } + + List scopedNamespaceIds = namespaceRoles.entrySet().stream() + .filter(entry -> requestedRoles.isEmpty() || requestedRoles.contains(entry.getValue())) + .map(Map.Entry::getKey) + .sorted() + .toList(); + if (scopedNamespaceIds.isEmpty()) { + Page empty = new PageImpl<>(List.of(), boundedPageable, 0); + return PageResponse.from(empty); + } + + Page visibleNamespaces = namespaceRepository.searchByIdIn( + scopedNamespaceIds, + status, + normalizedQuery, + normalizedSlug, + boundedPageable + ); + return PageResponse.from(visibleNamespaces.map(namespace -> myNamespaceResponse(namespace, namespaceRoles))); + } + @Transactional(readOnly = true) public NamespaceResponse getNamespace(String slug, String userId, Map userNamespaceRoles) { return getNamespace(slug, userId, userNamespaceRoles, Set.of()); @@ -253,6 +297,13 @@ public class NamespacePortalQueryAppService { return PageRequest.of(page, size, Sort.by(NAMESPACE_SLUG_SORT).ascending()); } + private String normalizeFilter(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + private boolean isSuperAdmin(Set platformRoles) { return platformRoles != null && platformRoles.contains(SUPER_ADMIN_ROLE); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 4567c015..96f4976b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -7,6 +7,9 @@ import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.iflytek.skillhub.domain.namespace.Namespace; @@ -145,6 +148,127 @@ class NamespacePortalQueryAppServiceTest { assertThat(response.size()).isEqualTo(2); } + @Test + void listMyNamespaces_superAdminWithRequestedRolesSearchesOnlyMatchingMembershipIds() { + Namespace owned = namespace(1L, "team-ai"); + Pageable expectedPageable = PageRequest.of(0, 20); + when(namespaceRepository.searchByIdIn( + eq(List.of(1L)), + eq(NamespaceStatus.ACTIVE), + eq("team"), + eq("team-ai"), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(owned), expectedPageable, 1)); + + var response = service.listMyNamespaces( + expectedPageable, + Map.of(1L, NamespaceRole.OWNER, 2L, NamespaceRole.MEMBER), + Set.of("SUPER_ADMIN"), + NamespaceStatus.ACTIVE, + " team ", + " team-ai ", + Set.of(NamespaceRole.OWNER, NamespaceRole.ADMIN) + ); + + assertThat(response.items()).extracting("slug").containsExactly("team-ai"); + assertThat(response.items()).extracting("currentUserRole").containsExactly(NamespaceRole.OWNER); + verify(namespaceRepository).searchByIdIn( + eq(List.of(1L)), + eq(NamespaceStatus.ACTIVE), + eq("team"), + eq("team-ai"), + any(Pageable.class) + ); + verify(namespaceRepository, never()).search(any(), any(), any(), any()); + } + + @Test + void listMyNamespaces_superAdminWithoutRequestedRolesUsesUnrestrictedFilteredSearch() { + Namespace archived = namespace(2L, "ops-team"); + archived.setStatus(NamespaceStatus.ARCHIVED); + Pageable expectedPageable = PageRequest.of(1, 10); + when(namespaceRepository.search( + eq(NamespaceStatus.ARCHIVED), + eq("ops"), + eq("ops-team"), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(archived), expectedPageable, 11)); + + var response = service.listMyNamespaces( + expectedPageable, + Map.of(), + Set.of("SUPER_ADMIN"), + NamespaceStatus.ARCHIVED, + " ops ", + " ops-team ", + Set.of() + ); + + assertThat(response.items()).extracting("slug").containsExactly("ops-team"); + assertThat(response.total()).isEqualTo(11); + verify(namespaceRepository).search( + eq(NamespaceStatus.ARCHIVED), + eq("ops"), + eq("ops-team"), + any(Pageable.class) + ); + verify(namespaceRepository, never()).searchByIdIn(anyList(), any(), any(), any(), any()); + } + + @Test + void listMyNamespaces_nonSuperAdminWithoutRequestedRolesSearchesAllMembershipIds() { + Namespace member = namespace(1L, "member-team"); + Namespace administered = namespace(2L, "admin-team"); + when(namespaceRepository.searchByIdIn( + eq(List.of(1L, 2L)), + eq(null), + eq(null), + eq(null), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(administered, member), PageRequest.of(0, 20), 2)); + + var response = service.listMyNamespaces( + PageRequest.of(0, 20), + Map.of(2L, NamespaceRole.ADMIN, 1L, NamespaceRole.MEMBER), + Set.of(), + null, + " ", + "\t", + Set.of() + ); + + assertThat(response.items()).extracting("slug").containsExactly("admin-team", "member-team"); + assertThat(response.items()).extracting("currentUserRole") + .containsExactly(NamespaceRole.ADMIN, NamespaceRole.MEMBER); + verify(namespaceRepository).searchByIdIn( + eq(List.of(1L, 2L)), + eq(null), + eq(null), + eq(null), + any(Pageable.class) + ); + verify(namespaceRepository, never()).search(any(), any(), any(), any()); + } + + @Test + void listMyNamespaces_emptyRoleRestrictedScopeReturnsEmptyPageWithoutRepositoryQuery() { + var response = service.listMyNamespaces( + PageRequest.of(2, 10), + Map.of(1L, NamespaceRole.MEMBER), + Set.of("SUPER_ADMIN"), + NamespaceStatus.ACTIVE, + " team ", + null, + Set.of(NamespaceRole.OWNER, NamespaceRole.ADMIN) + ); + + assertThat(response.items()).isEmpty(); + assertThat(response.total()).isZero(); + assertThat(response.page()).isEqualTo(2); + assertThat(response.size()).isEqualTo(10); + verifyNoInteractions(namespaceRepository); + } + @Test void listMyNamespaces_superAdminCompatibilityCollectsAllRepositoryPages() { Namespace first = namespace(1L, "first"); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index ae3eb4da..38dcc575 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -15,6 +15,14 @@ public interface NamespaceRepository { Optional findBySlug(String slug); Page findAll(Pageable pageable); Page findByStatus(NamespaceStatus status, Pageable pageable); + Page search(NamespaceStatus status, String query, String slug, Pageable pageable); + Page searchByIdIn( + List ids, + NamespaceStatus status, + String query, + String slug, + Pageable pageable + ); Namespace save(Namespace namespace); void delete(Namespace namespace); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index 7e7f3db0..ce918104 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -6,6 +6,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -20,4 +22,40 @@ public interface NamespaceJpaRepository List findByIdIn(List ids); Optional findBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); + + @Override + @Query(""" + SELECT n + FROM Namespace n + WHERE (:status IS NULL OR n.status = :status) + AND ( + :query IS NULL + OR lower(n.slug) LIKE lower(concat('%', :query, '%')) + OR lower(n.displayName) LIKE lower(concat('%', :query, '%')) + ) + AND (:slug IS NULL OR n.slug = :slug) + """) + Page search(@Param("status") NamespaceStatus status, + @Param("query") String query, + @Param("slug") String slug, + Pageable pageable); + + @Override + @Query(""" + SELECT n + FROM Namespace n + WHERE n.id IN :ids + AND (:status IS NULL OR n.status = :status) + AND ( + :query IS NULL + OR lower(n.slug) LIKE lower(concat('%', :query, '%')) + OR lower(n.displayName) LIKE lower(concat('%', :query, '%')) + ) + AND (:slug IS NULL OR n.slug = :slug) + """) + Page searchByIdIn(@Param("ids") List ids, + @Param("status") NamespaceStatus status, + @Param("query") String query, + @Param("slug") String slug, + Pageable pageable); }