mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-08 22:21:09 +00:00
feat(namespace): allow member overwrite of published skills via namespace setting
Add per-namespace allowMemberOverwrite (default false). When off, publish owner-isolation behaves exactly as before. When on, any namespace member may publish a new version to a (namespace, slug) coordinate already published by another member; the version attaches to the original owner's Skill record (Skill.ownerId unchanged, SkillVersion.createdBy records the actual publisher). validateOnly dry-run applies the same rule. Setting is mutable via the existing namespace update API (OWNER/ADMIN only) and exposed in the web namespace edit dialog.
This commit is contained in:
parent
08723fd01a
commit
ab7e02bda0
18 changed files with 304 additions and 45 deletions
|
|
@ -13,5 +13,7 @@ public record NamespaceRequest(
|
|||
String displayName,
|
||||
|
||||
@Size(max = 512, message = "{validation.namespace.description.size}")
|
||||
String description
|
||||
String description,
|
||||
|
||||
Boolean allowMemberOverwrite
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ public class NamespacePortalCommandAppService {
|
|||
request.displayName(),
|
||||
request.description(),
|
||||
null,
|
||||
request.allowMemberOverwrite(),
|
||||
userId
|
||||
);
|
||||
return NamespaceResponse.from(updated);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE namespace
|
||||
ADD COLUMN allow_member_overwrite BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Skill> 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<Skill> 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<NamespaceMember> membership) {
|
||||
return namespace.isAllowMemberOverwrite() && membership.isPresent();
|
||||
}
|
||||
|
||||
private void assertCanManageLifecycle(Skill skill,
|
||||
String actorUserId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<PackageEntry> 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<SkillVersion> 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<Skill> 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<PackageEntry> 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<PackageEntry> 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<PackageEntry> 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";
|
||||
|
|
|
|||
|
|
@ -763,14 +763,17 @@ export const namespaceApi = {
|
|||
})
|
||||
},
|
||||
|
||||
async update(slug: string, request: { displayName?: string; description?: string }): Promise<Namespace> {
|
||||
const body: Record<string, string> = {}
|
||||
async update(slug: string, request: { displayName?: string; description?: string; allowMemberOverwrite?: boolean }): Promise<Namespace> {
|
||||
const body: Record<string, string | boolean> = {}
|
||||
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<Namespace>(`/api/v1/namespaces/${normalizeNamespaceSlug(slug)}`, {
|
||||
method: 'PUT',
|
||||
headers: await ensureCsrfHeaders({
|
||||
|
|
|
|||
2
web/src/api/generated/schema.d.ts
vendored
2
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export interface Namespace {
|
|||
description?: string
|
||||
type: 'GLOBAL' | 'TEAM'
|
||||
avatarUrl?: string
|
||||
allowMemberOverwrite: boolean
|
||||
status: NamespaceStatus
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<input
|
||||
id="edit-allow-member-overwrite"
|
||||
type="checkbox"
|
||||
checked={allowMemberOverwrite}
|
||||
onChange={(event) => setAllowMemberOverwrite(event.target.checked)}
|
||||
className="mt-1.5 h-4 w-4 shrink-0 accent-primary"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-allow-member-overwrite">
|
||||
{t('namespaceEdit.allowMemberOverwriteLabel')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('namespaceEdit.allowMemberOverwriteHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{updateMutation.error ? (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1301,6 +1301,8 @@
|
|||
"displayNameLabel": "Отображаемое имя",
|
||||
"displayNameRequired": "Требуется отображаемое имя",
|
||||
"descriptionLabel": "Описание",
|
||||
"allowMemberOverwriteLabel": "Разрешить перезапись участниками",
|
||||
"allowMemberOverwriteHint": "Если включено, любой участник пространства может публиковать новые версии skill с slug, уже опубликованным другим участником (версия сохраняется за исходным владельцем). По умолчанию выключено: опубликованный slug принадлежит первому опубликовавшему.",
|
||||
"saveAction": "Сохранить",
|
||||
"saving": "Сохранение...",
|
||||
"saveSuccess": "Пространство имён успешно обновлено",
|
||||
|
|
|
|||
|
|
@ -1236,6 +1236,8 @@
|
|||
"displayNameLabel": "显示名称",
|
||||
"displayNameRequired": "显示名称不能为空",
|
||||
"descriptionLabel": "描述",
|
||||
"allowMemberOverwriteLabel": "允许成员覆盖发布",
|
||||
"allowMemberOverwriteHint": "开启后,空间内任意成员可向其他成员已发布的同名 skill 发布新版本(版本挂在原发布者名下)。默认关闭:已发布的 slug 归首次发布者所有。",
|
||||
"saveAction": "保存",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "命名空间已更新",
|
||||
|
|
|
|||
|
|
@ -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'] })
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue