feat: add namespace management endpoints

This commit is contained in:
yun-zhi-ztl 2026-03-16 11:46:28 +08:00 • committed by Xudong Sun
parent 7501bb3cf2
commit 971c75c936
4 changed files with 286 additions and 9 deletions

View file

@ -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<PageResponse<NamespaceResponse>> listNamespaces(Pageable pageable) {
Page<Namespace> namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable);
PageResponse<NamespaceResponse> response = PageResponse.from(namespaces.map(NamespaceResponse::from));
return ok("response.success.read", response);
}
@GetMapping("/{slug}")
@GetMapping("/me/namespaces")
public ApiResponse<List<MyNamespaceResponse>> listMyNamespaces(
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
if (namespaceRoles.isEmpty()) {
return ok("response.success.read", List.of());
}
List<MyNamespaceResponse> 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<NamespaceResponse> getNamespace(@PathVariable String slug) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
return ok("response.success.read", NamespaceResponse.from(namespace));
}
@PostMapping
@PostMapping("/namespaces")
public ApiResponse<NamespaceResponse> 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<NamespaceResponse> 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<NamespaceResponse> 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<NamespaceResponse> 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<NamespaceResponse> 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<NamespaceResponse> 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<PageResponse<MemberResponse>> listMembers(@PathVariable String slug, Pageable pageable) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
Page<NamespaceMember> 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<MemberResponse> 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<MessageResponse> 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<MemberResponse> updateMemberRole(
@PathVariable String slug,
@PathVariable String userId,

View file

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

View file

@ -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
) {}

View file

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