fix(authoring): prevent namespace deletion while drafts remain

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-14 19:46:41 +08:00
parent 642e780f1e
commit d8dbd4f0c9
8 changed files with 60 additions and 4 deletions

View file

@ -93,7 +93,7 @@ error.namespace.global.members.platformAdmin.required=Only platform user adminis
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.delete.hasDependencies=Namespace cannot be deleted while it still contains skills, drafts 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

View file

@ -90,7 +90,7 @@ error.namespace.global.members.platformAdmin.required=Только админи
error.namespace.admin.required=Требуется роль владельца или администратора пространства имён
error.namespace.owner.required=Требуется роль владельца пространства имён
error.namespace.create.platformAdminRequired=Создавать пространства имён могут только SKILL_ADMIN или SUPER_ADMIN
error.namespace.delete.hasDependencies=Пространство имён нельзя удалить, пока в нём есть скиллы или записи управления
error.namespace.delete.hasDependencies=Пространство имён нельзя удалить, пока в нём есть скиллы, черновики или записи управления
error.namespace.member.owner.assignDirect=Нельзя назначить роль OWNER напрямую
error.namespace.member.alreadyExists=Пользователь уже является участником пространства имён
error.namespace.member.notFound=Участник не найден

View file

@ -93,7 +93,7 @@ error.namespace.global.members.platformAdmin.required=只有平台用户管理
error.namespace.admin.required=需要命名空间管理员或所有者权限
error.namespace.owner.required=需要命名空间所有者权限
error.namespace.create.platformAdminRequired=只有 SKILL_ADMIN 或 SUPER_ADMIN 可以创建命名空间
error.namespace.delete.hasDependencies=命名空间下仍有技能或治理记录,暂时不能删除
error.namespace.delete.hasDependencies=命名空间下仍有技能、草稿或治理记录,暂时不能删除
error.namespace.member.owner.assignDirect=不能直接分配 OWNER 角色
error.namespace.member.alreadyExists=用户已经是该命名空间成员
error.namespace.member.notFound=未找到命名空间成员

View file

@ -18,6 +18,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.service.authoring.ValidationRunOrchestrator;
@ -93,6 +95,7 @@ class AuthoringDraftFlowIntegrationTest {
@Autowired private ValidationRunService runService;
@Autowired private ValidationRunOrchestrator orchestrator;
@Autowired private NamespaceRepository namespaceRepository;
@Autowired private NamespaceService namespaceService;
@Autowired private NamespaceMemberRepository namespaceMemberRepository;
@Autowired private UserAccountRepository userAccountRepository;
@ -377,6 +380,26 @@ class AuthoringDraftFlowIntegrationTest {
}
}
@Test
void namespaceWithOnlyDraftRequiresDraftRemovalBeforeDeletion() {
Namespace namespace = namespaceService.createNamespace("draft-delete-team", "Draft Delete Team", null, USER);
SkillDraft draft = draftService.createDraft(namespace.getSlug(), USER, "namespace-delete-draft", null, null);
assertThat(namespaceService.canDelete(namespace, NamespaceRole.OWNER)).isFalse();
DomainBadRequestException conflict = assertThrows(DomainBadRequestException.class,
() -> namespaceService.deleteNamespace(namespace.getId(), USER));
assertThat(conflict.messageCode()).isEqualTo("error.namespace.delete.hasDependencies");
assertThat(namespaceRepository.findById(namespace.getId())).isPresent();
assertThat(draftService.readFile(draft.getId(), USER, "SKILL.md", null).asText())
.contains("name: namespace-delete-draft");
draftService.deleteDraft(draft.getId(), USER, null);
assertThat(namespaceService.canDelete(namespace, NamespaceRole.OWNER)).isTrue();
namespaceService.deleteNamespace(namespace.getId(), USER);
assertThat(namespaceRepository.findById(namespace.getId())).isEmpty();
assertThat(namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), USER)).isEmpty();
}
private <T> List<T> concurrently(int workers, Callable<T> action) throws Exception {
CountDownLatch ready = new CountDownLatch(workers);
CountDownLatch start = new CountDownLatch(1);

View file

@ -18,6 +18,8 @@ public interface SkillDraftRepository {
List<SkillDraft> findByNamespaceId(Long namespaceId);
boolean existsByNamespaceId(Long namespaceId);
/** Case-insensitive uniqueness of (owner, name) used at creation time. */
Optional<SkillDraft> findByOwnerIdAndNameIgnoreCase(String ownerId, String name);

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.authoring.SkillDraftRepository;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
@ -20,6 +21,7 @@ public class NamespaceService {
private final NamespaceMemberRepository namespaceMemberRepository;
private final NamespaceAccessPolicy namespaceAccessPolicy;
private final SkillRepository skillRepository;
private final SkillDraftRepository skillDraftRepository;
private final ReviewTaskRepository reviewTaskRepository;
private final PromotionRequestRepository promotionRequestRepository;
@ -27,12 +29,14 @@ public class NamespaceService {
NamespaceMemberRepository namespaceMemberRepository,
NamespaceAccessPolicy namespaceAccessPolicy,
SkillRepository skillRepository,
SkillDraftRepository skillDraftRepository,
ReviewTaskRepository reviewTaskRepository,
PromotionRequestRepository promotionRequestRepository) {
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.namespaceAccessPolicy = namespaceAccessPolicy;
this.skillRepository = skillRepository;
this.skillDraftRepository = skillDraftRepository;
this.reviewTaskRepository = reviewTaskRepository;
this.promotionRequestRepository = promotionRequestRepository;
}
@ -181,7 +185,8 @@ public class NamespaceService {
private boolean hasDependentData(Long namespaceId) {
return skillRepository.existsByNamespaceId(namespaceId)
|| reviewTaskRepository.existsByNamespaceId(namespaceId)
|| promotionRequestRepository.existsByTargetNamespaceId(namespaceId);
|| promotionRequestRepository.existsByTargetNamespaceId(namespaceId)
|| skillDraftRepository.existsByNamespaceId(namespaceId);
}
private void assertWritable(Namespace namespace) {

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.authoring.SkillDraftRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
@ -33,6 +34,9 @@ class NamespaceServiceTest {
@Mock
private SkillRepository skillRepository;
@Mock
private SkillDraftRepository skillDraftRepository;
@Mock
private ReviewTaskRepository reviewTaskRepository;
@ -288,6 +292,26 @@ class NamespaceServiceTest {
assertFalse(namespaceService.canDelete(namespace, NamespaceRole.OWNER));
}
@Test
void draftDependencyPreventsDeleteAndDisablesDeleteCapability() {
Long namespaceId = 1L;
String owner = "owner-1";
Namespace namespace = new Namespace("draft-team", "Draft Team", owner);
setField(namespace, "id", namespaceId);
when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace));
when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, owner))
.thenReturn(Optional.of(new NamespaceMember(namespaceId, owner, NamespaceRole.OWNER)));
when(skillDraftRepository.existsByNamespaceId(namespaceId)).thenReturn(true);
assertFalse(namespaceService.canDelete(namespace, NamespaceRole.OWNER));
DomainBadRequestException exception = assertThrows(DomainBadRequestException.class,
() -> namespaceService.deleteNamespace(namespaceId, owner));
assertEquals("error.namespace.delete.hasDependencies", exception.messageCode());
verify(namespaceMemberRepository, never()).deleteByNamespaceId(namespaceId);
verify(namespaceRepository, never()).delete(any());
}
private void setField(Object target, String fieldName, Object value) {
try {
Field field = target.getClass().getDeclaredField(fieldName);

View file

@ -23,6 +23,8 @@ public interface SkillDraftJpaRepository extends JpaRepository<SkillDraft, Long>
List<SkillDraft> findByNamespaceId(Long namespaceId);
boolean existsByNamespaceId(Long namespaceId);
Optional<SkillDraft> findByOwnerIdAndNameIgnoreCase(String ownerId, String name);
boolean existsByIdAndOwnerId(Long id, String ownerId);