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 5735db3f..851f99d5 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 @@ -4,44 +4,72 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.*; import com.iflytek.skillhub.dto.*; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + @RestController -@RequestMapping({"/api/v1/namespaces", "/api/web/namespaces"}) +@RequestMapping({"/api/v1", "/api/web"}) public class NamespaceController extends BaseApiController { private final NamespaceService namespaceService; private final NamespaceMemberService namespaceMemberService; private final NamespaceRepository namespaceRepository; + private final NamespaceGovernanceService namespaceGovernanceService; + private final NamespaceAccessPolicy namespaceAccessPolicy; public NamespaceController(NamespaceService namespaceService, NamespaceMemberService namespaceMemberService, NamespaceRepository namespaceRepository, + NamespaceGovernanceService namespaceGovernanceService, + NamespaceAccessPolicy namespaceAccessPolicy, ApiResponseFactory responseFactory) { super(responseFactory); this.namespaceService = namespaceService; this.namespaceMemberService = namespaceMemberService; this.namespaceRepository = namespaceRepository; + this.namespaceGovernanceService = namespaceGovernanceService; + this.namespaceAccessPolicy = namespaceAccessPolicy; } - @GetMapping + @GetMapping("/namespaces") public ApiResponse> listNamespaces(Pageable pageable) { Page namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable); PageResponse response = PageResponse.from(namespaces.map(NamespaceResponse::from)); return ok("response.success.read", response); } - @GetMapping("/{slug}") + @GetMapping("/me/namespaces") + public ApiResponse> listMyNamespaces( + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); + if (namespaceRoles.isEmpty()) { + return ok("response.success.read", List.of()); + } + + List response = namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() + .sorted(Comparator.comparing(Namespace::getSlug)) + .map(namespace -> MyNamespaceResponse.from(namespace, namespaceRoles.get(namespace.getId()), namespaceAccessPolicy)) + .toList(); + + return ok("response.success.read", response); + } + + @GetMapping("/namespaces/{slug}") public ApiResponse getNamespace(@PathVariable String slug) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); return ok("response.success.read", NamespaceResponse.from(namespace)); } - @PostMapping + @PostMapping("/namespaces") public ApiResponse createNamespace( @Valid @RequestBody NamespaceRequest request, @AuthenticationPrincipal PlatformPrincipal principal) { @@ -54,7 +82,7 @@ public class NamespaceController extends BaseApiController { return ok("response.success.created", NamespaceResponse.from(namespace)); } - @PutMapping("/{slug}") + @PutMapping("/namespaces/{slug}") public ApiResponse updateNamespace( @PathVariable String slug, @RequestBody NamespaceRequest request, @@ -70,7 +98,67 @@ public class NamespaceController extends BaseApiController { return ok("response.success.updated", NamespaceResponse.from(updated)); } - @GetMapping("/{slug}/members") + @PostMapping("/namespaces/{slug}/freeze") + public ApiResponse freezeNamespace(@PathVariable String slug, + @RequestBody(required = false) NamespaceLifecycleRequest request, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Namespace namespace = namespaceGovernanceService.freezeNamespace( + slug, + userId, + request != null ? request.reason() : null, + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", NamespaceResponse.from(namespace)); + } + + @PostMapping("/namespaces/{slug}/unfreeze") + public ApiResponse unfreezeNamespace(@PathVariable String slug, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Namespace namespace = namespaceGovernanceService.unfreezeNamespace( + slug, + userId, + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", NamespaceResponse.from(namespace)); + } + + @PostMapping("/namespaces/{slug}/archive") + public ApiResponse archiveNamespace(@PathVariable String slug, + @RequestBody(required = false) NamespaceLifecycleRequest request, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Namespace namespace = namespaceGovernanceService.archiveNamespace( + slug, + userId, + request != null ? request.reason() : null, + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", NamespaceResponse.from(namespace)); + } + + @PostMapping("/namespaces/{slug}/restore") + public ApiResponse restoreNamespace(@PathVariable String slug, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Namespace namespace = namespaceGovernanceService.restoreNamespace( + slug, + userId, + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", NamespaceResponse.from(namespace)); + } + + @GetMapping("/namespaces/{slug}/members") public ApiResponse> listMembers(@PathVariable String slug, Pageable pageable) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); Page members = namespaceMemberService.listMembers(namespace.getId(), pageable); @@ -78,7 +166,7 @@ public class NamespaceController extends BaseApiController { return ok("response.success.read", response); } - @PostMapping("/{slug}/members") + @PostMapping("/namespaces/{slug}/members") public ApiResponse addMember( @PathVariable String slug, @Valid @RequestBody MemberRequest request, @@ -93,7 +181,7 @@ public class NamespaceController extends BaseApiController { return ok("response.success.created", MemberResponse.from(member)); } - @DeleteMapping("/{slug}/members/{userId}") + @DeleteMapping("/namespaces/{slug}/members/{userId}") public ApiResponse removeMember( @PathVariable String slug, @PathVariable("userId") String memberUserId, @@ -103,7 +191,7 @@ public class NamespaceController extends BaseApiController { return ok("response.success.deleted", new MessageResponse("Member removed successfully")); } - @PutMapping("/{slug}/members/{userId}/role") + @PutMapping("/namespaces/{slug}/members/{userId}/role") public ApiResponse updateMemberRole( @PathVariable String slug, @PathVariable String userId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java new file mode 100644 index 00000000..5f29c8c0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; + +import java.time.LocalDateTime; + +public record MyNamespaceResponse( + Long id, + String slug, + String displayName, + NamespaceStatus status, + String description, + NamespaceType type, + String avatarUrl, + String createdBy, + LocalDateTime createdAt, + LocalDateTime updatedAt, + NamespaceRole currentUserRole, + boolean immutable, + boolean canFreeze, + boolean canUnfreeze, + boolean canArchive, + boolean canRestore +) { + public static MyNamespaceResponse from(Namespace namespace, + NamespaceRole currentUserRole, + NamespaceAccessPolicy accessPolicy) { + return new MyNamespaceResponse( + namespace.getId(), + namespace.getSlug(), + namespace.getDisplayName(), + namespace.getStatus(), + namespace.getDescription(), + namespace.getType(), + namespace.getAvatarUrl(), + namespace.getCreatedBy(), + namespace.getCreatedAt(), + namespace.getUpdatedAt(), + currentUserRole, + accessPolicy.isImmutable(namespace), + accessPolicy.canFreeze(namespace, currentUserRole), + accessPolicy.canUnfreeze(namespace, currentUserRole), + accessPolicy.canArchive(namespace, currentUserRole), + accessPolicy.canRestore(namespace, currentUserRole) + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java new file mode 100644 index 00000000..16c07882 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Size; + +public record NamespaceLifecycleRequest( + @Size(max = 512, message = "{validation.namespace.description.size}") + String reason +) {} 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 new file mode 100644 index 00000000..7a86d0ae --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -0,0 +1,130 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class NamespacePortalControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceService namespaceService; + + @MockBean + private NamespaceGovernanceService namespaceGovernanceService; + + @MockBean + private com.iflytek.skillhub.domain.namespace.NamespaceRepository namespaceRepository; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceRepository.findByIdIn(List.of(1L))).willReturn(List.of(namespace)); + given(namespaceMemberRepository.findByUserId("owner-1")) + .willReturn(List.of(new com.iflytek.skillhub.domain.namespace.NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); + + mockMvc.perform(get("/api/v1/me/namespaces") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].slug").value("team-a")) + .andExpect(jsonPath("$.data[0].status").value("ARCHIVED")) + .andExpect(jsonPath("$.data[0].currentUserRole").value("OWNER")); + } + + @Test + void archiveNamespace_returnsUpdatedNamespace() throws Exception { + Namespace archived = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceGovernanceService.archiveNamespace(eq("team-a"), eq("owner-1"), eq("cleanup"), nullable(String.class), any(), any())) + .willReturn(archived); + + mockMvc.perform(post("/api/v1/namespaces/team-a/archive") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"cleanup\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.slug").value("team-a")) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + } + + private RequestPostProcessor auth(String userId) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "session", + java.util.Set.of() + ); + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + return authentication(authenticationToken); + } + + private Namespace namespace(Long id, String slug, NamespaceStatus status, NamespaceType type) { + Namespace namespace = new Namespace(slug, "Team A", "owner-1"); + setField(namespace, "id", id); + namespace.setStatus(status); + namespace.setType(type); + return namespace; + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +}