Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,19 @@ public ApiResponse<NamespaceResponse> restoreNamespace(@PathVariable String slug
AuditRequestContext.from(httpRequest)));
}

@DeleteMapping("/namespaces/{slug}")
public ApiResponse<MessageResponse> deleteNamespace(@PathVariable String slug,
@RequestBody(required = false) NamespaceLifecycleRequest request,
@RequestAttribute("userId") String userId,
HttpServletRequest httpRequest) {
return ok("response.success.deleted",
governanceWorkflowAppService.deleteNamespace(
slug,
request,
userId,
AuditRequestContext.from(httpRequest)));
}

@GetMapping("/namespaces/{slug}/members")
public ApiResponse<PageResponse<MemberResponse>> listMembers(@PathVariable String slug,
Pageable pageable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ public record MyNamespaceResponse(
boolean canFreeze,
boolean canUnfreeze,
boolean canArchive,
boolean canRestore
boolean canRestore,
boolean canDelete
) {
public static MyNamespaceResponse from(Namespace namespace,
NamespaceRole currentUserRole,
Expand All @@ -45,7 +46,8 @@ public static MyNamespaceResponse from(Namespace namespace,
accessPolicy.canFreeze(namespace, currentUserRole),
accessPolicy.canUnfreeze(namespace, currentUserRole),
accessPolicy.canArchive(namespace, currentUserRole),
accessPolicy.canRestore(namespace, currentUserRole)
accessPolicy.canRestore(namespace, currentUserRole),
accessPolicy.canDelete(namespace, currentUserRole)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,13 @@ public NamespaceResponse restoreNamespace(String slug,
return namespacePortalCommandAppService.restoreNamespace(slug, userId, auditContext);
}

public com.iflytek.skillhub.dto.MessageResponse deleteNamespace(String slug,
NamespaceLifecycleRequest request,
String userId,
AuditRequestContext auditContext) {
return namespacePortalCommandAppService.deleteNamespace(slug, request, userId, auditContext);
}

public SkillLifecycleMutationResponse submitForReview(String namespace,
String slug,
String version,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,22 @@ public NamespaceResponse restoreNamespace(String slug, String userId, AuditReque
return NamespaceResponse.from(namespace);
}

@Transactional
public MessageResponse deleteNamespace(String slug,
NamespaceLifecycleRequest request,
String userId,
AuditRequestContext auditContext) {
namespaceGovernanceService.deleteNamespace(
slug,
userId,
request != null ? request.reason() : null,
null,
auditContext.clientIp(),
auditContext.userAgent()
);
return new MessageResponse("Namespace deleted successfully");
}

@Transactional
public MemberResponse addMember(String slug, String memberUserId, com.iflytek.skillhub.domain.namespace.NamespaceRole role, String operatorUserId) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- V41__add_namespace_deleted_status.sql
-- Add DELETED status to namespace lifecycle

-- Note: PostgreSQL enum types cannot be altered directly in a transaction-safe way.
-- The namespace.status column uses VARCHAR(32), so no schema change is needed.
-- This migration serves as documentation that DELETED is now a valid status value.

-- Add comment to document the new status
COMMENT ON COLUMN namespace.status IS 'Namespace lifecycle status: ACTIVE, FROZEN, ARCHIVED, DELETED';
5 changes: 5 additions & 0 deletions server/skillhub-app/src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ error.namespace.member.search.tooShort=Search keyword must be at least 2 charact
error.namespace.owner.current.notFound=Current owner not found
error.namespace.owner.current.invalid=Current user is not the namespace owner
error.namespace.owner.new.notFound=New owner is not a namespace member
error.namespace.state.transition.invalid=Invalid namespace state transition for: {0}
error.namespace.lifecycle.forbidden=Forbidden namespace lifecycle operation for: {0}
error.namespace.system.immutable=System namespace cannot be modified: {0}
error.namespace.delete.mustBeArchived=Namespace must be archived before deletion: {0}
error.namespace.delete.hasActiveSkills=Cannot delete namespace with active skills: {0}
error.skill.metadata.content.empty=SKILL.md content cannot be empty
error.skill.metadata.frontmatter.missingStart=Missing frontmatter start marker ''---''
error.skill.metadata.frontmatter.missingContent=Missing frontmatter content after start marker
Expand Down
5 changes: 5 additions & 0 deletions server/skillhub-app/src/main/resources/messages_zh.properties
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ error.namespace.member.search.tooShort=搜索关键词至少需要 2 个字符
error.namespace.owner.current.notFound=未找到当前所有者
error.namespace.owner.current.invalid=当前用户不是命名空间所有者
error.namespace.owner.new.notFound=新所有者不是该命名空间成员
error.namespace.state.transition.invalid=命名空间状态转换非法:{0}
error.namespace.lifecycle.forbidden=命名空间生命周期操作被禁止:{0}
error.namespace.system.immutable=系统命名空间不可修改:{0}
error.namespace.delete.mustBeArchived=命名空间必须先归档才能删除:{0}
error.namespace.delete.hasActiveSkills=不能删除包含活跃技能的命名空间:{0}
error.skill.metadata.content.empty=SKILL.md 内容不能为空
error.skill.metadata.frontmatter.missingStart=缺少 frontmatter 起始标记 ---
error.skill.metadata.frontmatter.missingContent=frontmatter 起始标记后缺少内容
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.iflytek.skillhub.controller.portal;

import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.dto.MessageResponse;
import com.iflytek.skillhub.service.GovernanceWorkflowAppService;
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.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doThrow;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(TestRedisConfig.class)
class NamespaceControllerTest {

@Autowired
private MockMvc mockMvc;

@MockBean
private GovernanceWorkflowAppService governanceWorkflowAppService;

@MockBean
private DeviceAuthService deviceAuthService;

@Test
void deleteNamespace_success_whenArchivedAndOwner() throws Exception {
given(governanceWorkflowAppService.deleteNamespace(
eq("test-ns"),
any(),
eq("usr_1"),
any()))
.willReturn(new MessageResponse("Namespace deleted successfully"));

mockMvc.perform(delete("/api/web/namespaces/test-ns")
.requestAttr("userId", "usr_1")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"No longer needed\"}")
.with(user("usr_1"))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.message").exists());
}

@Test
void deleteNamespace_fails_whenNotArchived() throws Exception {
doThrow(new DomainBadRequestException("error.namespace.delete.mustBeArchived", "test-ns"))
.when(governanceWorkflowAppService).deleteNamespace(
eq("test-ns"),
any(),
eq("usr_1"),
any());

mockMvc.perform(delete("/api/web/namespaces/test-ns")
.requestAttr("userId", "usr_1")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"No longer needed\"}")
.with(user("usr_1"))
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}

@Test
void deleteNamespace_fails_whenNotOwner() throws Exception {
doThrow(new DomainForbiddenException("error.namespace.lifecycle.forbidden", "test-ns"))
.when(governanceWorkflowAppService).deleteNamespace(
eq("test-ns"),
any(),
eq("usr_2"),
any());

mockMvc.perform(delete("/api/web/namespaces/test-ns")
.requestAttr("userId", "usr_2")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"No longer needed\"}")
.with(user("usr_2"))
.with(csrf()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(403));
}

@Test
void deleteNamespace_fails_whenHasActiveSkills() throws Exception {
doThrow(new DomainBadRequestException("error.namespace.delete.hasActiveSkills", "test-ns"))
.when(governanceWorkflowAppService).deleteNamespace(
eq("test-ns"),
any(),
eq("usr_1"),
any());

mockMvc.perform(delete("/api/web/namespaces/test-ns")
.requestAttr("userId", "usr_1")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"No longer needed\"}")
.with(user("usr_1"))
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,10 @@ public boolean canRestore(Namespace namespace, NamespaceRole role) {
&& namespace.getStatus() == NamespaceStatus.ARCHIVED
&& role == NamespaceRole.OWNER;
}

public boolean canDelete(Namespace namespace, NamespaceRole role) {
return namespace.getType() == NamespaceType.TEAM
&& namespace.getStatus() == NamespaceStatus.ARCHIVED
&& role == NamespaceRole.OWNER;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* Applies namespace lifecycle transitions such as freeze, unfreeze, archive,
* and restore while recording audit history.
* restore, and delete while recording audit history.
*/
@Service
public class NamespaceGovernanceService {
Expand All @@ -17,15 +19,18 @@ public class NamespaceGovernanceService {
private final NamespaceMemberRepository namespaceMemberRepository;
private final NamespaceAccessPolicy namespaceAccessPolicy;
private final AuditLogService auditLogService;
private final SkillRepository skillRepository;

public NamespaceGovernanceService(NamespaceRepository namespaceRepository,
NamespaceMemberRepository namespaceMemberRepository,
NamespaceAccessPolicy namespaceAccessPolicy,
AuditLogService auditLogService) {
AuditLogService auditLogService,
SkillRepository skillRepository) {
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.namespaceAccessPolicy = namespaceAccessPolicy;
this.auditLogService = auditLogService;
this.skillRepository = skillRepository;
}

@Transactional
Expand Down Expand Up @@ -110,6 +115,35 @@ public Namespace restoreNamespace(String slug,
return updated;
}

@Transactional
public void deleteNamespace(String slug,
String actorUserId,
String reason,
String requestId,
String clientIp,
String userAgent) {
Namespace namespace = loadNamespaceBySlug(slug);
NamespaceRole role = requireRole(namespace.getId(), actorUserId);

if (namespace.getStatus() != NamespaceStatus.ARCHIVED) {
throw new DomainBadRequestException("error.namespace.delete.mustBeArchived", namespace.getSlug());
}

if (!namespaceAccessPolicy.canDelete(namespace, role)) {
throw new DomainForbiddenException("error.namespace.lifecycle.forbidden", namespace.getSlug());
}

// Check if namespace has any active skills
long activeSkillCount = skillRepository.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE).size();
if (activeSkillCount > 0) {
throw new DomainBadRequestException("error.namespace.delete.hasActiveSkills", namespace.getSlug());
}

namespace.setStatus(NamespaceStatus.DELETED);
namespaceRepository.save(namespace);
record("DELETE_NAMESPACE", actorUserId, namespace.getId(), requestId, clientIp, userAgent, reason);
}

private Namespace loadNamespaceBySlug(String slug) {
Namespace namespace = namespaceRepository.findBySlug(slug)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package com.iflytek.skillhub.domain.namespace;

public enum NamespaceStatus {
ACTIVE, FROZEN, ARCHIVED
ACTIVE, FROZEN, ARCHIVED, DELETED
}
Loading
Loading