diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceRequest.java index 7e645675..68a5d249 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceRequest.java @@ -13,5 +13,7 @@ public record NamespaceRequest( String displayName, @Size(max = 512, message = "{validation.namespace.description.size}") - String description + String description, + + Boolean allowMemberOverwrite ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java index 603a38da..4e0f5a4d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java @@ -14,6 +14,7 @@ public record NamespaceResponse( String description, NamespaceType type, String avatarUrl, + boolean allowMemberOverwrite, String createdBy, Instant createdAt, Instant updatedAt @@ -27,6 +28,7 @@ public record NamespaceResponse( namespace.getDescription(), namespace.getType(), namespace.getAvatarUrl(), + namespace.isAllowMemberOverwrite(), namespace.getCreatedBy(), namespace.getCreatedAt(), namespace.getUpdatedAt() 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 4bf8af91..bf74edc4 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 @@ -77,6 +77,7 @@ public class NamespacePortalCommandAppService { request.displayName(), request.description(), null, + request.allowMemberOverwrite(), userId ); return NamespaceResponse.from(updated); diff --git a/server/skillhub-app/src/main/resources/db/migration/V47__namespace_allow_member_overwrite.sql b/server/skillhub-app/src/main/resources/db/migration/V47__namespace_allow_member_overwrite.sql new file mode 100644 index 00000000..4ec776d9 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V47__namespace_allow_member_overwrite.sql @@ -0,0 +1,2 @@ +ALTER TABLE namespace + ADD COLUMN allow_member_overwrite BOOLEAN NOT NULL DEFAULT FALSE; 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 d6353a7f..d8ed1733 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 @@ -176,7 +176,7 @@ class NamespacePortalControllerTest { updated.setType(NamespaceType.TEAM); updated.setDescription("Updated description"); given(namespaceService.getNamespaceBySlug("team-a")).willReturn(existing); - given(namespaceService.updateNamespace(1L, "Team A+", "Updated description", null, "owner-1")) + given(namespaceService.updateNamespace(1L, "Team A+", "Updated description", null, null, "owner-1")) .willReturn(updated); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/api/v1/namespaces/team-a") diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java index 1f4c2ac8..fabd5c06 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java @@ -31,6 +31,9 @@ public class Namespace { @Column(name = "avatar_url", length = 512) private String avatarUrl; + @Column(name = "allow_member_overwrite", nullable = false) + private boolean allowMemberOverwrite = false; + @Column(name = "created_by") private String createdBy; @@ -71,6 +74,8 @@ public class Namespace { public void setType(NamespaceType type) { this.type = type; } public String getAvatarUrl() { return avatarUrl; } public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; } + public boolean isAllowMemberOverwrite() { return allowMemberOverwrite; } + public void setAllowMemberOverwrite(boolean allowMemberOverwrite) { this.allowMemberOverwrite = allowMemberOverwrite; } public String getCreatedBy() { return createdBy; } public Instant getCreatedAt() { return createdAt; } public Instant getUpdatedAt() { return updatedAt; } 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 e54379e0..ad9ebf58 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 @@ -65,7 +65,7 @@ public class NamespaceService { */ @Transactional public Namespace updateNamespace(Long namespaceId, String displayName, String description, String avatarUrl, - String operatorUserId) { + Boolean allowMemberOverwrite, String operatorUserId) { Namespace namespace = namespaceRepository.findById(namespaceId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); assertNotImmutable(namespace); @@ -81,6 +81,9 @@ public class NamespaceService { if (avatarUrl != null) { namespace.setAvatarUrl(avatarUrl); } + if (allowMemberOverwrite != null) { + namespace.setAllowMemberOverwrite(allowMemberOverwrite); + } return namespaceRepository.save(namespace); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 8313534f..4a3b4541 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.event.ReviewSubmittedEvent; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; +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; @@ -174,11 +175,9 @@ public class SkillPublishService { // 2. Check membership boolean isSuperAdmin = platformRoles.contains("SUPER_ADMIN"); - if (!isSuperAdmin) { - var member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId); - if (member.isEmpty()) { - errors.add("Publisher is not a member of namespace: " + namespaceSlug); - } + var member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId); + if (!isSuperAdmin && member.isEmpty()) { + errors.add("Publisher is not a member of namespace: " + namespaceSlug); } if (requiresSecurityScanner(visibility) && !securityScanService.isEnabled()) { errors.add("error.security.scanner.required"); @@ -234,6 +233,7 @@ public class SkillPublishService { // 6. Slug conflict, archived skill, and version-exists checks if (resolvedSlug != null && errors.isEmpty()) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), resolvedSlug); + boolean overwriteAllowed = namespace.isAllowMemberOverwrite() && member.isPresent(); for (Skill existing : existingSkills) { if (existing.getOwnerId().equals(publisherId)) { if (existing.getStatus() == SkillStatus.ARCHIVED) { @@ -249,7 +249,7 @@ public class SkillPublishService { boolean hasPublished = !skillVersionRepository .findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED) .isEmpty(); - if (hasPublished) { + if (hasPublished && !overwriteAllowed) { errors.add("Name conflict: slug \"" + resolvedSlug + "\" is already published by another user"); break; } @@ -349,10 +349,12 @@ public class SkillPublishService { boolean isSuperAdmin = platformRoles.contains("SUPER_ADMIN"); - // 2. Check publisher is member unless SUPER_ADMIN short-circuits permission checks - if (!isSuperAdmin && !bypassMembershipCheck) { - namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId) - .orElseThrow(() -> new DomainBadRequestException("error.skill.publish.publisher.notMember", namespaceSlug)); + // 2. Check publisher is member unless SUPER_ADMIN short-circuits permission checks. + // Membership is still resolved for super admin / bypass callers: it gates the + // allowMemberOverwrite relaxation below (see canOverwriteOtherOwners). + var membership = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId); + if (!isSuperAdmin && !bypassMembershipCheck && membership.isEmpty()) { + throw new DomainBadRequestException("error.skill.publish.publisher.notMember", namespaceSlug); } // 3. Validate package @@ -401,41 +403,56 @@ public class SkillPublishService { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug); // Check if any other owner's skill has published versions - // Only PUBLISHED status blocks same-name publishing (UPLOADED/PENDING_REVIEW allowed) + // Only PUBLISHED status blocks same-name publishing (UPLOADED/PENDING_REVIEW allowed). + // When the namespace opts into allowMemberOverwrite and the publisher is a member, the + // published skill owned by another user becomes the publish target instead: the new + // version attaches to it so the (namespace, slug) coordinate keeps its identity and + // original owner (SkillVersion.createdBy still records the actual publisher). + Skill overwriteTarget = null; for (Skill existing : existingSkills) { if (!existing.getOwnerId().equals(publisherId)) { boolean hasPublished = !skillVersionRepository .findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED) .isEmpty(); if (hasPublished) { - // Distinguish between PRIVATE and PUBLIC/NAMESPACE_ONLY conflicts - if (existing.getVisibility() == SkillVisibility.PRIVATE) { - throw new DomainBadRequestException("error.skill.publish.nameConflict.private", skillSlug); - } else { - throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug); + if (!canOverwriteOtherOwners(namespace, membership)) { + // Distinguish between PRIVATE and PUBLIC/NAMESPACE_ONLY conflicts + if (existing.getVisibility() == SkillVisibility.PRIVATE) { + throw new DomainBadRequestException("error.skill.publish.nameConflict.private", skillSlug); + } else { + throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug); + } + } + if (overwriteTarget == null) { + overwriteTarget = existing; } } } } // Find or create skill for current user - Skill skill = skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId) - .orElseGet(() -> { - Skill newSkill = new Skill(namespace.getId(), skillSlug, publisherId, visibility); - newSkill.setCreatedBy(publisherId); - try { - Skill savedSkill = skillRepository.save(newSkill); - // save() may defer the unique-constraint check until transaction commit. - // Flush here so this boundary can translate the coordinate race. - skillRepository.flush(); - return savedSkill; - } catch (DataIntegrityViolationException ex) { - // A concurrent publish for the same (namespace, slug, owner) coordinate inserted - // the skill first and won the unique-constraint race. Surface a deterministic - // business conflict instead of leaking the violation as an HTTP 500. - throw new DomainBadRequestException("error.skill.publish.concurrentConflict", skillSlug); - } - }); + Skill skill; + if (overwriteTarget != null) { + skill = overwriteTarget; + } else { + skill = skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId) + .orElseGet(() -> { + Skill newSkill = new Skill(namespace.getId(), skillSlug, publisherId, visibility); + newSkill.setCreatedBy(publisherId); + try { + Skill savedSkill = skillRepository.save(newSkill); + // save() may defer the unique-constraint check until transaction commit. + // Flush here so this boundary can translate the coordinate race. + skillRepository.flush(); + return savedSkill; + } catch (DataIntegrityViolationException ex) { + // A concurrent publish for the same (namespace, slug, owner) coordinate inserted + // the skill first and won the unique-constraint race. Surface a deterministic + // business conflict instead of leaking the violation as an HTTP 500. + throw new DomainBadRequestException("error.skill.publish.concurrentConflict", skillSlug); + } + }); + } if (skill.getStatus() == SkillStatus.ARCHIVED) { throw new DomainBadRequestException("error.skill.publish.archived", skillSlug); @@ -690,6 +707,16 @@ public class SkillPublishService { } } + /** + * Overwriting another owner's published (namespace, slug) coordinate is allowed only when + * the namespace opts in via allowMemberOverwrite and the publisher actually is a namespace + * member. With the setting off (the default) the owner-isolation behavior is unchanged for + * everyone, including OWNER/ADMIN and SUPER_ADMIN. + */ + private boolean canOverwriteOtherOwners(Namespace namespace, java.util.Optional membership) { + return namespace.isAllowMemberOverwrite() && membership.isPresent(); + } + private void assertCanManageLifecycle(Skill skill, String actorUserId, Map userNamespaceRoles) { 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 12c555f3..b61d037c 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 @@ -95,6 +95,7 @@ class NamespaceServiceTest { "New Name", "New Desc", "http://avatar.url", + null, operatorUserId ); @@ -102,12 +103,30 @@ class NamespaceServiceTest { verify(namespaceRepository).save(namespace); } + @Test + void updateNamespace_shouldUpdateAllowMemberOverwrite() { + Long namespaceId = 1L; + String operatorUserId = "user-1"; + Namespace namespace = new Namespace("slug", "Old Name", "user-1"); + when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) + .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canMutateSettings(namespace)).thenReturn(true); + when(namespaceRepository.save(any(Namespace.class))).thenReturn(namespace); + + namespaceService.updateNamespace(namespaceId, null, null, null, true, operatorUserId); + + assertTrue(namespace.isAllowMemberOverwrite()); + verify(namespaceRepository).save(namespace); + } + @Test void updateNamespace_shouldThrowExceptionWhenNotFound() { when(namespaceRepository.findById(1L)).thenReturn(Optional.empty()); assertThrows(DomainBadRequestException.class, () -> - namespaceService.updateNamespace(1L, "Name", "Desc", null, "user-1")); + namespaceService.updateNamespace(1L, "Name", "Desc", null, null, "user-1")); } @Test @@ -120,7 +139,7 @@ class NamespaceServiceTest { .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.MEMBER))); assertThrows(DomainForbiddenException.class, () -> - namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, null, operatorUserId)); } @Test @@ -136,7 +155,7 @@ class NamespaceServiceTest { when(namespaceAccessPolicy.canMutateSettings(namespace)).thenReturn(false); assertThrows(DomainBadRequestException.class, () -> - namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, null, operatorUserId)); } @Test @@ -149,7 +168,7 @@ class NamespaceServiceTest { when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); assertThrows(DomainBadRequestException.class, () -> - namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, null, operatorUserId)); } @Test @@ -162,7 +181,7 @@ class NamespaceServiceTest { when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> - namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, null, operatorUserId)); assertEquals("error.namespace.system.immutable", exception.messageCode()); verify(namespaceMemberRepository, never()).findByNamespaceIdAndUserId(namespaceId, operatorUserId); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index c0fda2b6..f50e45df 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -1395,6 +1395,171 @@ class SkillPublishServiceTest { assertEquals("test-skill", result.slug()); } + @Test + void testPublishFromEntries_ShouldOverwriteOtherOwnersPublishedSkillWhenNamespaceAllows() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-200"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + namespace.setAllowMemberOverwrite(true); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + // Existing skill owned by another user with a published version + Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC); + setId(existingSkill, 1L); + SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100"); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty()); + + List savedVersions = new java.util.ArrayList<>(); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) setId(saved, 10L); + savedVersions.add(saved); + return saved; + }); + List savedSkills = new java.util.ArrayList<>(); + when(skillRepository.save(any(Skill.class))).thenAnswer(invocation -> { + savedSkills.add(invocation.getArgument(0)); + return invocation.getArgument(0); + }); + + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of() + ); + + // Overwrite lands on the original owner's skill record: ownership preserved, + // actual publisher recorded on the version. + assertNotNull(result); + assertEquals("test-skill", result.slug()); + assertEquals(1, savedVersions.size()); + assertEquals(1L, savedVersions.get(0).getSkillId()); + assertEquals(publisherId, savedVersions.get(0).getCreatedBy()); + assertEquals(1, savedSkills.size()); + assertEquals("user-100", savedSkills.get(0).getOwnerId()); + assertEquals(publisherId, savedSkills.get(0).getUpdatedBy()); + } + + @Test + void testPublishFromEntries_ShouldOverwritePrivateSkillWhenNamespaceAllows() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-200"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + namespace.setAllowMemberOverwrite(true); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PRIVATE); + setId(existingSkill, 1L); + SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100"); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) setId(saved, 10L); + return saved; + }); + when(skillRepository.save(any(Skill.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, entries, publisherId, SkillVisibility.PRIVATE, Set.of() + ); + + assertNotNull(result); + assertEquals("test-skill", result.slug()); + } + + @Test + void testValidateOnly_ShouldNotReportConflictWhenNamespaceAllows() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-200"; + List entries = skillEntries("test-skill", "1.0.0"); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + namespace.setAllowMemberOverwrite(true); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PRIVATE); + setId(existingSkill, 1L); + SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100"); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion)); + + SkillPublishService.DryRunResult result = service.validateOnly( + namespaceSlug, entries, publisherId, SkillVisibility.PRIVATE, Set.of() + ); + + assertTrue(result.valid()); + assertTrue(result.errors().isEmpty()); + } + + @Test + void testValidateOnly_ShouldReportConflictWhenNamespaceDisallows() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-200"; + List entries = skillEntries("test-skill", "1.0.0"); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC); + setId(existingSkill, 1L); + SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100"); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion)); + + SkillPublishService.DryRunResult result = service.validateOnly( + namespaceSlug, entries, publisherId, SkillVisibility.PRIVATE, Set.of() + ); + + assertFalse(result.valid()); + assertTrue(result.errors().stream().anyMatch(e -> e.contains("Name conflict"))); + } + @Test void testPublishFromEntries_ShouldAutoWithdrawPendingVersions() throws Exception { String namespaceSlug = "test-ns"; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c503257f..d5020a76 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -763,14 +763,17 @@ export const namespaceApi = { }) }, - async update(slug: string, request: { displayName?: string; description?: string }): Promise { - const body: Record = {} + async update(slug: string, request: { displayName?: string; description?: string; allowMemberOverwrite?: boolean }): Promise { + const body: Record = {} if (request.displayName !== undefined) { body.displayName = request.displayName.trim() } if (request.description !== undefined) { body.description = request.description === '' ? '' : request.description.trim() } + if (request.allowMemberOverwrite !== undefined) { + body.allowMemberOverwrite = request.allowMemberOverwrite + } return fetchJson(`/api/v1/namespaces/${normalizeNamespaceSlug(slug)}`, { method: 'PUT', headers: await ensureCsrfHeaders({ diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 03786be7..5a444be7 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3754,6 +3754,7 @@ export interface components { slug: string; displayName: string; description?: string; + allowMemberOverwrite?: boolean; }; ApiResponseNamespaceResponse: { /** Format: int32 */ @@ -3775,6 +3776,7 @@ export interface components { /** @enum {string} */ type?: "GLOBAL" | "TEAM"; avatarUrl?: string; + allowMemberOverwrite?: boolean; createdBy?: string; /** Format: date-time */ createdAt?: string; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 4db8e8db..a590e50c 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -108,6 +108,7 @@ export interface Namespace { description?: string type: 'GLOBAL' | 'TEAM' avatarUrl?: string + allowMemberOverwrite: boolean status: NamespaceStatus createdAt: string updatedAt?: string diff --git a/web/src/features/namespace/edit-namespace-dialog.tsx b/web/src/features/namespace/edit-namespace-dialog.tsx index 66949fcc..eea2e0fc 100644 --- a/web/src/features/namespace/edit-namespace-dialog.tsx +++ b/web/src/features/namespace/edit-namespace-dialog.tsx @@ -28,11 +28,13 @@ export function EditNamespaceDialog({ namespace, children }: EditNamespaceDialog const [open, setOpen] = useState(false) const [displayName, setDisplayName] = useState(namespace.displayName) const [description, setDescription] = useState(namespace.description ?? '') + const [allowMemberOverwrite, setAllowMemberOverwrite] = useState(namespace.allowMemberOverwrite) const [displayNameError, setDisplayNameError] = useState(null) const resetDialog = () => { setDisplayName(namespace.displayName) setDescription(namespace.description ?? '') + setAllowMemberOverwrite(namespace.allowMemberOverwrite) setDisplayNameError(null) updateMutation.reset() } @@ -56,6 +58,7 @@ export function EditNamespaceDialog({ namespace, children }: EditNamespaceDialog slug: namespace.slug, displayName: trimmedDisplayName, description: description.trim(), + allowMemberOverwrite, }) toast.success(t('namespaceEdit.saveSuccess')) setOpen(false) @@ -97,6 +100,24 @@ export function EditNamespaceDialog({ namespace, children }: EditNamespaceDialog rows={3} /> + +
+ setAllowMemberOverwrite(event.target.checked)} + className="mt-1.5 h-4 w-4 shrink-0 accent-primary" + /> +
+ +

+ {t('namespaceEdit.allowMemberOverwriteHint')} +

+
+
{updateMutation.error ? ( diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index fe95fbd1..65bf8a25 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1236,6 +1236,8 @@ "displayNameLabel": "Display Name", "displayNameRequired": "Display name is required", "descriptionLabel": "Description", + "allowMemberOverwriteLabel": "Allow member overwrite", + "allowMemberOverwriteHint": "When enabled, any namespace member may publish new versions to a skill slug already published by another member. When disabled (default), published slugs are locked to their original owner.", "saveAction": "Save", "saving": "Saving...", "saveSuccess": "Namespace updated successfully", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index a3d13fe4..d464a581 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1301,6 +1301,8 @@ "displayNameLabel": "Отображаемое имя", "displayNameRequired": "Требуется отображаемое имя", "descriptionLabel": "Описание", + "allowMemberOverwriteLabel": "Разрешить перезапись участниками", + "allowMemberOverwriteHint": "Если включено, любой участник пространства может публиковать новые версии skill с slug, уже опубликованным другим участником (версия сохраняется за исходным владельцем). По умолчанию выключено: опубликованный slug принадлежит первому опубликовавшему.", "saveAction": "Сохранить", "saving": "Сохранение...", "saveSuccess": "Пространство имён успешно обновлено", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 30de1e66..178625dd 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1236,6 +1236,8 @@ "displayNameLabel": "显示名称", "displayNameRequired": "显示名称不能为空", "descriptionLabel": "描述", + "allowMemberOverwriteLabel": "允许成员覆盖发布", + "allowMemberOverwriteHint": "开启后,空间内任意成员可向其他成员已发布的同名 skill 发布新版本(版本挂在原发布者名下)。默认关闭:已发布的 slug 归首次发布者所有。", "saveAction": "保存", "saving": "保存中...", "saveSuccess": "命名空间已更新", diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index be2d7230..ef89788c 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -193,8 +193,8 @@ export function useUpdateNamespace() { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ slug, displayName, description }: { slug: string; displayName?: string; description?: string }) => - namespaceApi.update(slug, { displayName, description }), + mutationFn: ({ slug, displayName, description, allowMemberOverwrite }: { slug: string; displayName?: string; description?: string; allowMemberOverwrite?: boolean }) => + namespaceApi.update(slug, { displayName, description, allowMemberOverwrite }), onSuccess: (namespace) => { queryClient.invalidateQueries({ queryKey: ['namespaces', namespace.slug] }) queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] })