diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index c0c34567..3bd04be2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -61,23 +61,28 @@ public class NamespaceController extends BaseApiController { @GetMapping("/namespaces") public ApiResponse> listNamespaces( Pageable pageable, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles)); + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.read", + namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles, platformRoles(principal))); } @GetMapping("/me/namespaces") public ApiResponse> listMyNamespaces( @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - return ok("response.success.read", namespacePortalQueryAppService.listMyNamespaces(userNsRoles)); + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.read", + namespacePortalQueryAppService.listMyNamespaces(userNsRoles, platformRoles(principal))); } @GetMapping("/namespaces/{slug}") public ApiResponse getNamespace(@PathVariable String slug, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @AuthenticationPrincipal PlatformPrincipal principal) { return ok("response.success.read", - namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles)); + namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles, platformRoles(principal))); } @PostMapping("/namespaces") @@ -158,11 +163,14 @@ public class NamespaceController extends BaseApiController { Pageable pageable, @RequestAttribute("userId") String userId, @AuthenticationPrincipal PlatformPrincipal principal) { - Set platformRoles = principal != null && principal.platformRoles() != null + return ok("response.success.read", + namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles(principal))); + } + + private Set platformRoles(PlatformPrincipal principal) { + return principal != null && principal.platformRoles() != null ? principal.platformRoles() : Set.of(); - return ok("response.success.read", - namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles)); } @GetMapping("/namespaces/{slug}/member-candidates") 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 e8df0ad9..6382eecb 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 @@ -55,7 +55,14 @@ public class NamespacePortalQueryAppService { } @Transactional(readOnly = true) - public PageResponse listNamespaces(Pageable pageable, Map userNamespaceRoles) { + public PageResponse listNamespaces(Pageable pageable, + Map userNamespaceRoles, + Set platformRoles) { + if (isSuperAdmin(platformRoles)) { + return PageResponse.from(namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable) + .map(NamespaceResponse::from)); + } + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); if (namespaceRoles.isEmpty()) { Page empty = new PageImpl<>( @@ -83,24 +90,34 @@ public class NamespacePortalQueryAppService { } @Transactional(readOnly = true) - public List listMyNamespaces(Map userNamespaceRoles) { + public List listMyNamespaces(Map userNamespaceRoles, + Set platformRoles) { Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + if (isSuperAdmin(platformRoles)) { + return namespaceRepository.findAll().stream() + .sorted(Comparator.comparing(Namespace::getSlug)) + .map(namespace -> toMyNamespaceResponse(namespace, namespaceRoles.get(namespace.getId()))) + .toList(); + } if (namespaceRoles.isEmpty()) { return List.of(); } return namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() .sorted(Comparator.comparing(Namespace::getSlug)) - .map(namespace -> MyNamespaceResponse.from( - namespace, - namespaceRoles.get(namespace.getId()), - namespaceAccessPolicy, - namespaceService.canDelete(namespace, namespaceRoles.get(namespace.getId())))) + .map(namespace -> toMyNamespaceResponse(namespace, namespaceRoles.get(namespace.getId()))) .toList(); } @Transactional(readOnly = true) - public NamespaceResponse getNamespace(String slug, String userId, Map userNamespaceRoles) { + public NamespaceResponse getNamespace(String slug, + String userId, + Map userNamespaceRoles, + Set platformRoles) { + if (isSuperAdmin(platformRoles)) { + return NamespaceResponse.from(namespaceService.getNamespaceBySlug(slug)); + } + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); Namespace namespace = namespaceService.getNamespaceBySlugForRead( slug, @@ -138,4 +155,16 @@ public class NamespacePortalQueryAppService { MemberResponse.from(member, userMap.get(member.getUserId())) )); } + + private MyNamespaceResponse toMyNamespaceResponse(Namespace namespace, NamespaceRole role) { + return MyNamespaceResponse.from( + namespace, + role, + namespaceAccessPolicy, + role != null && namespaceService.canDelete(namespace, role)); + } + + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 566d3ef0..5754f16c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -95,6 +95,60 @@ class NamespacePortalControllerTest { .andExpect(jsonPath("$.data[0].canDelete").value(false)); } + @Test + void listMyNamespaces_allowsSuperAdminToSeeAllNamespacesWithoutMembership() throws Exception { + Namespace teamA = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace teamB = namespace(2L, "team-b", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceRepository.findAll()).willReturn(List.of(teamB, teamA)); + given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); + + mockMvc.perform(get("/api/v1/me/namespaces") + .with(auth("super-1", Set.of("SUPER_ADMIN"))) + .requestAttr("userId", "super-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].slug").value("team-a")) + .andExpect(jsonPath("$.data[0].currentUserRole").doesNotExist()) + .andExpect(jsonPath("$.data[1].slug").value("team-b")) + .andExpect(jsonPath("$.data[1].status").value("ARCHIVED")); + } + + @Test + void getNamespace_allowsSuperAdminToReadArchivedNamespaceWithoutMembership() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace); + given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); + + mockMvc.perform(get("/api/v1/namespaces/team-a") + .with(auth("super-1", Set.of("SUPER_ADMIN"))) + .requestAttr("userId", "super-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.slug").value("team-a")) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + } + + @Test + void listNamespaces_allowsSuperAdminToSeeActiveNamespacesWithoutMembership() throws Exception { + Namespace teamA = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>( + List.of(teamA), + org.springframework.data.domain.PageRequest.of(0, 20), + 1 + )); + given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); + + mockMvc.perform(get("/api/v1/namespaces") + .param("page", "0") + .param("size", "20") + .with(auth("super-1", Set.of("SUPER_ADMIN"))) + .requestAttr("userId", "super-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].slug").value("team-a")); + } + @Test void getNamespace_requiresAuthentication() throws Exception { mockMvc.perform(get("/api/v1/namespaces/team-a")) 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 05a9fd3c..af368140 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 @@ -67,7 +67,7 @@ class NamespacePortalQueryAppServiceTest { var response = service.listMyNamespaces(Map.of( 2L, NamespaceRole.ADMIN, 1L, NamespaceRole.OWNER - )); + ), Set.of()); assertThat(response).hasSize(2); assertThat(response.get(0).slug()).isEqualTo("alpha"); @@ -93,7 +93,8 @@ class NamespacePortalQueryAppServiceTest { 1L, NamespaceRole.MEMBER, 2L, NamespaceRole.ADMIN, 3L, NamespaceRole.OWNER - ) + ), + Set.of() ); assertThat(response.items()).hasSize(2); @@ -107,10 +108,54 @@ class NamespacePortalQueryAppServiceTest { when(namespaceService.getNamespaceBySlugForRead("team-a", "user-1", Map.of())) .thenReturn(namespace); - assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of())) + assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of(), Set.of())) .isInstanceOf(DomainForbiddenException.class); } + @Test + void listNamespaces_superAdminSeesAllActiveNamespacesWithoutMembership() { + Namespace teamA = namespace(1L, "team-a"); + Namespace teamB = namespace(2L, "team-b"); + when(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(teamA, teamB), PageRequest.of(0, 20), 2)); + + var response = service.listNamespaces(PageRequest.of(0, 20), Map.of(), Set.of("SUPER_ADMIN")); + + assertThat(response.items()).extracting("slug").containsExactly("team-a", "team-b"); + } + + @Test + void listMyNamespaces_superAdminSeesAllNamespacesWithoutSyntheticOwnerActions() { + Namespace active = namespace(1L, "active-team"); + Namespace archived = namespace(2L, "archived-team"); + archived.setStatus(NamespaceStatus.ARCHIVED); + when(namespaceRepository.findAll()).thenReturn(List.of(archived, active)); + when(namespaceAccessPolicy.isImmutable(active)).thenReturn(false); + when(namespaceAccessPolicy.isImmutable(archived)).thenReturn(false); + + var response = service.listMyNamespaces(Map.of(), Set.of("SUPER_ADMIN")); + + assertThat(response).hasSize(2); + assertThat(response.get(0).slug()).isEqualTo("active-team"); + assertThat(response.get(0).currentUserRole()).isNull(); + assertThat(response.get(0).canFreeze()).isFalse(); + assertThat(response.get(0).canArchive()).isFalse(); + assertThat(response.get(0).canDelete()).isFalse(); + assertThat(response.get(1).slug()).isEqualTo("archived-team"); + } + + @Test + void getNamespace_superAdminReadsNamespaceWithoutMembership() { + Namespace namespace = namespace(1L, "team-a"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + + var response = service.getNamespace("team-a", "super-1", Map.of(), Set.of("SUPER_ADMIN")); + + assertThat(response.slug()).isEqualTo("team-a"); + assertThat(response.status()).isEqualTo(NamespaceStatus.ARCHIVED); + } + private Namespace namespace(Long id, String slug) { Namespace namespace = new Namespace(slug, slug, "owner-1"); ReflectionTestUtils.setField(namespace, "id", id); 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 dc558a93..2cf39c13 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 @@ -11,6 +11,7 @@ import java.util.Optional; */ public interface NamespaceRepository { Optional findById(Long id); + List findAll(); List findByIdIn(List ids); Optional findBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable);