mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
feat: add namespace management endpoints
This commit is contained in:
parent
7501bb3cf2
commit
971c75c936
4 changed files with 286 additions and 9 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue