From 8dd16685552a4b832b04a71a9317d619c675dea4 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 8 May 2026 10:15:17 +0800 Subject: [PATCH 1/4] fix(namespace): support team namespace deletion --- .../portal/NamespaceController.java | 8 + .../skillhub/dto/MyNamespaceResponse.java | 9 +- .../NamespacePortalCommandAppService.java | 7 + .../NamespacePortalQueryAppService.java | 3 +- .../src/main/resources/messages.properties | 2 + .../src/main/resources/messages_zh.properties | 2 + .../NamespacePortalControllerTest.java | 18 +- .../NamespacePortalCommandAppServiceTest.java | 12 + .../NamespacePortalQueryAppServiceTest.java | 4 + .../skillhub/stream/ScanTaskConsumerTest.java | 5 + .../namespace/NamespaceAccessPolicy.java | 5 + .../namespace/NamespaceMemberRepository.java | 1 + .../domain/namespace/NamespaceRepository.java | 1 + .../domain/namespace/NamespaceService.java | 59 ++++- .../review/PromotionRequestRepository.java | 1 + .../domain/review/ReviewTaskRepository.java | 1 + .../domain/skill/SkillRepository.java | 1 + .../namespace/NamespaceAccessPolicyTest.java | 12 + .../namespace/NamespaceServiceTest.java | 102 ++++++++ .../infra/jpa/JpaSkillRepositoryAdapter.java | 5 + .../jpa/NamespaceMemberJpaRepository.java | 1 + .../jpa/PromotionRequestJpaRepository.java | 2 + .../infra/jpa/ReviewTaskJpaRepository.java | 2 + .../infra/jpa/SkillJpaRepository.java | 1 + web/e2e/helpers/test-data-builder.ts | 1 + web/e2e/my-namespaces-data.spec.ts | 58 ++++- web/src/api/client.test.ts | 47 ++++ web/src/api/client.ts | 7 + web/src/api/generated/schema.d.ts | 49 +++- web/src/api/types.ts | 1 + web/src/features/review/review-paths.test.ts | 4 + web/src/i18n/locales/en.json | 6 + web/src/i18n/locales/zh.json | 6 + web/src/pages/dashboard/my-namespaces.test.ts | 147 ++++++++++- web/src/pages/dashboard/my-namespaces.tsx | 230 ++++++++++++------ web/src/shared/components/confirm-dialog.tsx | 8 +- web/src/shared/hooks/use-namespace-queries.ts | 16 ++ web/src/shared/ui/dialog.tsx | 2 + 38 files changed, 744 insertions(+), 102 deletions(-) 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 4b3faf70..a5268c32 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 @@ -95,6 +95,14 @@ public class NamespaceController extends BaseApiController { namespacePortalCommandAppService.updateNamespace(slug, request, userId)); } + @DeleteMapping("/namespaces/{slug}") + public ApiResponse deleteNamespace( + @PathVariable String slug, + @RequestAttribute("userId") String userId) { + return ok("response.success.deleted", + namespacePortalCommandAppService.deleteNamespace(slug, userId)); + } + @PostMapping("/namespaces/{slug}/freeze") public ApiResponse freezeNamespace(@PathVariable String slug, @RequestBody(required = false) NamespaceLifecycleRequest request, 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 index 09640c10..3d019381 100644 --- 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 @@ -24,11 +24,13 @@ public record MyNamespaceResponse( boolean canFreeze, boolean canUnfreeze, boolean canArchive, - boolean canRestore + boolean canRestore, + boolean canDelete ) { public static MyNamespaceResponse from(Namespace namespace, NamespaceRole currentUserRole, - NamespaceAccessPolicy accessPolicy) { + NamespaceAccessPolicy accessPolicy, + boolean canDelete) { return new MyNamespaceResponse( namespace.getId(), namespace.getSlug(), @@ -45,7 +47,8 @@ public record MyNamespaceResponse( accessPolicy.canFreeze(namespace, currentUserRole), accessPolicy.canUnfreeze(namespace, currentUserRole), accessPolicy.canArchive(namespace, currentUserRole), - accessPolicy.canRestore(namespace, currentUserRole) + accessPolicy.canRestore(namespace, currentUserRole), + canDelete ); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java index b6d4caea..396938fb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java @@ -82,6 +82,13 @@ public class NamespacePortalCommandAppService { return NamespaceResponse.from(updated); } + @Transactional + public MessageResponse deleteNamespace(String slug, String userId) { + Namespace namespace = namespaceService.getNamespaceBySlug(slug); + namespaceService.deleteNamespace(namespace.getId(), userId); + return new MessageResponse("Namespace deleted successfully"); + } + @Transactional public NamespaceResponse freezeNamespace(String slug, NamespaceLifecycleRequest request, 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 cff2eb59..82b38610 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 @@ -92,7 +92,8 @@ public class NamespacePortalQueryAppService { .map(namespace -> MyNamespaceResponse.from( namespace, namespaceRoles.get(namespace.getId()), - namespaceAccessPolicy)) + namespaceAccessPolicy, + namespaceService.canDelete(namespace, namespaceRoles.get(namespace.getId())))) .toList(); } diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 029bbff8..3b63392b 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -69,7 +69,9 @@ error.namespace.id.notFound=Namespace not found: {0} error.namespace.slug.notFound=Namespace not found: {0} error.namespace.membership.required=Namespace membership required error.namespace.admin.required=Namespace owner or admin role required +error.namespace.owner.required=Namespace owner role required error.namespace.create.platformAdminRequired=Only SKILL_ADMIN or SUPER_ADMIN can create namespaces +error.namespace.delete.hasDependencies=Namespace cannot be deleted while it still contains skills or governance records error.namespace.member.owner.assignDirect=Cannot assign OWNER role directly error.namespace.member.alreadyExists=User is already a namespace member error.namespace.member.notFound=Member not found diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index b338f6be..85f8d284 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -69,7 +69,9 @@ error.namespace.id.notFound=未找到命名空间:{0} error.namespace.slug.notFound=未找到命名空间:{0} error.namespace.membership.required=需要先加入该命名空间 error.namespace.admin.required=需要命名空间管理员或所有者权限 +error.namespace.owner.required=需要命名空间所有者权限 error.namespace.create.platformAdminRequired=只有 SKILL_ADMIN 或 SUPER_ADMIN 可以创建命名空间 +error.namespace.delete.hasDependencies=命名空间下仍有技能或治理记录,暂时不能删除 error.namespace.member.owner.assignDirect=不能直接分配 OWNER 角色 error.namespace.member.alreadyExists=用户已经是该命名空间成员 error.namespace.member.notFound=未找到命名空间成员 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 404d6419..699cb740 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 @@ -39,6 +39,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doThrow; 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.delete; 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; @@ -90,7 +91,8 @@ class NamespacePortalControllerTest { .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")); + .andExpect(jsonPath("$.data[0].currentUserRole").value("OWNER")) + .andExpect(jsonPath("$.data[0].canDelete").value(false)); } @Test @@ -144,6 +146,20 @@ class NamespacePortalControllerTest { .andExpect(jsonPath("$.data.description").value("Updated description")); } + @Test + void deleteNamespace_returnsSuccessMessage() throws Exception { + Namespace existing = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceService.getNamespaceBySlug("team-a")).willReturn(existing); + + mockMvc.perform(delete("/api/v1/namespaces/team-a") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.message").value("Namespace deleted successfully")); + } + @Test void listMembers_forNonMember_returns403() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java index 719ad40d..0082d7dd 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java @@ -19,6 +19,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.MemberResponse; +import com.iflytek.skillhub.dto.MessageResponse; import com.iflytek.skillhub.dto.NamespaceLifecycleRequest; import com.iflytek.skillhub.dto.NamespaceRequest; import com.iflytek.skillhub.dto.UpdateMemberRoleRequest; @@ -74,6 +75,17 @@ class NamespacePortalCommandAppServiceTest { verify(namespaceGovernanceService).freezeNamespace("team-alpha", "owner-1", "cleanup", null, "127.0.0.1", "JUnit"); } + @Test + void deleteNamespace_delegatesToDomainService() { + Namespace namespace = namespace(7L, "team-alpha"); + when(namespaceService.getNamespaceBySlug("team-alpha")).thenReturn(namespace); + + MessageResponse response = service.deleteNamespace("team-alpha", "owner-1"); + + assertThat(response.message()).isEqualTo("Namespace deleted successfully"); + verify(namespaceService).deleteNamespace(7L, "owner-1"); + } + private Namespace namespace(Long id, String slug) { Namespace namespace = new Namespace(slug, "Team Alpha", "owner-1"); ReflectionTestUtils.setField(namespace, "id", id); 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 6091a905..10e58718 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 @@ -55,11 +55,13 @@ class NamespacePortalQueryAppServiceTest { when(namespaceAccessPolicy.canUnfreeze(alpha, NamespaceRole.OWNER)).thenReturn(false); when(namespaceAccessPolicy.canArchive(alpha, NamespaceRole.OWNER)).thenReturn(true); when(namespaceAccessPolicy.canRestore(alpha, NamespaceRole.OWNER)).thenReturn(false); + when(namespaceService.canDelete(alpha, NamespaceRole.OWNER)).thenReturn(true); when(namespaceAccessPolicy.isImmutable(zeta)).thenReturn(false); when(namespaceAccessPolicy.canFreeze(zeta, NamespaceRole.ADMIN)).thenReturn(true); when(namespaceAccessPolicy.canUnfreeze(zeta, NamespaceRole.ADMIN)).thenReturn(false); when(namespaceAccessPolicy.canArchive(zeta, NamespaceRole.ADMIN)).thenReturn(true); when(namespaceAccessPolicy.canRestore(zeta, NamespaceRole.ADMIN)).thenReturn(false); + when(namespaceService.canDelete(zeta, NamespaceRole.ADMIN)).thenReturn(false); var response = service.listMyNamespaces(Map.of( 2L, NamespaceRole.ADMIN, @@ -69,8 +71,10 @@ class NamespacePortalQueryAppServiceTest { assertThat(response).hasSize(2); assertThat(response.get(0).slug()).isEqualTo("alpha"); assertThat(response.get(0).currentUserRole()).isEqualTo(NamespaceRole.OWNER); + assertThat(response.get(0).canDelete()).isTrue(); assertThat(response.get(1).slug()).isEqualTo("zeta"); assertThat(response.get(1).currentUserRole()).isEqualTo(NamespaceRole.ADMIN); + assertThat(response.get(1).canDelete()).isFalse(); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java index 66bd9864..8e9a8ff6 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java @@ -481,6 +481,11 @@ class ScanTaskConsumerTest { throw unsupported(); } + @Override + public boolean existsByNamespaceId(Long namespaceId) { + return false; + } + @Override public void deleteBySkillVersionIdIn(Collection skillVersionIds) { throw unsupported(); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java index 5482628a..59e867c0 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java @@ -49,4 +49,9 @@ public class NamespaceAccessPolicy { && namespace.getStatus() == NamespaceStatus.ARCHIVED && role == NamespaceRole.OWNER; } + + public boolean canDelete(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && role == NamespaceRole.OWNER; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java index 7b4cab44..dc4545b9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java @@ -16,5 +16,6 @@ public interface NamespaceMemberRepository { Page findByNamespaceId(Long namespaceId, Pageable pageable); List findByNamespaceIdAndRoleIn(Long namespaceId, Collection roles); NamespaceMember save(NamespaceMember member); + void deleteByNamespaceId(Long namespaceId); void deleteByNamespaceIdAndUserId(Long namespaceId, String userId); } 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 775206e9..dc558a93 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 @@ -15,4 +15,5 @@ public interface NamespaceRepository { Optional findBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); Namespace save(Namespace namespace); + void delete(Namespace namespace); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java index 32f20fd1..e54379e0 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java @@ -1,7 +1,10 @@ package com.iflytek.skillhub.domain.namespace; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.SkillRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,13 +19,22 @@ public class NamespaceService { private final NamespaceRepository namespaceRepository; private final NamespaceMemberRepository namespaceMemberRepository; private final NamespaceAccessPolicy namespaceAccessPolicy; + private final SkillRepository skillRepository; + private final ReviewTaskRepository reviewTaskRepository; + private final PromotionRequestRepository promotionRequestRepository; public NamespaceService(NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, - NamespaceAccessPolicy namespaceAccessPolicy) { + NamespaceAccessPolicy namespaceAccessPolicy, + SkillRepository skillRepository, + ReviewTaskRepository reviewTaskRepository, + PromotionRequestRepository promotionRequestRepository) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.namespaceAccessPolicy = namespaceAccessPolicy; + this.skillRepository = skillRepository; + this.reviewTaskRepository = reviewTaskRepository; + this.promotionRequestRepository = promotionRequestRepository; } /** @@ -102,13 +114,34 @@ public class NamespaceService { .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); } + /** + * Permanently deletes an empty team namespace after ownership checks. + */ + @Transactional + public void deleteNamespace(Long namespaceId, String operatorUserId) { + Namespace namespace = getNamespace(namespaceId); + assertNotImmutable(namespace); + + NamespaceRole role = requireRole(namespaceId, operatorUserId); + if (!namespaceAccessPolicy.canDelete(namespace, role)) { + throw new DomainForbiddenException("error.namespace.owner.required"); + } + assertNoDependentData(namespaceId); + + namespaceMemberRepository.deleteByNamespaceId(namespaceId); + namespaceRepository.delete(namespace); + } + + public boolean canDelete(Namespace namespace, NamespaceRole role) { + return namespaceAccessPolicy.canDelete(namespace, role) + && !hasDependentData(namespace.getId()); + } + /** * Ensures the caller holds an owner or admin membership in the namespace. */ public void assertAdminOrOwner(Long namespaceId, String userId) { - NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) - .map(NamespaceMember::getRole) - .orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required")); + NamespaceRole role = requireRole(namespaceId, userId); if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) { throw new DomainForbiddenException("error.namespace.admin.required"); } @@ -133,6 +166,24 @@ public class NamespaceService { } } + private NamespaceRole requireRole(Long namespaceId, String userId) { + return namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .map(NamespaceMember::getRole) + .orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required")); + } + + private void assertNoDependentData(Long namespaceId) { + if (hasDependentData(namespaceId)) { + throw new DomainBadRequestException("error.namespace.delete.hasDependencies"); + } + } + + private boolean hasDependentData(Long namespaceId) { + return skillRepository.existsByNamespaceId(namespaceId) + || reviewTaskRepository.existsByNamespaceId(namespaceId) + || promotionRequestRepository.existsByTargetNamespaceId(namespaceId); + } + private void assertWritable(Namespace namespace) { if (!namespaceAccessPolicy.canMutateSettings(namespace)) { throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java index 64b8fd53..05d07f04 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java @@ -14,6 +14,7 @@ public interface PromotionRequestRepository { Optional findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status); Optional findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status); Page findByStatus(ReviewTaskStatus status, Pageable pageable); + boolean existsByTargetNamespaceId(Long namespaceId); void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId); int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, String reviewComment, Long targetSkillId, Integer expectedVersion); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java index c825d994..25c7d9f0 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java @@ -15,6 +15,7 @@ public interface ReviewTaskRepository { Page findByStatus(ReviewTaskStatus status, Pageable pageable); Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); + boolean existsByNamespaceId(Long namespaceId); void deleteBySkillVersionIdIn(Collection skillVersionIds); void delete(ReviewTask reviewTask); int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java index facf3091..520ebed1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java @@ -16,6 +16,7 @@ public interface SkillRepository { List findByNamespaceIdAndSlug(Long namespaceId, String slug); Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId); List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status); + boolean existsByNamespaceId(Long namespaceId); Skill save(Skill skill); void flush(); void delete(Skill skill); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicyTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicyTest.java index 6e5e9f69..245849e7 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicyTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicyTest.java @@ -54,4 +54,16 @@ class NamespaceAccessPolicyTest { assertThat(policy.canRestore(namespace, NamespaceRole.OWNER)).isTrue(); assertThat(policy.canRestore(namespace, NamespaceRole.ADMIN)).isFalse(); } + + @Test + void deleteIsOwnerOnlyForTeamNamespaces() { + Namespace namespace = new Namespace("team-a", "Team A", "owner"); + namespace.setType(NamespaceType.TEAM); + + assertThat(policy.canDelete(namespace, NamespaceRole.OWNER)).isTrue(); + assertThat(policy.canDelete(namespace, NamespaceRole.ADMIN)).isFalse(); + + namespace.setType(NamespaceType.GLOBAL); + assertThat(policy.canDelete(namespace, NamespaceRole.OWNER)).isFalse(); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java index 48953f90..12c555f3 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java @@ -2,12 +2,16 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.skill.SkillRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.lang.reflect.Field; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -26,6 +30,15 @@ class NamespaceServiceTest { @Mock private NamespaceAccessPolicy namespaceAccessPolicy; + @Mock + private SkillRepository skillRepository; + + @Mock + private ReviewTaskRepository reviewTaskRepository; + + @Mock + private PromotionRequestRepository promotionRequestRepository; + @InjectMocks private NamespaceService namespaceService; @@ -195,4 +208,93 @@ class NamespaceServiceTest { assertThrows(DomainForbiddenException.class, () -> namespaceService.assertMember(namespaceId, userId)); } + + @Test + void deleteNamespace_shouldDeleteMembersAndNamespaceForOwner() { + Long namespaceId = 1L; + String operatorUserId = "owner-1"; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) + .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER))); + when(skillRepository.existsByNamespaceId(namespaceId)).thenReturn(false); + when(reviewTaskRepository.existsByNamespaceId(namespaceId)).thenReturn(false); + when(promotionRequestRepository.existsByTargetNamespaceId(namespaceId)).thenReturn(false); + + namespaceService.deleteNamespace(namespaceId, operatorUserId); + + verify(namespaceMemberRepository).deleteByNamespaceId(namespaceId); + verify(namespaceRepository).delete(namespace); + } + + @Test + void deleteNamespace_shouldRejectAdminUser() { + Long namespaceId = 1L; + String operatorUserId = "admin-1"; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.ADMIN)).thenReturn(false); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) + .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.ADMIN))); + + DomainForbiddenException exception = assertThrows(DomainForbiddenException.class, () -> + namespaceService.deleteNamespace(namespaceId, operatorUserId)); + + assertEquals("error.namespace.owner.required", exception.messageCode()); + verify(namespaceRepository, never()).delete(any()); + } + + @Test + void deleteNamespace_shouldRejectNamespaceWithDependentData() { + Long namespaceId = 1L; + String operatorUserId = "owner-1"; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) + .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER))); + when(skillRepository.existsByNamespaceId(namespaceId)).thenReturn(true); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> + namespaceService.deleteNamespace(namespaceId, operatorUserId)); + + assertEquals("error.namespace.delete.hasDependencies", exception.messageCode()); + verify(namespaceMemberRepository, never()).deleteByNamespaceId(namespaceId); + verify(namespaceRepository, never()).delete(any()); + } + + @Test + void canDelete_shouldReturnFalseWhenRoleCannotDelete() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + + assertFalse(namespaceService.canDelete(namespace, NamespaceRole.ADMIN)); + verify(skillRepository, never()).existsByNamespaceId(any()); + } + + @Test + void canDelete_shouldReflectDependencyChecksForOwner() { + Long namespaceId = 1L; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true); + setField(namespace, "id", namespaceId); + when(skillRepository.existsByNamespaceId(namespaceId)).thenReturn(false); + when(reviewTaskRepository.existsByNamespaceId(namespaceId)).thenReturn(false); + when(promotionRequestRepository.existsByTargetNamespaceId(namespaceId)).thenReturn(true); + + assertFalse(namespaceService.canDelete(namespace, NamespaceRole.OWNER)); + } + + private void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + fail(e); + } + } } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java index 1249f12d..ed209a50 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -57,6 +57,11 @@ public class JpaSkillRepositoryAdapter implements SkillRepository { return delegate.findByNamespaceIdAndStatus(namespaceId, status); } + @Override + public boolean existsByNamespaceId(Long namespaceId) { + return delegate.existsByNamespaceId(namespaceId); + } + @Override public Skill save(Skill skill) { return jpaDelegate.save(skill); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java index ccbb7074..d1b1be75 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java @@ -22,5 +22,6 @@ public interface NamespaceMemberJpaRepository List findByUserId(String userId); Page findByNamespaceId(Long namespaceId, Pageable pageable); List findByNamespaceIdAndRoleIn(Long namespaceId, Collection roles); + void deleteByNamespaceId(Long namespaceId); void deleteByNamespaceIdAndUserId(Long namespaceId, String userId); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java index b04a7c63..c26f611e 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java @@ -25,6 +25,8 @@ public interface PromotionRequestJpaRepository extends JpaRepository findByStatus(ReviewTaskStatus status, Pageable pageable); + boolean existsByTargetNamespaceId(Long targetNamespaceId); + void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId); @Modifying(clearAutomatically = true, flushAutomatically = true) diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java index 1efd038e..286c0eab 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java @@ -28,6 +28,8 @@ public interface ReviewTaskJpaRepository extends JpaRepository Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); + boolean existsByNamespaceId(Long namespaceId); + void deleteBySkillVersionIdIn(Collection skillVersionIds); @Modifying diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java index 55ad2aad..1b2e0246 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java @@ -23,6 +23,7 @@ public interface SkillJpaRepository extends JpaRepository, SkillRep List findByIdIn(List ids); List findByNamespaceIdAndSlug(Long namespaceId, String slug); Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId); + boolean existsByNamespaceId(Long namespaceId); @Override default List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) { diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 0123468b..80591591 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -15,6 +15,7 @@ export interface SeededNamespace { currentUserRole?: string canUnfreeze?: boolean canRestore?: boolean + canDelete?: boolean } export interface SeededSkill { diff --git a/web/e2e/my-namespaces-data.spec.ts b/web/e2e/my-namespaces-data.spec.ts index af2d4b64..28284603 100644 --- a/web/e2e/my-namespaces-data.spec.ts +++ b/web/e2e/my-namespaces-data.spec.ts @@ -1,12 +1,13 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -import { registerSession } from './helpers/session' import { E2eTestDataBuilder } from './helpers/test-data-builder' test.describe('My Namespaces Data (Real API)', () => { - test.beforeEach(async ({ page }, testInfo) => { + test.beforeEach(async ({ page }) => { await setEnglishLocale(page) - await registerSession(page, testInfo) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', + }) }) test('shows namespace created by request helper', async ({ page }, testInfo) => { @@ -23,4 +24,55 @@ test.describe('My Namespaces Data (Real API)', () => { await builder.cleanup() } }) + + test('deletes a writable namespace from the dashboard', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.createNamespace('e2e-delete') + + await page.goto('/dashboard/namespaces') + + const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`) + await expect(namespaceCard.getByText(`@${namespace.slug}`)).toBeVisible() + + await page.getByTestId(`delete-namespace-${namespace.slug}`).click() + await expect(page.getByTestId('namespace-action-dialog-delete')).toBeVisible() + + const deleteResponsePromise = page.waitForResponse((response) => + response.request().method() === 'DELETE' + && response.url().includes(`/api/web/namespaces/${namespace.slug}`), + ) + await page.getByTestId('namespace-action-confirm-delete').click() + const deleteResponse = await deleteResponsePromise + + expect(deleteResponse.ok()).toBeTruthy() + await expect(page.getByText(`@${namespace.slug}`)).toHaveCount(0) + } finally { + await builder.cleanup() + } + }) + + test('hides the delete action when the namespace has dependent skills', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.createNamespace('e2e-delete-guard') + + await builder.publishSkill(namespace.slug, { + name: `Delete Guard ${Date.now()}`, + description: 'Prevents namespace deletion during Playwright validation', + }) + + await page.goto('/dashboard/namespaces') + + const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`) + await expect(namespaceCard.getByText(`@${namespace.slug}`)).toBeVisible() + await expect(page.getByTestId(`delete-namespace-${namespace.slug}`)).toHaveCount(0) + } finally { + await builder.cleanup() + } + }) }) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 4e1810cb..f2d4287b 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const originalWindow = globalThis.window +const originalDocument = globalThis.document function setMockWindow(runtimeConfig?: Window['__SKILLHUB_RUNTIME_CONFIG__']) { Object.defineProperty(globalThis, 'window', { @@ -39,6 +40,7 @@ import { fetchText, getDirectAuthRuntimeConfig, getSessionBootstrapRuntimeConfig, + namespaceApi, } from './client' beforeEach(() => { @@ -48,6 +50,16 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals() + if (originalDocument) { + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: originalDocument, + }) + } else { + Reflect.deleteProperty(globalThis, 'document') + } + if (originalWindow) { Object.defineProperty(globalThis, 'window', { configurable: true, @@ -116,6 +128,41 @@ describe('fetchText', () => { }) }) +describe('namespaceApi.delete', () => { + it('sends a DELETE request to the normalized namespace endpoint', async () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com' } + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { + cookie: 'XSRF-TOKEN=test-token', + }, + }) + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + msg: 'ok', + data: null, + timestamp: '2026-05-07T00:00:00Z', + requestId: 'req-test', + }), + }) + vi.stubGlobal('fetch', fetchMock) + + await namespaceApi.delete('@team-delete') + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/web/namespaces/team-delete', + expect.objectContaining({ + method: 'DELETE', + headers: expect.any(Headers), + }), + ) + }) +}) + describe('getDirectAuthRuntimeConfig', () => { it('returns disabled when no runtime config is present', () => { const config = getDirectAuthRuntimeConfig() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0573fa18..3a9911d5 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -675,6 +675,13 @@ export const namespaceApi = { }) }, + async delete(slug: string): Promise { + await fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`, { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }) + }, + async listMembers(slug: string): Promise { const page = await fetchJson<{ items: NamespaceMember[] }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members`) return page.items diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 316f17b7..3d9ff18d 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -270,7 +270,7 @@ export interface paths { get: operations["getNamespace"]; put: operations["updateNamespace"]; post?: never; - delete?: never; + delete: operations["deleteNamespace"]; options?: never; head?: never; patch?: never; @@ -286,7 +286,7 @@ export interface paths { get: operations["getNamespace_1"]; put: operations["updateNamespace_1"]; post?: never; - delete?: never; + delete: operations["deleteNamespace_1"]; options?: never; head?: never; patch?: never; @@ -4183,6 +4183,7 @@ export interface components { canUnfreeze?: boolean; canArchive?: boolean; canRestore?: boolean; + canDelete?: boolean; }; ApiResponseGovernanceSummaryResponse: { /** Format: int32 */ @@ -5351,6 +5352,28 @@ export interface operations { }; }; }; + deleteNamespace: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; getNamespace_1: { parameters: { query?: never; @@ -5399,6 +5422,28 @@ export interface operations { }; }; }; + deleteNamespace_1: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; updateExpiration: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 0cba6fcc..fd7714b6 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -121,6 +121,7 @@ export interface ManagedNamespace extends Namespace { canUnfreeze: boolean canArchive: boolean canRestore: boolean + canDelete: boolean } export interface NamespaceMember { diff --git a/web/src/features/review/review-paths.test.ts b/web/src/features/review/review-paths.test.ts index bcd61200..0a9dc531 100644 --- a/web/src/features/review/review-paths.test.ts +++ b/web/src/features/review/review-paths.test.ts @@ -45,6 +45,7 @@ describe('review-paths', () => { canUnfreeze: false, canArchive: false, canRestore: false, + canDelete: false, currentUserRole: 'ADMIN', createdAt: '', }, @@ -59,6 +60,7 @@ describe('review-paths', () => { canUnfreeze: false, canArchive: false, canRestore: false, + canDelete: false, currentUserRole: 'OWNER', createdAt: '', }, @@ -78,6 +80,7 @@ describe('review-paths', () => { canUnfreeze: false, canArchive: false, canRestore: false, + canDelete: false, currentUserRole: 'MEMBER', createdAt: '', }, @@ -98,6 +101,7 @@ describe('review-paths', () => { canUnfreeze: false, canArchive: false, canRestore: false, + canDelete: false, currentUserRole: 'ADMIN', createdAt: '', }, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b54bfdac..29bd4a0f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -428,6 +428,7 @@ "unfreeze": "Unfreeze", "archive": "Archive", "restore": "Restore", + "delete": "Delete", "activeHint": "This namespace is fully active and can continue managing members, reviews, and skill publishing.", "frozenHint": "This namespace is frozen. Members can still view it, but it is read-only and cannot publish or change membership.", "archivedHint": "This namespace is archived. Public entry points are hidden, but you can still view and restore it from the dashboard.", @@ -440,6 +441,8 @@ "archiveConfirmDescription": "\"{{name}}\" will be hidden from public entry points and only remain recoverable from the dashboard.", "restoreConfirmTitle": "Restore namespace", "restoreConfirmDescription": "\"{{name}}\" will return to an active operating state.", + "deleteConfirmTitle": "Delete namespace", + "deleteConfirmDescription": "\"{{name}}\" will be permanently deleted. This cannot be undone.", "freezeSuccessTitle": "Namespace frozen", "freezeSuccessDescription": "\"{{name}}\" is now read-only.", "freezeErrorTitle": "Failed to freeze namespace", @@ -452,6 +455,9 @@ "restoreSuccessTitle": "Namespace restored", "restoreSuccessDescription": "\"{{name}}\" can operate normally again.", "restoreErrorTitle": "Failed to restore namespace", + "deleteSuccessTitle": "Namespace deleted", + "deleteSuccessDescription": "\"{{name}}\" has been permanently deleted.", + "deleteErrorTitle": "Failed to delete namespace", "emptyTitle": "No namespaces yet", "emptyDescription": "Create a namespace to organize your skills" }, diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 593b4bc4..12e1704b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -428,6 +428,7 @@ "unfreeze": "解冻", "archive": "归档", "restore": "恢复", + "delete": "删除", "activeHint": "命名空间运行正常,可继续管理成员、处理审核和发布技能。", "frozenHint": "命名空间已冻结。成员仍可查看,但当前处于只读状态,不能继续发布或变更成员。", "archivedHint": "命名空间已归档。公开入口已隐藏,但你仍可在管理台查看并恢复。", @@ -440,6 +441,8 @@ "archiveConfirmDescription": "归档后“{{name}}”会从公开入口隐藏,仅保留管理台恢复入口。", "restoreConfirmTitle": "确认恢复命名空间", "restoreConfirmDescription": "恢复后“{{name}}”会重新进入可运营状态。", + "deleteConfirmTitle": "确认删除命名空间", + "deleteConfirmDescription": "删除后会永久移除“{{name}}”,且无法恢复。", "freezeSuccessTitle": "命名空间已冻结", "freezeSuccessDescription": "“{{name}}”已切换为只读状态。", "freezeErrorTitle": "冻结命名空间失败", @@ -452,6 +455,9 @@ "restoreSuccessTitle": "命名空间已恢复", "restoreSuccessDescription": "“{{name}}”已重新开放治理能力。", "restoreErrorTitle": "恢复命名空间失败", + "deleteSuccessTitle": "命名空间已删除", + "deleteSuccessDescription": "“{{name}}”已被永久删除。", + "deleteErrorTitle": "删除命名空间失败", "emptyTitle": "还没有命名空间", "emptyDescription": "创建一个命名空间来组织你的技能" }, diff --git a/web/src/pages/dashboard/my-namespaces.test.ts b/web/src/pages/dashboard/my-namespaces.test.ts index a5b1b58f..f3044a6c 100644 --- a/web/src/pages/dashboard/my-namespaces.test.ts +++ b/web/src/pages/dashboard/my-namespaces.test.ts @@ -1,7 +1,20 @@ -import { describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ReactNode } from 'react' +import type { ManagedNamespace } from '@/api/types' + +const navigateMock = vi.fn() +const freezeMutateAsync = vi.fn() +const unfreezeMutateAsync = vi.fn() +const archiveMutateAsync = vi.fn() +const restoreMutateAsync = vi.fn() +const deleteMutateAsync = vi.fn() + +let mockNamespaces: ManagedNamespace[] = [] vi.mock('@tanstack/react-router', () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => navigateMock, })) vi.mock('react-i18next', async () => { @@ -19,19 +32,25 @@ vi.mock('@/features/auth/use-auth', () => ({ })) vi.mock('@/shared/ui/button', () => ({ - Button: ({ children }: { children: unknown }) => children, + Button: ({ children, ...props }: { children: ReactNode }) => createElement('button', props, children), })) vi.mock('@/shared/ui/card', () => ({ - Card: ({ children }: { children: unknown }) => children, + Card: ({ children, ...props }: { children: ReactNode }) => createElement('div', props, children), })) vi.mock('@/shared/components/namespace-badge', () => ({ - NamespaceBadge: () => null, + NamespaceBadge: ({ name }: { name: string }) => createElement('span', null, name), })) vi.mock('@/shared/components/empty-state', () => ({ - EmptyState: () => null, + EmptyState: ({ title, description, action }: { title: string; description: string; action?: ReactNode }) => createElement( + 'section', + null, + title, + description, + action, + ), })) vi.mock('@/shared/components/confirm-dialog', () => ({ @@ -39,19 +58,26 @@ vi.mock('@/shared/components/confirm-dialog', () => ({ })) vi.mock('@/shared/components/dashboard-page-header', () => ({ - DashboardPageHeader: () => null, + DashboardPageHeader: ({ title, subtitle, actions }: { title: string; subtitle: string; actions?: ReactNode }) => createElement( + 'header', + null, + title, + subtitle, + actions, + ), })) vi.mock('@/features/namespace/create-namespace-dialog', () => ({ - CreateNamespaceDialog: () => null, + CreateNamespaceDialog: ({ children }: { children: ReactNode }) => children, })) vi.mock('@/shared/hooks/use-namespace-queries', () => ({ - useArchiveNamespace: () => ({ mutateAsync: vi.fn() }), - useFreezeNamespace: () => ({ mutateAsync: vi.fn() }), - useMyNamespaces: () => ({ data: [], isLoading: false }), - useRestoreNamespace: () => ({ mutateAsync: vi.fn() }), - useUnfreezeNamespace: () => ({ mutateAsync: vi.fn() }), + useArchiveNamespace: () => ({ mutateAsync: archiveMutateAsync }), + useDeleteNamespace: () => ({ mutateAsync: deleteMutateAsync }), + useFreezeNamespace: () => ({ mutateAsync: freezeMutateAsync }), + useMyNamespaces: () => ({ data: mockNamespaces, isLoading: false }), + useRestoreNamespace: () => ({ mutateAsync: restoreMutateAsync }), + useUnfreezeNamespace: () => ({ mutateAsync: unfreezeMutateAsync }), })) vi.mock('@/shared/lib/toast', () => ({ @@ -59,9 +85,104 @@ vi.mock('@/shared/lib/toast', () => ({ })) import { MyNamespacesPage } from './my-namespaces' +import { executeNamespaceAction, resolveNamespaceActionCopy } from './my-namespaces' + +function buildNamespace(overrides: Partial = {}): ManagedNamespace { + return { + id: 1, + slug: 'team-ml', + displayName: 'Team ML', + description: 'namespace', + type: 'TEAM', + status: 'ACTIVE', + createdAt: '2026-05-07T00:00:00Z', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + canDelete: false, + ...overrides, + } +} describe('MyNamespacesPage', () => { + beforeEach(() => { + navigateMock.mockReset() + freezeMutateAsync.mockReset() + unfreezeMutateAsync.mockReset() + archiveMutateAsync.mockReset() + restoreMutateAsync.mockReset() + deleteMutateAsync.mockReset() + mockNamespaces = [] + }) + it('exports a named component function', () => { expect(typeof MyNamespacesPage).toBe('function') }) + + it('renders the delete action when the namespace is deletable', () => { + mockNamespaces = [buildNamespace({ canDelete: true })] + + const html = renderToStaticMarkup(createElement(MyNamespacesPage)) + + expect(html).toContain('myNamespaces.delete') + }) + + it('hides the delete action when the namespace is not deletable', () => { + mockNamespaces = [buildNamespace({ canDelete: false })] + + const html = renderToStaticMarkup(createElement(MyNamespacesPage)) + + expect(html).not.toContain('myNamespaces.delete') + }) + + it('routes delete actions to the delete mutation and emits success feedback', async () => { + const t = (key: string) => key + const copy = resolveNamespaceActionCopy(t, 'delete', 'Team ML') + deleteMutateAsync.mockResolvedValueOnce(undefined) + const success = vi.fn() + const error = vi.fn() + + await executeNamespaceAction( + { action: 'delete', slug: 'team-ml', name: 'Team ML' }, + { + freeze: { mutateAsync: freezeMutateAsync }, + unfreeze: { mutateAsync: unfreezeMutateAsync }, + archive: { mutateAsync: archiveMutateAsync }, + restore: { mutateAsync: restoreMutateAsync }, + delete: { mutateAsync: deleteMutateAsync }, + }, + copy, + { success, error }, + ) + + expect(deleteMutateAsync).toHaveBeenCalledWith({ slug: 'team-ml' }) + expect(success).toHaveBeenCalledWith('myNamespaces.deleteSuccessTitle', 'myNamespaces.deleteSuccessDescription') + expect(error).not.toHaveBeenCalled() + }) + + it('surfaces delete failures through error feedback and rethrows', async () => { + const t = (key: string) => key + const copy = resolveNamespaceActionCopy(t, 'delete', 'Team ML') + deleteMutateAsync.mockRejectedValueOnce(new Error('blocked')) + const success = vi.fn() + const error = vi.fn() + + await expect(executeNamespaceAction( + { action: 'delete', slug: 'team-ml', name: 'Team ML' }, + { + freeze: { mutateAsync: freezeMutateAsync }, + unfreeze: { mutateAsync: unfreezeMutateAsync }, + archive: { mutateAsync: archiveMutateAsync }, + restore: { mutateAsync: restoreMutateAsync }, + delete: { mutateAsync: deleteMutateAsync }, + }, + copy, + { success, error }, + )).rejects.toThrow('blocked') + + expect(error).toHaveBeenCalledWith('myNamespaces.deleteErrorTitle', 'blocked') + expect(success).not.toHaveBeenCalled() + }) }) diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index f1e1fe4f..235f0b11 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -9,7 +9,14 @@ import { EmptyState } from '@/shared/components/empty-state' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog' -import { useArchiveNamespace, useFreezeNamespace, useMyNamespaces, useRestoreNamespace, useUnfreezeNamespace } from '@/shared/hooks/use-namespace-queries' +import { + useArchiveNamespace, + useDeleteNamespace, + useFreezeNamespace, + useMyNamespaces, + useRestoreNamespace, + useUnfreezeNamespace, +} from '@/shared/hooks/use-namespace-queries' import { toast } from '@/shared/lib/toast' type PendingNamespaceAction = @@ -17,6 +24,120 @@ type PendingNamespaceAction = | { action: 'unfreeze'; slug: string; name: string } | { action: 'archive'; slug: string; name: string } | { action: 'restore'; slug: string; name: string } + | { action: 'delete'; slug: string; name: string } + +type NamespaceActionCopy = { + title: string + description: string + confirmText: string + successTitle: string + successDescription: string + errorTitle: string + variant: 'default' | 'destructive' +} + +type NamespaceActionMutation = { + mutateAsync: (input: { slug: string }) => Promise +} + +type NamespaceActionMutations = { + freeze: NamespaceActionMutation + unfreeze: NamespaceActionMutation + archive: NamespaceActionMutation + restore: NamespaceActionMutation + delete: NamespaceActionMutation +} + +type NamespaceActionToast = { + success: (title: string, description?: string) => void + error: (title: string, description?: string) => void +} + +export function resolveNamespaceActionCopy( + t: (key: string, options?: Record) => string, + action: PendingNamespaceAction['action'], + name: string, +): NamespaceActionCopy { + if (action === 'freeze') { + return { + title: t('myNamespaces.freezeConfirmTitle'), + description: t('myNamespaces.freezeConfirmDescription', { name }), + confirmText: t('myNamespaces.freeze'), + successTitle: t('myNamespaces.freezeSuccessTitle'), + successDescription: t('myNamespaces.freezeSuccessDescription', { name }), + errorTitle: t('myNamespaces.freezeErrorTitle'), + variant: 'default', + } + } + if (action === 'unfreeze') { + return { + title: t('myNamespaces.unfreezeConfirmTitle'), + description: t('myNamespaces.unfreezeConfirmDescription', { name }), + confirmText: t('myNamespaces.unfreeze'), + successTitle: t('myNamespaces.unfreezeSuccessTitle'), + successDescription: t('myNamespaces.unfreezeSuccessDescription', { name }), + errorTitle: t('myNamespaces.unfreezeErrorTitle'), + variant: 'default', + } + } + if (action === 'archive') { + return { + title: t('myNamespaces.archiveConfirmTitle'), + description: t('myNamespaces.archiveConfirmDescription', { name }), + confirmText: t('myNamespaces.archive'), + successTitle: t('myNamespaces.archiveSuccessTitle'), + successDescription: t('myNamespaces.archiveSuccessDescription', { name }), + errorTitle: t('myNamespaces.archiveErrorTitle'), + variant: 'destructive', + } + } + if (action === 'delete') { + return { + title: t('myNamespaces.deleteConfirmTitle'), + description: t('myNamespaces.deleteConfirmDescription', { name }), + confirmText: t('myNamespaces.delete'), + successTitle: t('myNamespaces.deleteSuccessTitle'), + successDescription: t('myNamespaces.deleteSuccessDescription', { name }), + errorTitle: t('myNamespaces.deleteErrorTitle'), + variant: 'destructive', + } + } + return { + title: t('myNamespaces.restoreConfirmTitle'), + description: t('myNamespaces.restoreConfirmDescription', { name }), + confirmText: t('myNamespaces.restore'), + successTitle: t('myNamespaces.restoreSuccessTitle'), + successDescription: t('myNamespaces.restoreSuccessDescription', { name }), + errorTitle: t('myNamespaces.restoreErrorTitle'), + variant: 'default', + } +} + +export async function executeNamespaceAction( + pendingAction: PendingNamespaceAction, + mutations: NamespaceActionMutations, + copy: NamespaceActionCopy, + notifications: NamespaceActionToast, +) { + try { + if (pendingAction.action === 'freeze') { + await mutations.freeze.mutateAsync({ slug: pendingAction.slug }) + } else if (pendingAction.action === 'unfreeze') { + await mutations.unfreeze.mutateAsync({ slug: pendingAction.slug }) + } else if (pendingAction.action === 'archive') { + await mutations.archive.mutateAsync({ slug: pendingAction.slug }) + } else if (pendingAction.action === 'delete') { + await mutations.delete.mutateAsync({ slug: pendingAction.slug }) + } else { + await mutations.restore.mutateAsync({ slug: pendingAction.slug }) + } + + notifications.success(copy.successTitle, copy.successDescription) + } catch (error) { + notifications.error(copy.errorTitle, error instanceof Error ? error.message : '') + throw error + } +} /** * Dashboard page for namespaces the current user can manage or review. It owns @@ -34,6 +155,7 @@ export function MyNamespacesPage() { const unfreezeMutation = useUnfreezeNamespace() const archiveMutation = useArchiveNamespace() const restoreMutation = useRestoreNamespace() + const deleteMutation = useDeleteNamespace() const handleNamespaceClick = (slug: string) => { navigate({ to: `/space/${encodeURIComponent(slug)}` }) @@ -82,79 +204,25 @@ export function MyNamespacesPage() { return t('myNamespaces.activeHint') } - /** - * Centralizes dialog copy so lifecycle operations can reuse one confirmation - * component without scattering user-facing strings across event handlers. - */ - const resolveActionCopy = (action: PendingNamespaceAction['action'], name: string) => { - if (action === 'freeze') { - return { - title: t('myNamespaces.freezeConfirmTitle'), - description: t('myNamespaces.freezeConfirmDescription', { name }), - confirmText: t('myNamespaces.freeze'), - successTitle: t('myNamespaces.freezeSuccessTitle'), - successDescription: t('myNamespaces.freezeSuccessDescription', { name }), - errorTitle: t('myNamespaces.freezeErrorTitle'), - variant: 'default' as const, - } - } - if (action === 'unfreeze') { - return { - title: t('myNamespaces.unfreezeConfirmTitle'), - description: t('myNamespaces.unfreezeConfirmDescription', { name }), - confirmText: t('myNamespaces.unfreeze'), - successTitle: t('myNamespaces.unfreezeSuccessTitle'), - successDescription: t('myNamespaces.unfreezeSuccessDescription', { name }), - errorTitle: t('myNamespaces.unfreezeErrorTitle'), - variant: 'default' as const, - } - } - if (action === 'archive') { - return { - title: t('myNamespaces.archiveConfirmTitle'), - description: t('myNamespaces.archiveConfirmDescription', { name }), - confirmText: t('myNamespaces.archive'), - successTitle: t('myNamespaces.archiveSuccessTitle'), - successDescription: t('myNamespaces.archiveSuccessDescription', { name }), - errorTitle: t('myNamespaces.archiveErrorTitle'), - variant: 'destructive' as const, - } - } - return { - title: t('myNamespaces.restoreConfirmTitle'), - description: t('myNamespaces.restoreConfirmDescription', { name }), - confirmText: t('myNamespaces.restore'), - successTitle: t('myNamespaces.restoreSuccessTitle'), - successDescription: t('myNamespaces.restoreSuccessDescription', { name }), - errorTitle: t('myNamespaces.restoreErrorTitle'), - variant: 'default' as const, - } - } - const handleNamespaceAction = async () => { if (!pendingAction) { return } - const copy = resolveActionCopy(pendingAction.action, pendingAction.name) - - try { - if (pendingAction.action === 'freeze') { - await freezeMutation.mutateAsync({ slug: pendingAction.slug }) - } else if (pendingAction.action === 'unfreeze') { - await unfreezeMutation.mutateAsync({ slug: pendingAction.slug }) - } else if (pendingAction.action === 'archive') { - await archiveMutation.mutateAsync({ slug: pendingAction.slug }) - } else { - await restoreMutation.mutateAsync({ slug: pendingAction.slug }) - } - - toast.success(copy.successTitle, copy.successDescription) - setPendingAction(null) - } catch (error) { - toast.error(copy.errorTitle, error instanceof Error ? error.message : '') - throw error - } + const copy = resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name) + await executeNamespaceAction( + pendingAction, + { + freeze: freezeMutation, + unfreeze: unfreezeMutation, + archive: archiveMutation, + restore: restoreMutation, + delete: deleteMutation, + }, + copy, + toast, + ) + setPendingAction(null) } if (isLoading) { @@ -184,6 +252,7 @@ export function MyNamespacesPage() { {namespaces.map((namespace, idx) => ( handleNamespaceClick(namespace.slug)} > @@ -281,6 +350,19 @@ export function MyNamespacesPage() { {t('myNamespaces.restore')} )} + {namespace.canDelete && ( + + )} @@ -305,10 +387,12 @@ export function MyNamespacesPage() { setPendingAction(null) } }} - title={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).title : ''} - description={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).description : ''} - confirmText={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).confirmText : undefined} - variant={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).variant : 'default'} + title={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).title : ''} + description={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).description : ''} + confirmText={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).confirmText : undefined} + variant={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).variant : 'default'} + contentTestId={pendingAction ? `namespace-action-dialog-${pendingAction.action}` : undefined} + confirmButtonTestId={pendingAction ? `namespace-action-confirm-${pendingAction.action}` : undefined} onConfirm={handleNamespaceAction} /> diff --git a/web/src/shared/components/confirm-dialog.tsx b/web/src/shared/components/confirm-dialog.tsx index 5da8dea0..b760ac45 100644 --- a/web/src/shared/components/confirm-dialog.tsx +++ b/web/src/shared/components/confirm-dialog.tsx @@ -19,6 +19,8 @@ interface ConfirmDialogProps { cancelText?: string variant?: 'default' | 'destructive' onConfirm: () => void | Promise + contentTestId?: string + confirmButtonTestId?: string } export function ConfirmDialog({ @@ -30,6 +32,8 @@ export function ConfirmDialog({ cancelText, variant = 'default', onConfirm, + contentTestId, + confirmButtonTestId, }: ConfirmDialogProps) { const { t } = useTranslation() const resolvedConfirmText = confirmText ?? t('dialog.confirm') @@ -41,7 +45,7 @@ export function ConfirmDialog({ return ( - + {title} {description && {description}} @@ -50,7 +54,7 @@ export function ConfirmDialog({ - diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index aa5d3efd..6e9753a1 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -36,6 +36,10 @@ async function removeNamespaceMember(params: { slug: string; userId: string }): return namespaceApi.removeMember(params.slug, params.userId) } +async function deleteNamespace(params: { slug: string }): Promise { + return namespaceApi.delete(params.slug) +} + async function batchAddNamespaceMembers(params: { slug: string; members: Array<{ userId: string; role: string }> }): Promise { return namespaceApi.batchAddMembers(params.slug, params.members) } @@ -186,3 +190,15 @@ export function useRestoreNamespace() { }, }) } + +export function useDeleteNamespace() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: deleteNamespace, + onSuccess: (_data, variables) => { + invalidateNamespaceQueries(queryClient, variables.slug) + queryClient.invalidateQueries({ queryKey: ['namespaces'] }) + }, + }) +} diff --git a/web/src/shared/ui/dialog.tsx b/web/src/shared/ui/dialog.tsx index d029b6f2..38d2b1e9 100644 --- a/web/src/shared/ui/dialog.tsx +++ b/web/src/shared/ui/dialog.tsx @@ -112,6 +112,8 @@ const DialogContent = React.forwardRef
Date: Fri, 8 May 2026 14:42:27 +0800 Subject: [PATCH 2/4] fix(namespace): add index on promotion_request.target_namespace_id The existsByTargetNamespaceId query used in namespace deletion dependency checks was missing an index, causing a sequential scan. --- .../V41__add_promotion_request_target_namespace_index.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V41__add_promotion_request_target_namespace_index.sql diff --git a/server/skillhub-app/src/main/resources/db/migration/V41__add_promotion_request_target_namespace_index.sql b/server/skillhub-app/src/main/resources/db/migration/V41__add_promotion_request_target_namespace_index.sql new file mode 100644 index 00000000..bf581892 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V41__add_promotion_request_target_namespace_index.sql @@ -0,0 +1 @@ +CREATE INDEX idx_promotion_request_target_namespace ON promotion_request(target_namespace_id); From e869a4d3653f1b1ec58e5f4a0a7fe132f1403e1d Mon Sep 17 00:00:00 2001 From: Cheney <970320820@qq.com> Date: Sat, 9 May 2026 13:55:50 +0800 Subject: [PATCH 3/4] fix(web): restore compatibility with Chromium 83 (Debian 10) Publish page dropdowns (namespace/visibility) failed to open on older Chromium because Vite 6 defaults build target to chrome87 and some bundled deps call runtime APIs absent in Chrome 83 (replaceAll, .at, hasOwn). Lower esbuild/vite target to chrome83, add browserslist, drop ??= in bootstrap, and inject runtime polyfills before main loads. --- web/.browserslistrc | 4 ++ web/src/bootstrap.ts | 19 +++++---- web/src/legacy-polyfills.ts | 78 +++++++++++++++++++++++++++++++++++++ web/vite.config.ts | 14 +++++++ 4 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 web/.browserslistrc create mode 100644 web/src/legacy-polyfills.ts diff --git a/web/.browserslistrc b/web/.browserslistrc new file mode 100644 index 00000000..fdca1742 --- /dev/null +++ b/web/.browserslistrc @@ -0,0 +1,4 @@ +Chrome >= 83 +Edge >= 83 +Firefox >= 78 +Safari >= 14 diff --git a/web/src/bootstrap.ts b/web/src/bootstrap.ts index a8ac6927..203d7ebb 100644 --- a/web/src/bootstrap.ts +++ b/web/src/bootstrap.ts @@ -4,6 +4,7 @@ * Deployments inject `/runtime-config.js` at startup, and this file guarantees the app sees either * that config or a safe fallback object before importing the main entry. */ +import './legacy-polyfills' async function loadRuntimeConfig() { await new Promise((resolve, reject) => { const script = document.createElement('script') @@ -16,14 +17,16 @@ async function loadRuntimeConfig() { } function ensureRuntimeConfigFallback() { - window.__SKILLHUB_RUNTIME_CONFIG__ ??= { - apiBaseUrl: '', - appBaseUrl: '', - authDirectEnabled: 'false', - authDirectProvider: '', - authSessionBootstrapEnabled: 'false', - authSessionBootstrapProvider: '', - authSessionBootstrapAuto: 'false', + if (!window.__SKILLHUB_RUNTIME_CONFIG__) { + window.__SKILLHUB_RUNTIME_CONFIG__ = { + apiBaseUrl: '', + appBaseUrl: '', + authDirectEnabled: 'false', + authDirectProvider: '', + authSessionBootstrapEnabled: 'false', + authSessionBootstrapProvider: '', + authSessionBootstrapAuto: 'false', + } } } diff --git a/web/src/legacy-polyfills.ts b/web/src/legacy-polyfills.ts new file mode 100644 index 00000000..16112303 --- /dev/null +++ b/web/src/legacy-polyfills.ts @@ -0,0 +1,78 @@ +/** + * Polyfills for older browsers (e.g. Chromium 83 on Debian 10) that lack a few + * ES2021+ runtime methods used by bundled dependencies. esbuild only transpiles + * syntax, not runtime APIs, so we patch the prototypes here before any other + * module runs. + * + * TypeScript's lib target is ES2020, so the methods we patch are referenced via + * string keys / loose casts to avoid compile-time type errors. + */ + +type AnyStringReplacer = (substring: string, ...args: unknown[]) => string + +const StringProto = String.prototype as unknown as Record +const ArrayProto = Array.prototype as unknown as Record +const ObjectCtor = Object as unknown as Record + +if (typeof StringProto.replaceAll !== 'function') { + Object.defineProperty(String.prototype, 'replaceAll', { + configurable: true, + writable: true, + value: function replaceAll( + this: string, + search: string | RegExp, + replacement: string | AnyStringReplacer, + ): string { + if (search instanceof RegExp) { + if (!search.flags.includes('g')) { + throw new TypeError('String.prototype.replaceAll called with a non-global RegExp argument') + } + return this.replace(search, replacement as string) + } + const needle = String(search) + if (needle === '') { + return this.replace(new RegExp('', 'g'), replacement as string) + } + const escaped = needle.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&') + return this.replace(new RegExp(escaped, 'g'), replacement as string) + }, + }) +} + +if (typeof ArrayProto.at !== 'function') { + Object.defineProperty(Array.prototype, 'at', { + configurable: true, + writable: true, + value: function at(this: unknown[], index: number) { + const len = this.length + const i = Math.trunc(index) || 0 + const resolved = i < 0 ? len + i : i + if (resolved < 0 || resolved >= len) return undefined + return this[resolved] + }, + }) +} + +if (typeof StringProto.at !== 'function') { + Object.defineProperty(String.prototype, 'at', { + configurable: true, + writable: true, + value: function at(this: string, index: number) { + const len = this.length + const i = Math.trunc(index) || 0 + const resolved = i < 0 ? len + i : i + if (resolved < 0 || resolved >= len) return undefined + return this.charAt(resolved) + }, + }) +} + +if (typeof ObjectCtor.hasOwn !== 'function') { + Object.defineProperty(Object, 'hasOwn', { + configurable: true, + writable: true, + value: function hasOwn(target: object, property: PropertyKey) { + return Object.prototype.hasOwnProperty.call(target, property) + }, + }) +} diff --git a/web/vite.config.ts b/web/vite.config.ts index aff7ad67..2fae8e82 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' +const LEGACY_BROWSER_TARGETS = ['chrome83', 'edge83', 'firefox78', 'safari14'] + export default defineConfig({ plugins: [react()], resolve: { @@ -9,6 +11,18 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + build: { + target: LEGACY_BROWSER_TARGETS, + cssTarget: LEGACY_BROWSER_TARGETS, + }, + esbuild: { + target: LEGACY_BROWSER_TARGETS, + }, + optimizeDeps: { + esbuildOptions: { + target: LEGACY_BROWSER_TARGETS, + }, + }, test: { exclude: ['**/node_modules/**', '**/e2e/**'], }, From 81f7e3943f76887dd85ef26c994db6f3e54805ec Mon Sep 17 00:00:00 2001 From: Cheney <970320820@qq.com> Date: Sat, 9 May 2026 13:55:57 +0800 Subject: [PATCH 4/4] fix(web): scope legacy browser target to production build only The top-level esbuild.target also applied to dev/test transforms, which broke vitest suites that use top-level await (Chromium 83 / ES2020 does not support it). Only build.target and optimizeDeps.esbuildOptions.target need the legacy target; remove the global esbuild.target override. --- web/vite.config.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/vite.config.ts b/web/vite.config.ts index 2fae8e82..631fd1c0 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -15,9 +15,6 @@ export default defineConfig({ target: LEGACY_BROWSER_TARGETS, cssTarget: LEGACY_BROWSER_TARGETS, }, - esbuild: { - target: LEGACY_BROWSER_TARGETS, - }, optimizeDeps: { esbuildOptions: { target: LEGACY_BROWSER_TARGETS,