fix(namespace): bound filtered namespace queries

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-07-27 16:00:28 +08:00
parent 2fa7a53e2b
commit a55264b130
4 changed files with 221 additions and 0 deletions

View file

@ -163,6 +163,50 @@ public class NamespacePortalQueryAppService {
return PageResponse.from(responsePage);
}
@Transactional(readOnly = true)
public PageResponse<MyNamespaceResponse> listMyNamespaces(Pageable pageable,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles,
NamespaceStatus status,
String query,
String slug,
Set<NamespaceRole> roles) {
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
Set<NamespaceRole> requestedRoles = roles != null ? roles : Set.of();
Pageable boundedPageable = normalizeMyNamespacesPageable(pageable);
String normalizedQuery = normalizeFilter(query);
String normalizedSlug = normalizeFilter(slug);
if (isSuperAdmin(platformRoles) && requestedRoles.isEmpty()) {
Page<Namespace> visibleNamespaces = namespaceRepository.search(
status,
normalizedQuery,
normalizedSlug,
boundedPageable
);
return PageResponse.from(visibleNamespaces.map(namespace -> myNamespaceResponse(namespace, namespaceRoles)));
}
List<Long> scopedNamespaceIds = namespaceRoles.entrySet().stream()
.filter(entry -> requestedRoles.isEmpty() || requestedRoles.contains(entry.getValue()))
.map(Map.Entry::getKey)
.sorted()
.toList();
if (scopedNamespaceIds.isEmpty()) {
Page<MyNamespaceResponse> empty = new PageImpl<>(List.of(), boundedPageable, 0);
return PageResponse.from(empty);
}
Page<Namespace> 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<Long, NamespaceRole> 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<String> platformRoles) {
return platformRoles != null && platformRoles.contains(SUPER_ADMIN_ROLE);
}

View file

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

View file

@ -15,6 +15,14 @@ public interface NamespaceRepository {
Optional<Namespace> findBySlug(String slug);
Page<Namespace> findAll(Pageable pageable);
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
Page<Namespace> search(NamespaceStatus status, String query, String slug, Pageable pageable);
Page<Namespace> searchByIdIn(
List<Long> ids,
NamespaceStatus status,
String query,
String slug,
Pageable pageable
);
Namespace save(Namespace namespace);
void delete(Namespace namespace);
}

View file

@ -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<Namespace> findByIdIn(List<Long> ids);
Optional<Namespace> findBySlug(String slug);
Page<Namespace> 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<Namespace> 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<Namespace> searchByIdIn(@Param("ids") List<Long> ids,
@Param("status") NamespaceStatus status,
@Param("query") String query,
@Param("slug") String slug,
Pageable pageable);
}