fix(compat): support namespace publish fallback for clawhub

This commit is contained in:
dongmucat 2026-04-07 11:20:04 +08:00
parent 8cb783406d
commit 4837cddd63
4 changed files with 210 additions and 7 deletions

View file

@ -267,7 +267,7 @@ public class ClawHubCompatAppService {
String clientIp,
String userAgent) throws IOException {
MultipartPackageExtractor.ExtractedPackage extracted = multipartPackageExtractor.extract(files, payloadJson);
String namespace = determineNamespace(principal, extracted.payload());
String namespace = determineNamespace(extracted.payload());
SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
namespace,
extracted.entries(),
@ -285,15 +285,16 @@ public class ClawHubCompatAppService {
PlatformPrincipal principal,
String clientIp,
String userAgent) throws IOException {
String targetNamespace = normalizeNamespace(namespace);
SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
namespace,
targetNamespace,
zipPackageExtractor.extract(file),
principal.userId(),
SkillVisibility.PUBLIC,
principal.platformRoles()
);
recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent,
"{\"namespace\":\"" + namespace + "\"}");
"{\"namespace\":\"" + targetNamespace + "\"}");
return new ClawHubPublishResponse(result.skillId().toString(), result.version().getId().toString());
}
@ -367,8 +368,16 @@ public class ClawHubCompatAppService {
);
}
private String determineNamespace(PlatformPrincipal principal, MultipartPackageExtractor.PublishPayload payload) {
return "global";
private String determineNamespace(MultipartPackageExtractor.PublishPayload payload) {
return normalizeNamespace(payload.namespace());
}
private String normalizeNamespace(String namespace) {
if (namespace == null) {
return "global";
}
String trimmed = namespace.trim();
return trimmed.isEmpty() ? "global" : trimmed;
}
private void recordCompatPublishAudit(String userId,

View file

@ -149,7 +149,7 @@ public class ClawHubCompatController {
@RateLimit(category = "publish", authenticated = 60, anonymous = 20)
@PostMapping("/publish")
public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file,
@RequestParam("namespace") String namespace,
@RequestParam(value = "namespace", required = false) String namespace,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request) throws IOException {
return clawHubCompatAppService.publish(

View file

@ -36,7 +36,8 @@ public class MultipartPackageExtractor {
String changelog,
Boolean acceptLicenseTerms,
List<String> tags,
ForkOf forkOf
ForkOf forkOf,
String namespace
) {
public record ForkOf(String slug, String version) {}
}

View file

@ -0,0 +1,193 @@
package com.iflytek.skillhub.compat;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.support.MultipartPackageExtractor;
import com.iflytek.skillhub.controller.support.ZipPackageExtractor;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
@ExtendWith(MockitoExtension.class)
class ClawHubCompatAppServiceTest {
@Mock
private CanonicalSlugMapper mapper;
@Mock
private SkillSearchAppService skillSearchAppService;
@Mock
private SkillQueryService skillQueryService;
@Mock
private SkillPublishService skillPublishService;
@Mock
private ZipPackageExtractor zipPackageExtractor;
@Mock
private MultipartPackageExtractor multipartPackageExtractor;
@Mock
private AuditLogService auditLogService;
@Mock
private CompatSkillLookupService compatSkillLookupService;
@Mock
private SkillStarService skillStarService;
private ClawHubCompatAppService appService;
@BeforeEach
void setUp() {
appService = new ClawHubCompatAppService(
mapper,
skillSearchAppService,
skillQueryService,
skillPublishService,
zipPackageExtractor,
multipartPackageExtractor,
auditLogService,
compatSkillLookupService,
skillStarService
);
}
@Test
void publishSkill_uses_namespace_from_payload_when_specified() throws IOException {
MultipartFile[] files = new MultipartFile[] {
new MockMultipartFile("files", "SKILL.md", "text/markdown", "# demo".getBytes())
};
MultipartPackageExtractor.PublishPayload payload = new MultipartPackageExtractor.PublishPayload(
"demo-skill",
"Demo Skill",
"1.0.0",
null,
true,
List.of("ai"),
null,
"team-ai"
);
when(multipartPackageExtractor.extract(any(MultipartFile[].class), any(String.class)))
.thenReturn(new MultipartPackageExtractor.ExtractedPackage(payload, List.of()));
when(skillPublishService.publishFromEntries(eq("team-ai"), anyList(), eq("user-1"), eq(SkillVisibility.PUBLIC), anySet()))
.thenReturn(publishResult(100L, 200L));
var response = appService.publishSkill("{}", files, principal(), "127.0.0.1", "JUnit");
assertThat(response.ok()).isTrue();
assertThat(response.skillId()).isEqualTo("100");
assertThat(response.versionId()).isEqualTo("200");
verify(skillPublishService).publishFromEntries(eq("team-ai"), anyList(), eq("user-1"), eq(SkillVisibility.PUBLIC), anySet());
}
@Test
void publishSkill_falls_back_to_global_when_payload_namespace_missing() throws IOException {
MultipartFile[] files = new MultipartFile[] {
new MockMultipartFile("files", "SKILL.md", "text/markdown", "# demo".getBytes())
};
MultipartPackageExtractor.PublishPayload payload = new MultipartPackageExtractor.PublishPayload(
"demo-skill",
"Demo Skill",
"1.0.0",
null,
true,
List.of("ai"),
null,
null
);
when(multipartPackageExtractor.extract(any(MultipartFile[].class), any(String.class)))
.thenReturn(new MultipartPackageExtractor.ExtractedPackage(payload, List.of()));
when(skillPublishService.publishFromEntries(eq("global"), anyList(), eq("user-1"), eq(SkillVisibility.PUBLIC), anySet()))
.thenReturn(publishResult(101L, 201L));
var response = appService.publishSkill("{}", files, principal(), "127.0.0.1", "JUnit");
assertThat(response.ok()).isTrue();
assertThat(response.skillId()).isEqualTo("101");
assertThat(response.versionId()).isEqualTo("201");
verify(skillPublishService).publishFromEntries(eq("global"), anyList(), eq("user-1"), eq(SkillVisibility.PUBLIC), anySet());
}
@Test
void publish_falls_back_to_global_when_namespace_query_missing() throws IOException {
MultipartFile file = new MockMultipartFile("file", "demo.zip", "application/zip", "zip".getBytes());
List<PackageEntry> entries = List.of(new PackageEntry("SKILL.md", "# demo".getBytes(), 6L, "text/markdown"));
when(zipPackageExtractor.extract(file)).thenReturn(entries);
when(skillPublishService.publishFromEntries(eq("global"), eq(entries), eq("user-1"), eq(SkillVisibility.PUBLIC), anySet()))
.thenReturn(publishResult(102L, 202L));
var response = appService.publish(file, null, principal(), "127.0.0.1", "JUnit");
assertThat(response.ok()).isTrue();
assertThat(response.skillId()).isEqualTo("102");
assertThat(response.versionId()).isEqualTo("202");
verify(skillPublishService).publishFromEntries(eq("global"), eq(entries), eq("user-1"), eq(SkillVisibility.PUBLIC), anySet());
}
@Test
void publish_uses_namespace_query_when_specified() throws IOException {
MultipartFile file = new MockMultipartFile("file", "demo.zip", "application/zip", "zip".getBytes());
List<PackageEntry> entries = List.of(new PackageEntry("SKILL.md", "# demo".getBytes(), 6L, "text/markdown"));
when(zipPackageExtractor.extract(file)).thenReturn(entries);
when(skillPublishService.publishFromEntries(eq("team-ai"), eq(entries), eq("user-1"), eq(SkillVisibility.PUBLIC), anySet()))
.thenReturn(publishResult(103L, 203L));
var response = appService.publish(file, "team-ai", principal(), "127.0.0.1", "JUnit");
assertThat(response.ok()).isTrue();
assertThat(response.skillId()).isEqualTo("103");
assertThat(response.versionId()).isEqualTo("203");
verify(skillPublishService).publishFromEntries(eq("team-ai"), eq(entries), eq("user-1"), eq(SkillVisibility.PUBLIC), anySet());
}
@Test
void publish_falls_back_to_global_when_namespace_query_blank() throws IOException {
MultipartFile file = new MockMultipartFile("file", "demo.zip", "application/zip", "zip".getBytes());
List<PackageEntry> entries = List.of(new PackageEntry("SKILL.md", "# demo".getBytes(), 6L, "text/markdown"));
when(zipPackageExtractor.extract(file)).thenReturn(entries);
when(skillPublishService.publishFromEntries(eq("global"), eq(entries), eq("user-1"), eq(SkillVisibility.PUBLIC), anySet()))
.thenReturn(publishResult(104L, 204L));
var response = appService.publish(file, " ", principal(), "127.0.0.1", "JUnit");
assertThat(response.ok()).isTrue();
assertThat(response.skillId()).isEqualTo("104");
assertThat(response.versionId()).isEqualTo("204");
verify(skillPublishService).publishFromEntries(eq("global"), eq(entries), eq("user-1"), eq(SkillVisibility.PUBLIC), anySet());
}
private PlatformPrincipal principal() {
return new PlatformPrincipal(
"user-1",
"tester",
"tester@example.com",
null,
"github",
Set.of("SUPER_ADMIN")
);
}
private SkillPublishService.PublishResult publishResult(Long skillId, Long versionId) {
SkillVersion version = new SkillVersion(skillId, "1.0.0", "user-1");
ReflectionTestUtils.setField(version, "id", versionId);
return new SkillPublishService.PublishResult(skillId, "demo-skill", version);
}
}