From 85c025a1b955668e808866c9786b74ed6e853693 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 4 Jun 2026 10:41:55 +0800 Subject: [PATCH 01/19] feat(skill): add namespace search and bundle download Signed-off-by: dongmucat <1127093059@qq.com> --- .../portal/NamespaceController.java | 30 ++++++ .../src/main/resources/messages.properties | 1 + .../src/main/resources/messages_zh.properties | 1 + .../portal/SkillControllerDownloadTest.java | 25 +++++ .../skill/service/SkillDownloadService.java | 93 ++++++++++++++++++- .../service/SkillDownloadServiceTest.java | 70 ++++++++++++++ web/src/app/router.tsx | 3 +- web/src/i18n/locales/en.json | 7 +- web/src/i18n/locales/zh.json | 7 +- web/src/pages/namespace.test.tsx | 50 +++++++++- web/src/pages/namespace.tsx | 73 ++++++++++++++- web/src/pages/search.test.tsx | 51 +++++++++- web/src/pages/search.tsx | 72 ++++++++------ web/src/shared/lib/search-query.test.ts | 25 ++++- web/src/shared/lib/search-query.ts | 29 ++++++ 15 files changed, 497 insertions(+), 40 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 69be5fa3..461b086d 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 @@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.BatchMemberRequest; @@ -18,6 +19,7 @@ import com.iflytek.skillhub.dto.NamespaceResponse; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.TransferOwnershipRequest; import com.iflytek.skillhub.dto.UpdateMemberRoleRequest; +import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.GovernanceWorkflowAppService; import com.iflytek.skillhub.service.NamespacePortalCommandAppService; @@ -25,7 +27,11 @@ import com.iflytek.skillhub.service.NamespacePortalQueryAppService; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; +import org.springframework.core.io.InputStreamResource; import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -44,17 +50,20 @@ public class NamespaceController extends BaseApiController { private final NamespacePortalCommandAppService namespacePortalCommandAppService; private final NamespaceMemberCandidateService namespaceMemberCandidateService; private final GovernanceWorkflowAppService governanceWorkflowAppService; + private final SkillDownloadService skillDownloadService; public NamespaceController(NamespacePortalQueryAppService namespacePortalQueryAppService, NamespacePortalCommandAppService namespacePortalCommandAppService, NamespaceMemberCandidateService namespaceMemberCandidateService, GovernanceWorkflowAppService governanceWorkflowAppService, + SkillDownloadService skillDownloadService, ApiResponseFactory responseFactory) { super(responseFactory); this.namespacePortalQueryAppService = namespacePortalQueryAppService; this.namespacePortalCommandAppService = namespacePortalCommandAppService; this.namespaceMemberCandidateService = namespaceMemberCandidateService; this.governanceWorkflowAppService = governanceWorkflowAppService; + this.skillDownloadService = skillDownloadService; } @GetMapping("/namespaces") @@ -169,6 +178,27 @@ public class NamespaceController extends BaseApiController { return ok("response.success.read", namespaceMemberCandidateService.searchCandidates(slug, search, userId, size)); } + @GetMapping("/namespaces/{slug}/skills/download") + @RateLimit(category = "download", authenticated = 30, anonymous = 10) + public ResponseEntity downloadNamespaceSkills( + @PathVariable String slug, + @RequestParam(name = "skill", required = false) List selectedSkills, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + + SkillDownloadService.DownloadResult result = skillDownloadService.downloadNamespaceBundle( + slug, + selectedSkills != null ? selectedSkills : List.of(), + userId, + userNsRoles != null ? userNsRoles : Map.of()); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") + .contentType(MediaType.parseMediaType(result.contentType())) + .contentLength(result.contentLength()) + .body(new InputStreamResource(result.openContent())); + } + @PostMapping("/namespaces/{slug}/members") public ApiResponse addMember( @PathVariable String slug, diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index be3e2ebe..32a072e6 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -143,6 +143,7 @@ error.skill.version.submit.notUploaded=Version ''{0}'' is not in UPLOADED status error.skill.version.confirm.notUploaded=Version ''{0}'' is not in UPLOADED status and cannot be confirmed error.skill.confirm.notPrivate=Only PRIVATE skills can use confirm-publish error.skill.version.notDownloadable=Version ''{0}'' is not available for download +error.namespace.skills.download.empty=No downloadable skills found in namespace ''{0}'' # Profile update error.profile.displayName.length=Display name must be between 2 and 32 characters diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index cef09563..057c2f03 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -143,6 +143,7 @@ error.skill.version.submit.notUploaded=版本"{0}"不在 UPLOADED 状态,无 error.skill.version.confirm.notUploaded=版本"{0}"不在 UPLOADED 状态,无法确认发布 error.skill.confirm.notPrivate=只有 PRIVATE 技能可以使用确认发布功能 error.skill.version.notDownloadable=版本"{0}"不可下载 +error.namespace.skills.download.empty=命名空间“{0}”下没有可下载的技能 # 用户资料修改 error.profile.displayName.length=昵称长度需在 2-32 个字符之间 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java index 8945d2a9..4804537c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimiter; import java.io.ByteArrayInputStream; +import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -199,4 +200,28 @@ class SkillControllerDownloadTest { 120, 60); } + + @Test + void downloadNamespaceBundle_streamsSelectedNamespaceSkills() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); + given(skillDownloadService.downloadNamespaceBundle("team-ai", List.of("alpha"), "test-user", java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + () -> new ByteArrayInputStream("zip".getBytes()), + "team-ai-skills.zip", + 3L, + "application/zip", + null, + false + )); + + mockMvc.perform(get("/api/web/namespaces/team-ai/skills/download") + .param("skill", "alpha") + .with(user("test-user")) + .requestAttr("userId", "test-user") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"team-ai-skills.zip\"")); + + verify(skillDownloadService).downloadNamespaceBundle("team-ai", List.of("alpha"), "test-user", java.util.Map.of()); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 3bb194ff..354f7e05 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -20,8 +20,10 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.time.Duration; import java.util.Comparator; +import java.util.HashSet; import java.util.Map; import java.util.List; +import java.util.Set; import java.util.function.Supplier; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -162,6 +164,49 @@ public class SkillDownloadService { return buildDownloadResult(skill, version); } + /** + * Builds one namespace-level archive containing each selected skill as its + * own versioned zip bundle. + */ + public DownloadResult downloadNamespaceBundle( + String namespaceSlug, + List selectedSkillSlugs, + String currentUserId, + Map userNsRoles) { + + Namespace namespace = findNamespace(namespaceSlug); + Set selected = selectedSkillSlugs == null + ? Set.of() + : new HashSet<>(selectedSkillSlugs.stream() + .filter(slug -> slug != null && !slug.isBlank()) + .map(slug -> slug.trim().replaceFirst("^@", "")) + .toList()); + + List entries = skillRepository.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE) + .stream() + .filter(skill -> selected.isEmpty() || selected.contains(skill.getSlug())) + .sorted(Comparator.comparing(Skill::getSlug)) + .map(skill -> toNamespaceBundleEntry(namespace, skill, currentUserId, userNsRoles)) + .flatMap(java.util.Optional::stream) + .toList(); + + if (entries.isEmpty()) { + throw new DomainBadRequestException("error.namespace.skills.download.empty", namespaceSlug); + } + + byte[] bundle = createNamespaceBundle(namespace.getSlug(), entries); + entries.forEach(entry -> recordPublishedDownload(entry.skill(), entry.version())); + + return new DownloadResult( + () -> new ByteArrayInputStream(bundle), + sanitizeFilename(namespace.getSlug()) + "-skills.zip", + bundle.length, + "application/zip", + null, + false + ); + } + private DownloadResult downloadVersion(Skill skill, SkillVersion version) { assertPublishedAccessible(skill); assertDownloadableVersion(skill, version); @@ -169,13 +214,55 @@ public class SkillDownloadService { // Only increment download count for PUBLISHED versions if (version.getStatus() == SkillVersionStatus.PUBLISHED) { - skillRepository.incrementDownloadCount(skill.getId()); - skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); - eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + recordPublishedDownload(skill, version); } return result; } + private java.util.Optional toNamespaceBundleEntry( + Namespace namespace, + Skill skill, + String currentUserId, + Map userNsRoles) { + assertCanDownload(namespace, skill, currentUserId, userNsRoles); + if (skill.getLatestVersionId() == null) { + return java.util.Optional.empty(); + } + SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.latest.notFound")); + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + return java.util.Optional.empty(); + } + return java.util.Optional.of(new NamespaceBundleEntry(skill, version, buildDownloadResult(skill, version))); + } + + private byte[] createNamespaceBundle(String namespaceSlug, List entries) { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { + for (NamespaceBundleEntry entry : entries) { + ZipEntry zipEntry = new ZipEntry(namespaceSlug + "/" + entry.skill().getSlug() + "-" + entry.version().getVersion() + ".zip"); + zipOutputStream.putNextEntry(zipEntry); + try (InputStream inputStream = entry.downloadResult().openContent()) { + inputStream.transferTo(zipOutputStream); + } + zipOutputStream.closeEntry(); + } + zipOutputStream.finish(); + return outputStream.toByteArray(); + } catch (Exception e) { + throw new IllegalStateException("Failed to build namespace skill bundle zip", e); + } + } + + private void recordPublishedDownload(Skill skill, SkillVersion version) { + skillRepository.incrementDownloadCount(skill.getId()); + skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); + eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + } + + private record NamespaceBundleEntry(Skill skill, SkillVersion version, DownloadResult downloadResult) { + } + private DownloadResult buildDownloadResult(Skill skill, SkillVersion version) { String storageKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index ba24003b..666df6fa 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -340,6 +340,76 @@ class SkillDownloadServiceTest { verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadNamespaceBundle_PackagesVisiblePublishedSkills() throws Exception { + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + setId(namespace, 2L); + namespace.setType(NamespaceType.TEAM); + + Skill alpha = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); + setId(alpha, 11L); + alpha.setDisplayName("Alpha Skill"); + alpha.setStatus(SkillStatus.ACTIVE); + alpha.setLatestVersionId(101L); + + Skill beta = new Skill(2L, "beta", "owner-1", SkillVisibility.PUBLIC); + setId(beta, 12L); + beta.setDisplayName("Beta Skill"); + beta.setStatus(SkillStatus.ACTIVE); + beta.setLatestVersionId(102L); + + SkillVersion alphaVersion = new SkillVersion(11L, "1.0.0", "owner-1"); + setId(alphaVersion, 101L); + alphaVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion betaVersion = new SkillVersion(12L, "2.0.0", "owner-1"); + setId(betaVersion, 102L); + betaVersion.setStatus(SkillVersionStatus.PUBLISHED); + + SkillFile alphaFile = new SkillFile(101L, "SKILL.md", 5L, "text/markdown", "hash-a", "skills/11/101/SKILL.md"); + SkillFile betaFile = new SkillFile(102L, "README.md", 4L, "text/markdown", "hash-b", "skills/12/102/README.md"); + + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(alpha, beta)); + when(visibilityChecker.canAccess(alpha, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(visibilityChecker.canAccess(beta, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(alphaVersion)); + when(skillVersionRepository.findById(102L)).thenReturn(Optional.of(betaVersion)); + when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(false); + when(objectStorageService.exists("packages/12/102/bundle.zip")).thenReturn(false); + when(skillFileRepository.findByVersionId(101L)).thenReturn(List.of(alphaFile)); + when(skillFileRepository.findByVersionId(102L)).thenReturn(List.of(betaFile)); + when(objectStorageService.exists("skills/11/101/SKILL.md")).thenReturn(true); + when(objectStorageService.exists("skills/12/102/README.md")).thenReturn(true); + when(objectStorageService.getObject("skills/11/101/SKILL.md")).thenReturn(new ByteArrayInputStream("alpha".getBytes())); + when(objectStorageService.getObject("skills/12/102/README.md")).thenReturn(new ByteArrayInputStream("beta".getBytes())); + + SkillDownloadService.DownloadResult result = service.downloadNamespaceBundle( + "team-ai", + List.of(), + "user-1", + Map.of(2L, NamespaceRole.MEMBER)); + + assertEquals("team-ai-skills.zip", result.filename()); + assertEquals("application/zip", result.contentType()); + assertNull(result.presignedUrl()); + assertTrue(result.contentLength() > 0); + + try (ZipInputStream zipInputStream = new ZipInputStream(result.openContent())) { + var firstEntry = zipInputStream.getNextEntry(); + assertNotNull(firstEntry); + assertEquals("team-ai/alpha-1.0.0.zip", firstEntry.getName()); + var secondEntry = zipInputStream.getNextEntry(); + assertNotNull(secondEntry); + assertEquals("team-ai/beta-2.0.0.zip", secondEntry.getName()); + } + + verify(skillRepository).incrementDownloadCount(11L); + verify(skillRepository).incrementDownloadCount(12L); + verify(skillVersionStatsRepository).incrementDownloadCount(101L, 11L); + verify(skillVersionStatsRepository).incrementDownloadCount(102L, 12L); + verify(eventPublisher, times(2)).publishEvent(any(SkillDownloadedEvent.class)); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 8cabf0b3..b925f1c1 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -199,9 +199,10 @@ const searchRoute = createRoute({ getParentRoute: () => rootRoute, path: 'search', component: SearchPage, - validateSearch: (search: Record): { q: string; label?: string; sort: string; page: number; starredOnly: boolean } => { + validateSearch: (search: Record): { q: string; namespace?: string; label?: string; sort: string; page: number; starredOnly: boolean } => { return { q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''), + namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace.replace(/^@/, '') : undefined, label: typeof search.label === 'string' && search.label ? search.label : undefined, sort: (search.sort as string) || 'newest', page: Number(search.page) || 0, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3ff964a4..d556f3c9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -189,6 +189,7 @@ "noStarredResults": "No starred skills found", "noStarredResultsFor": "No starred skills match \"{{q}}\"", "noStarredSkills": "You have not starred any skills yet", + "namespaceFilter": "@{{namespace}}", "enterKeyword": "Please enter a search keyword", "results": "{{count}} skills found", "resultCount": "Found <1>{{count}} results", @@ -765,7 +766,11 @@ "notFound": "Namespace not found", "skillList": "Skills", "emptyTitle": "No skills", - "emptyDescription": "No skills have been published in this namespace yet" + "emptyDescription": "No skills have been published in this namespace yet", + "downloadAll": "Download all", + "downloadSelected": "Download selected", + "copyInstallManifest": "Copy install list", + "selectSkill": "Select {{name}}" }, "skillDetail": { "back": "Back", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 85cdc73b..88daccf8 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -189,6 +189,7 @@ "noStarredResults": "未找到已收藏技能", "noStarredResultsFor": "已收藏技能中没有与 \"{{q}}\" 相关的结果", "noStarredSkills": "你还没有收藏任何技能", + "namespaceFilter": "@{{namespace}}", "enterKeyword": "请输入搜索关键词", "results": "找到 {{count}} 个技能", "resultCount": "找到 <1>{{count}} 个结果", @@ -765,7 +766,11 @@ "notFound": "命名空间不存在", "skillList": "技能列表", "emptyTitle": "暂无技能", - "emptyDescription": "该命名空间下还没有发布任何技能" + "emptyDescription": "该命名空间下还没有发布任何技能", + "downloadAll": "下载全部", + "downloadSelected": "下载选中", + "copyInstallManifest": "复制安装清单", + "selectSkill": "选择 {{name}}" }, "skillDetail": { "back": "返回上一页", diff --git a/web/src/pages/namespace.test.tsx b/web/src/pages/namespace.test.tsx index 8fcfd410..6a920a85 100644 --- a/web/src/pages/namespace.test.tsx +++ b/web/src/pages/namespace.test.tsx @@ -1,4 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const buttonRecords: Array<{ label: string }> = [] vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), @@ -23,6 +26,14 @@ vi.mock('@/features/skill/skill-card', () => ({ SkillCard: () => null, })) +vi.mock('@/shared/ui/button', () => ({ + Button: ({ children }: { children?: ReactNode }) => { + const label = Array.isArray(children) ? children.join('') : String(children ?? '') + buttonRecords.push({ label }) + return + }, +})) + vi.mock('@/shared/components/skeleton-loader', () => ({ SkeletonList: () => null, })) @@ -38,7 +49,26 @@ vi.mock('@/shared/hooks/use-namespace-queries', () => ({ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useSearchSkills: () => ({ - data: { items: [] }, + data: { + items: [ + { + id: 1, + displayName: 'Demo Skill', + summary: 'summary', + namespace: 'global', + slug: 'demo', + downloadCount: 1, + starCount: 1, + ratingCount: 0, + updatedAt: '2026-03-20T00:00:00Z', + canSubmitPromotion: false, + publishedVersion: { id: 10, version: '1.0.0', status: 'PUBLISHED' }, + }, + ], + total: 1, + page: 0, + size: 20, + }, isLoading: false, }), })) @@ -47,6 +77,14 @@ import { renderToStaticMarkup } from 'react-dom/server' import { NamespacePage } from './namespace' describe('NamespacePage', () => { + beforeEach(() => { + buttonRecords.length = 0 + useNamespaceDetailMock.mockReturnValue({ + data: { id: 1, slug: 'global', displayName: 'Global', type: 'GLOBAL', status: 'ACTIVE' }, + isLoading: false, + }) + }) + it('exports a named component function', () => { expect(typeof NamespacePage).toBe('function') }) @@ -60,4 +98,12 @@ describe('NamespacePage', () => { const html = renderToStaticMarkup() expect(html).toContain('namespace.notFound') }) + + it('renders namespace distribution actions when skills are available', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('namespace.downloadAll') + expect(html).toContain('namespace.downloadSelected') + expect(html).toContain('namespace.copyInstallManifest') + }) }) diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index 69ac0127..58dc2271 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -1,13 +1,16 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useNavigate, useParams } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { ClipboardCopy, Download } from 'lucide-react' import { NamespaceHeader } from '@/features/namespace/namespace-header' import { SkillCard } from '@/features/skill/skill-card' +import { buildInstallTarget } from '@/features/skill/install-command' import { SkeletonList } from '@/shared/components/skeleton-loader' import { EmptyState } from '@/shared/components/empty-state' import { Pagination } from '@/shared/components/pagination' import { useSearchSkills } from '@/shared/hooks/use-skill-queries' import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' +import { Button } from '@/shared/ui/button' const PAGE_SIZE = 20 @@ -19,10 +22,12 @@ export function NamespacePage() { const navigate = useNavigate() const { namespace } = useParams({ from: '/space/$namespace' }) const [page, setPage] = useState(0) + const [selectedSkillSlugs, setSelectedSkillSlugs] = useState([]) // Reset page when namespace changes useEffect(() => { setPage(0) + setSelectedSkillSlugs([]) }, [namespace]) const { data: namespaceData, isLoading: isLoadingNamespace } = useNamespaceDetail(namespace) @@ -33,11 +38,46 @@ export function NamespacePage() { }) const totalPages = skillsData ? Math.max(Math.ceil(skillsData.total / skillsData.size), 1) : 1 + const visibleSkills = skillsData?.items ?? [] + const selectedSlugSet = useMemo(() => new Set(selectedSkillSlugs), [selectedSkillSlugs]) + const hasSkills = visibleSkills.length > 0 + const selectedDownloadSlugs = selectedSkillSlugs.filter((slug) => visibleSkills.some((skill) => skill.slug === slug)) const handleSkillClick = (slug: string) => { navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` }) } + const handleSkillSelectionChange = (slug: string, selected: boolean) => { + setSelectedSkillSlugs((current) => { + if (selected) { + return current.includes(slug) ? current : [...current, slug] + } + return current.filter((item) => item !== slug) + }) + } + + const buildNamespaceDownloadUrl = (slugs: string[]) => { + const params = new URLSearchParams() + slugs.forEach((slug) => params.append('skill', slug)) + const queryString = params.toString() + return `/api/web/namespaces/${encodeURIComponent(namespace)}/skills/download${queryString ? `?${queryString}` : ''}` + } + + const handleDownloadAll = () => { + window.location.assign(buildNamespaceDownloadUrl([])) + } + + const handleDownloadSelected = () => { + window.location.assign(buildNamespaceDownloadUrl(selectedDownloadSlugs)) + } + + const handleCopyInstallManifest = async () => { + const manifest = visibleSkills + .map((skill) => `skillhub install ${buildInstallTarget(skill.namespace, skill.slug)}`) + .join('\n') + await navigator.clipboard?.writeText(manifest) + } + if (isLoadingNamespace) { return (
@@ -56,14 +96,41 @@ export function NamespacePage() {
-

{t('namespace.skillList')}

+
+

{t('namespace.skillList')}

+ {hasSkills ? ( +
+ + + +
+ ) : null} +
{isLoadingSkills ? ( ) : skillsData && skillsData.items.length > 0 ? ( <>
{skillsData.items.map((skill, idx) => ( -
+
+ handleSkillClick(skill.slug)} diff --git a/web/src/pages/search.test.tsx b/web/src/pages/search.test.tsx index a921d5d3..aac629a0 100644 --- a/web/src/pages/search.test.tsx +++ b/web/src/pages/search.test.tsx @@ -6,6 +6,8 @@ const navigateMock = vi.fn() const useSearchMock = vi.fn() const buttonRecords: Array<{ label: string; variant?: string | null; onClick?: (() => void) | undefined }> = [] const paginationProps: Array<{ onPageChange: (page: number) => void }> = [] +const searchBarProps: Array<{ value?: string; onSearch?: (query: string) => void }> = [] +const searchSkillParams: Array> = [] vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -34,7 +36,10 @@ vi.mock('@/features/auth/use-auth', () => ({ })) vi.mock('@/features/search/search-bar', () => ({ - SearchBar: () =>
search-bar
, + SearchBar: (props: { value?: string; onSearch?: (query: string) => void }) => { + searchBarProps.push(props) + return
search-bar
+ }, })) vi.mock('@/features/skill/skill-card', () => ({ @@ -85,7 +90,10 @@ vi.mock('@/app/page-shell-style', () => ({ const useSearchSkillsMock = vi.fn() vi.mock('@/shared/hooks/use-skill-queries', () => ({ - useSearchSkills: () => useSearchSkillsMock(), + useSearchSkills: (params: Record) => { + searchSkillParams.push(params) + return useSearchSkillsMock() + }, })) vi.mock('@/shared/hooks/use-label-queries', () => ({ @@ -120,8 +128,11 @@ describe('SearchPage', () => { navigateMock.mockReset() buttonRecords.length = 0 paginationProps.length = 0 + searchBarProps.length = 0 + searchSkillParams.length = 0 useSearchMock.mockReturnValue({ q: 'agent', + namespace: 'team-ai', label: 'code-generation', sort: 'downloads', page: 1, @@ -156,6 +167,7 @@ describe('SearchPage', () => { to: '/search', search: { q: 'agent', + namespace: 'team-ai', label: '', sort: 'downloads', page: 0, @@ -173,6 +185,7 @@ describe('SearchPage', () => { to: '/search', search: { q: 'agent', + namespace: 'team-ai', label: 'code-generation', sort: 'newest', page: 0, @@ -191,6 +204,7 @@ describe('SearchPage', () => { to: '/search', search: { q: 'agent', + namespace: 'team-ai', label: 'code-generation', sort: 'downloads', page: 2, @@ -201,6 +215,7 @@ describe('SearchPage', () => { to: '/search', search: { q: 'agent', + namespace: 'team-ai', label: 'code-generation', sort: 'downloads', page: 0, @@ -209,6 +224,38 @@ describe('SearchPage', () => { }) }) + it('passes the namespace URL state into skill search', () => { + renderToStaticMarkup() + + expect(searchSkillParams[0]).toMatchObject({ + q: 'agent', + namespace: 'team-ai', + label: 'code-generation', + sort: 'downloads', + page: 1, + size: 12, + }) + }) + + it('extracts a leading namespace token from the search input', () => { + renderToStaticMarkup() + + searchBarProps[0]?.onSearch?.('@product-team onboarding') + + expect(navigateMock).toHaveBeenCalledWith({ + to: '/search', + search: { + q: 'onboarding', + namespace: 'product-team', + label: 'code-generation', + sort: 'downloads', + page: 0, + starredOnly: false, + }, + replace: true, + }) + }) + it('renders the default skill list when the empty query still returns items', () => { useSearchMock.mockReturnValue({ q: '', diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 58c421db..dc849cab 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -12,7 +12,7 @@ import { Pagination } from '@/shared/components/pagination' import { useSearchSkills } from '@/shared/hooks/use-skill-queries' import { useVisibleLabels } from '@/shared/hooks/use-label-queries' import { useMyStars } from '@/shared/hooks/use-user-queries' -import { normalizeSearchQuery } from '@/shared/lib/search-query' +import { formatNamespaceSearchInput, normalizeSearchQuery, parseNamespaceSearchInput } from '@/shared/lib/search-query' import { Button } from '@/shared/ui/button' import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style' @@ -55,17 +55,22 @@ function scrollToTopOnPageChange() { * Search text, sorting, pagination, and the starred-only filter are mirrored into router search * params so the page can be shared, restored, and revisited without losing state. */ -function filterStarredSkills(skills: SkillSummary[], query: string): SkillSummary[] { +function filterStarredSkills(skills: SkillSummary[], query: string, namespace: string): SkillSummary[] { const normalizedQuery = query.trim().toLowerCase() - if (!normalizedQuery) { - return skills - } + const normalizedNamespace = namespace.trim().toLowerCase() - return skills.filter((skill) => - [skill.displayName, skill.summary, skill.namespace, skill.slug] - .filter(Boolean) - .some((value) => value!.toLowerCase().includes(normalizedQuery)) - ) + return skills.filter((skill) => { + const matchesNamespace = !normalizedNamespace || skill.namespace.toLowerCase() === normalizedNamespace + if (!matchesNamespace) { + return false + } + if (!normalizedQuery) { + return true + } + return [skill.displayName, skill.summary, skill.namespace, skill.slug] + .filter(Boolean) + .some((value) => value!.toLowerCase().includes(normalizedQuery)) + }) } function sortStarredSkills(skills: SkillSummary[], sort: string): SkillSummary[] { @@ -86,16 +91,17 @@ export function SearchPage() { const { isAuthenticated } = useAuth() const q = normalizeSearchQuery(searchParams.q || '') + const namespace = (searchParams.namespace || '').replace(/^@/, '') const selectedLabel = searchParams.label || '' const sort = searchParams.sort || 'newest' const page = searchParams.page ?? 0 const starredOnly = searchParams.starredOnly ?? false - const [queryInput, setQueryInput] = useState(q) + const [queryInput, setQueryInput] = useState(formatNamespaceSearchInput(namespace, q)) const previousPageRef = useRef(page) useEffect(() => { - setQueryInput(q) - }, [q]) + setQueryInput(formatNamespaceSearchInput(namespace, q)) + }, [namespace, q]) useEffect(() => { if (previousPageRef.current !== page) { @@ -113,6 +119,7 @@ export function SearchPage() { const { data, isLoading, isFetching } = useSearchSkills({ q, + namespace: namespace || undefined, label: selectedLabel || undefined, sort, page, @@ -128,47 +135,51 @@ export function SearchPage() { useEffect(() => { // Debounce URL updates while the user is typing so query state stays shareable without // triggering a navigation on every keystroke. - const normalizedQuery = normalizeSearchQuery(queryInput) - if (normalizedQuery === q) { + const parsedInput = parseNamespaceSearchInput(queryInput) + if (parsedInput.query === q && parsedInput.namespace === namespace) { return } - if (!normalizedQuery) { + if (!parsedInput.query && !parsedInput.namespace) { startTransition(() => { - navigate({ to: '/search', search: { q: '', label: selectedLabel, sort, page: 0, starredOnly }, replace: page === 0 }) + navigate({ to: '/search', search: { q: '', namespace: '', label: selectedLabel, sort, page: 0, starredOnly }, replace: page === 0 }) }) return } const timeoutId = window.setTimeout(() => { startTransition(() => { - navigate({ to: '/search', search: { q: normalizedQuery, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) + navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) }) }, 250) return () => window.clearTimeout(timeoutId) - }, [navigate, page, q, queryInput, selectedLabel, sort, starredOnly]) + }, [navigate, namespace, page, q, queryInput, selectedLabel, sort, starredOnly]) const handleSearch = (query: string) => { - const normalizedQuery = normalizeSearchQuery(query) + const parsedInput = parseNamespaceSearchInput(query) setQueryInput(query) startTransition(() => { - navigate({ to: '/search', search: { q: normalizedQuery, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) + navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) }) } const handleSortChange = (newSort: string) => { - navigate({ to: '/search', search: { q, label: selectedLabel, sort: newSort, page: 0, starredOnly } }) + navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort: newSort, page: 0, starredOnly } }) } const handlePageChange = (newPage: number) => { blurActiveElement() - navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: newPage, starredOnly } }) + navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: newPage, starredOnly } }) } const handleLabelToggle = (label: string) => { const nextLabel = selectedLabel === label ? '' : label - navigate({ to: '/search', search: { q, label: nextLabel, sort, page: 0, starredOnly } }) + navigate({ to: '/search', search: { q, namespace, label: nextLabel, sort, page: 0, starredOnly } }) + } + + const handleNamespaceClear = () => { + navigate({ to: '/search', search: { q, namespace: '', label: selectedLabel, sort, page: 0, starredOnly } }) } const handleStarredToggle = () => { @@ -182,7 +193,7 @@ export function SearchPage() { return } - navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: 0, starredOnly: !starredOnly } }) + navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: 0, starredOnly: !starredOnly } }) } const handleSkillClick = (namespace: string, slug: string) => { @@ -190,7 +201,7 @@ export function SearchPage() { } const filteredStarredSkills = starredOnly - ? sortStarredSkills(filterStarredSkills(starredSkills ?? [], q), sort) + ? sortStarredSkills(filterStarredSkills(starredSkills ?? [], q, namespace), sort) : [] const starredPageItems = starredOnly ? filteredStarredSkills.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) @@ -280,6 +291,15 @@ export function SearchPage() { {label.displayName} ))} + {namespace ? ( + + ) : null}
diff --git a/web/src/shared/lib/search-query.test.ts b/web/src/shared/lib/search-query.test.ts index b0322df0..1643793a 100644 --- a/web/src/shared/lib/search-query.test.ts +++ b/web/src/shared/lib/search-query.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { MAX_SEARCH_QUERY_LENGTH, normalizeSearchQuery } from './search-query' +import { MAX_SEARCH_QUERY_LENGTH, normalizeSearchQuery, parseNamespaceSearchInput } from './search-query' describe('normalizeSearchQuery', () => { it('trims whitespace around the query', () => { @@ -13,3 +13,26 @@ describe('normalizeSearchQuery', () => { expect(normalizeSearchQuery(query)).toBe('a'.repeat(MAX_SEARCH_QUERY_LENGTH)) }) }) + +describe('parseNamespaceSearchInput', () => { + it('extracts a leading namespace token and keeps the remaining query', () => { + expect(parseNamespaceSearchInput('@team-ai release notes')).toEqual({ + namespace: 'team-ai', + query: 'release notes', + }) + }) + + it('treats a bare namespace token as a namespace-only search', () => { + expect(parseNamespaceSearchInput('@product')).toEqual({ + namespace: 'product', + query: '', + }) + }) + + it('leaves ordinary search text unchanged', () => { + expect(parseNamespaceSearchInput('meeting assistant')).toEqual({ + namespace: '', + query: 'meeting assistant', + }) + }) +}) diff --git a/web/src/shared/lib/search-query.ts b/web/src/shared/lib/search-query.ts index 1b28b3ea..24f920fa 100644 --- a/web/src/shared/lib/search-query.ts +++ b/web/src/shared/lib/search-query.ts @@ -3,3 +3,32 @@ export const MAX_SEARCH_QUERY_LENGTH = 50 export function normalizeSearchQuery(query: string): string { return query.trim().slice(0, MAX_SEARCH_QUERY_LENGTH) } + +export interface NamespaceSearchInput { + namespace: string + query: string +} + +const LEADING_NAMESPACE_PATTERN = /^@([a-zA-Z0-9][a-zA-Z0-9-]{0,63})(?:\s+|$)(.*)$/ + +export function parseNamespaceSearchInput(input: string): NamespaceSearchInput { + const normalized = normalizeSearchQuery(input) + const match = normalized.match(LEADING_NAMESPACE_PATTERN) + if (!match) { + return { namespace: '', query: normalized } + } + + return { + namespace: match[1], + query: normalizeSearchQuery(match[2] ?? ''), + } +} + +export function formatNamespaceSearchInput(namespace: string, query: string): string { + const normalizedNamespace = namespace.trim().replace(/^@/, '') + const normalizedQuery = normalizeSearchQuery(query) + if (!normalizedNamespace) { + return normalizedQuery + } + return normalizedQuery ? `@${normalizedNamespace} ${normalizedQuery}` : `@${normalizedNamespace}` +} From 204f52dd304c9b5f000def4b2fe04c8d3ac5b516 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 4 Jun 2026 15:49:40 +0800 Subject: [PATCH 02/19] test(web): add namespace search download e2e coverage Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/namespace-search-download.spec.ts | 301 ++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 web/e2e/namespace-search-download.spec.ts diff --git a/web/e2e/namespace-search-download.spec.ts b/web/e2e/namespace-search-download.spec.ts new file mode 100644 index 00000000..b935a558 --- /dev/null +++ b/web/e2e/namespace-search-download.spec.ts @@ -0,0 +1,301 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +const namespaceSlug = 'product-managers' + +const skillFixtures = [ + { + id: 7101, + slug: 'roadmap-agent', + displayName: 'Roadmap Agent', + summary: 'Turns product strategy into roadmap drafts.', + downloadCount: 12, + starCount: 3, + ratingAvg: 4.8, + ratingCount: 4, + namespace: namespaceSlug, + updatedAt: '2026-06-01T00:00:00Z', + canSubmitPromotion: false, + headlineVersion: { id: 8101, version: '1.0.0', status: 'PUBLISHED' }, + publishedVersion: { id: 8101, version: '1.0.0', status: 'PUBLISHED' }, + }, + { + id: 7102, + slug: 'requirements-agent', + displayName: 'Requirements Agent', + summary: 'Helps product managers refine user stories.', + downloadCount: 8, + starCount: 2, + ratingAvg: 4.5, + ratingCount: 2, + namespace: namespaceSlug, + updatedAt: '2026-06-02T00:00:00Z', + canSubmitPromotion: false, + headlineVersion: { id: 8102, version: '1.1.0', status: 'PUBLISHED' }, + publishedVersion: { id: 8102, version: '1.1.0', status: 'PUBLISHED' }, + }, + { + id: 7201, + slug: 'backend-agent', + displayName: 'Backend Agent', + summary: 'A skill outside the selected namespace.', + downloadCount: 20, + starCount: 6, + ratingAvg: 4.2, + ratingCount: 5, + namespace: 'developers', + updatedAt: '2026-06-03T00:00:00Z', + canSubmitPromotion: false, + headlineVersion: { id: 8201, version: '2.0.0', status: 'PUBLISHED' }, + publishedVersion: { id: 8201, version: '2.0.0', status: 'PUBLISHED' }, + }, +] + +function envelope(data: unknown, code = 0, msg = 'success') { + return JSON.stringify({ + code, + msg, + data, + timestamp: '2026-06-04T00:00:00Z', + requestId: 'e2e-namespace-search-download', + }) +} + +async function mockCommonApi(page: Page, options?: { authenticated?: boolean }) { + await page.route('**/api/v1/auth/me', async (route) => { + if (options?.authenticated) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope({ + userId: 'e2e-product-manager', + displayName: 'E2E Product Manager', + email: 'pm@example.com', + platformRoles: [], + }), + }) + return + } + + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: envelope(null, 401, 'Unauthorized'), + }) + }) + await page.route('**/api/v1/auth/providers**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope([]), + }) + }) + await page.route('**/api/v1/auth/methods**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope([]), + }) + }) + await page.route('**/api/web/labels', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope([]), + }) + }) + await page.route('**/api/web/me/namespaces', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope([]), + }) + }) + await page.route('**/api/web/notifications/unread-count', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope({ count: 0 }), + }) + }) + await page.route('**/api/web/notifications/sse', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: '', + }) + }) + await page.route(/\/api\/web\/skills\/\d+\/star$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope(false), + }) + }) +} + +async function mockSearchApi(page: Page) { + const requests: URL[] = [] + + await page.route(/\/api\/web\/skills\?/, async (route) => { + const url = new URL(route.request().url()) + requests.push(url) + + const q = (url.searchParams.get('q') ?? '').trim().toLowerCase() + const namespace = (url.searchParams.get('namespace') ?? '').trim().toLowerCase() + const pageNumber = Number(url.searchParams.get('page') ?? '0') + const pageSize = Number(url.searchParams.get('size') ?? '12') + const items = skillFixtures.filter((skill) => { + const matchesNamespace = !namespace || skill.namespace === namespace + const matchesQuery = !q + || skill.displayName.toLowerCase().includes(q) + || skill.summary.toLowerCase().includes(q) + || skill.slug.toLowerCase().includes(q) + return matchesNamespace && matchesQuery + }) + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope({ + items, + total: items.length, + page: pageNumber, + size: pageSize, + }), + }) + }) + + return requests +} + +async function mockNamespaceApi(page: Page) { + await page.route(`**/api/web/namespaces/${namespaceSlug}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: envelope({ + id: 5101, + slug: namespaceSlug, + displayName: 'Product Managers', + description: 'Skills curated for product and requirements work.', + type: 'TEAM', + status: 'ACTIVE', + createdAt: '2026-06-01T00:00:00Z', + updatedAt: '2026-06-02T00:00:00Z', + }), + }) + }) + + await page.route(`**/api/web/namespaces/${namespaceSlug}/skills/download**`, async (route) => { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${namespaceSlug}-skills.zip"`, + }, + body: 'PK', + }) + }) +} + +test.describe('Namespace Search and Download', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('submits @namespace search input as separate namespace and keyword URL parameters', async ({ page }) => { + await mockCommonApi(page) + const requests = await mockSearchApi(page) + + await page.goto('/search') + await page.getByPlaceholder('Search skills...').fill(`@${namespaceSlug} roadmap`) + await page.getByRole('button', { name: 'Search', exact: true }).click() + + await expect(page).toHaveURL(new RegExp(`namespace=${namespaceSlug}`)) + await expect(page).toHaveURL(/q=roadmap/) + await expect(page.getByRole('button', { name: `@${namespaceSlug}` })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Roadmap Agent' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Backend Agent' })).toHaveCount(0) + await expect.poll(() => requests.some((url) => + url.searchParams.get('namespace') === namespaceSlug + && url.searchParams.get('q') === 'roadmap', + )).toBe(true) + }) + + test('clears the namespace filter while preserving the keyword and sort mode', async ({ page }) => { + await mockCommonApi(page) + const requests = await mockSearchApi(page) + + await page.goto(`/search?q=roadmap&namespace=${namespaceSlug}&sort=downloads&page=1&starredOnly=false`) + await page.getByRole('button', { name: `@${namespaceSlug}` }).click() + + await expect(page).toHaveURL(/q=roadmap/) + await expect(page).toHaveURL(/sort=downloads/) + await expect(page).toHaveURL(/page=0/) + await expect(page).not.toHaveURL(new RegExp(`namespace=${namespaceSlug}`)) + await expect.poll(() => requests.some((url) => + url.searchParams.get('q') === 'roadmap' + && !url.searchParams.has('namespace') + && url.searchParams.get('sort') === 'downloads', + )).toBe(true) + }) + + test('copies the namespace install manifest and gates selected download until a skill is checked', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await mockCommonApi(page, { authenticated: true }) + await mockSearchApi(page) + await mockNamespaceApi(page) + + await page.goto(`/space/${namespaceSlug}`) + + const selectedDownloadButton = page.getByRole('button', { name: 'Download selected' }) + await expect(selectedDownloadButton).toBeDisabled() + + await page.getByLabel('Select Roadmap Agent').check() + await expect(selectedDownloadButton).toBeEnabled() + + await page.getByRole('button', { name: 'Copy install list' }).click() + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) + + expect(clipboardText).toContain(`skillhub install ${namespaceSlug}--roadmap-agent`) + expect(clipboardText).toContain(`skillhub install ${namespaceSlug}--requirements-agent`) + }) + + test('downloads only selected namespace skills with skill query parameters', async ({ page }) => { + await mockCommonApi(page, { authenticated: true }) + await mockSearchApi(page) + await mockNamespaceApi(page) + + await page.goto(`/space/${namespaceSlug}`) + await page.getByLabel('Select Roadmap Agent').check() + + const [request, response] = await Promise.all([ + page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), + page.waitForResponse((response) => response.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), + page.getByRole('button', { name: 'Download selected' }).click(), + ]) + + const downloadUrl = new URL(request.url()) + expect(response.headers()['content-disposition']).toContain(`${namespaceSlug}-skills.zip`) + expect(downloadUrl.searchParams.getAll('skill')).toEqual(['roadmap-agent']) + }) + + test('downloads the full namespace bundle without skill query parameters', async ({ page }) => { + await mockCommonApi(page, { authenticated: true }) + await mockSearchApi(page) + await mockNamespaceApi(page) + + await page.goto(`/space/${namespaceSlug}`) + + const [request, response] = await Promise.all([ + page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), + page.waitForResponse((response) => response.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), + page.getByRole('button', { name: 'Download all' }).click(), + ]) + + const downloadUrl = new URL(request.url()) + expect(response.headers()['content-disposition']).toContain(`${namespaceSlug}-skills.zip`) + expect(downloadUrl.searchParams.getAll('skill')).toEqual([]) + }) +}) From 6bb89b1c89164aa2948954e1bbd17435d9ec8df0 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 4 Jun 2026 17:12:44 +0800 Subject: [PATCH 03/19] fix(skill): address namespace bundle review findings Signed-off-by: dongmucat <1127093059@qq.com> --- .../policy/RouteSecurityPolicyRegistry.java | 4 ++ .../RouteSecurityPolicyRegistryTest.java | 17 +++++ .../skill/service/SkillDownloadService.java | 15 +++- .../service/SkillDownloadServiceTest.java | 72 +++++++++++++++++++ web/e2e/namespace-search-download.spec.ts | 18 +++-- web/src/i18n/locales/en.json | 8 ++- web/src/i18n/locales/zh.json | 8 ++- web/src/pages/namespace.tsx | 36 +++++++++- web/src/shared/components/confirm-dialog.tsx | 2 +- 9 files changed, 167 insertions(+), 13 deletions(-) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 5ac6c1d1..4747814f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -52,6 +52,7 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/files"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/file"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/labels"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces/*/skills/download"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions"), @@ -67,6 +68,7 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/files"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/file"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/labels"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces/*/skills/download"), RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/id/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"), @@ -98,8 +100,10 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/skills/**"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills/**"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*/skills/download"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*/skills/download"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resolve/**"), diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 0206e395..d46d8b9d 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -73,6 +73,23 @@ class RouteSecurityPolicyRegistryTest { assertTrue(matchedWeb); } + @Test + void authorizationPolicies_shouldKeepNamespaceDownloadRoutesAnonymous() { + boolean matchedV1 = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && "/api/v1/namespaces/*/skills/download".equals(policy.pattern()) + && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL); + boolean matchedWeb = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && "/api/web/namespaces/*/skills/download".equals(policy.pattern()) + && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL); + + assertTrue(matchedV1); + assertTrue(matchedWeb); + assertTrue(registry.authorizeApiToken("GET", "/api/v1/namespaces/global/skills/download", Set.of()).allowed()); + assertTrue(registry.authorizeApiToken("GET", "/api/web/namespaces/global/skills/download", Set.of()).allowed()); + } + @Test void apiTokenPolicySupportsNativeCliRoutes() { assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/auth/whoami", Set.of()).allowed()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 354f7e05..aed9a91b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -224,7 +224,9 @@ public class SkillDownloadService { Skill skill, String currentUserId, Map userNsRoles) { - assertCanDownload(namespace, skill, currentUserId, userNsRoles); + if (!canIncludeInNamespaceBundle(namespace, skill, currentUserId, userNsRoles)) { + return java.util.Optional.empty(); + } if (skill.getLatestVersionId() == null) { return java.util.Optional.empty(); } @@ -236,6 +238,17 @@ public class SkillDownloadService { return java.util.Optional.of(new NamespaceBundleEntry(skill, version, buildDownloadResult(skill, version))); } + private boolean canIncludeInNamespaceBundle( + Namespace namespace, + Skill skill, + String currentUserId, + Map userNsRoles) { + if (currentUserId == null && !isAnonymousDownloadAllowed(namespace, skill)) { + return false; + } + return visibilityChecker.canAccess(skill, currentUserId, userNsRoles); + } + private byte[] createNamespaceBundle(String namespaceSlug, List entries) { try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 666df6fa..22be0a3f 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -410,6 +410,78 @@ class SkillDownloadServiceTest { verify(eventPublisher, times(2)).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadNamespaceBundle_SkipsInvisibleAndUnpublishedSkills() throws Exception { + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + setId(namespace, 2L); + namespace.setType(NamespaceType.TEAM); + + Skill visible = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); + setId(visible, 11L); + visible.setDisplayName("Alpha Skill"); + visible.setStatus(SkillStatus.ACTIVE); + visible.setLatestVersionId(101L); + + Skill invisible = new Skill(2L, "beta-private", "owner-2", SkillVisibility.PRIVATE); + setId(invisible, 12L); + invisible.setDisplayName("Beta Private"); + invisible.setStatus(SkillStatus.ACTIVE); + invisible.setLatestVersionId(102L); + + Skill draftOnly = new Skill(2L, "gamma-draft", "owner-1", SkillVisibility.PUBLIC); + setId(draftOnly, 13L); + draftOnly.setDisplayName("Gamma Draft"); + draftOnly.setStatus(SkillStatus.ACTIVE); + draftOnly.setLatestVersionId(103L); + + SkillVersion visibleVersion = new SkillVersion(11L, "1.0.0", "owner-1"); + setId(visibleVersion, 101L); + visibleVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion privateVersion = new SkillVersion(12L, "1.0.0", "owner-2"); + setId(privateVersion, 102L); + privateVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion draftVersion = new SkillVersion(13L, "0.1.0", "owner-1"); + setId(draftVersion, 103L); + draftVersion.setStatus(SkillVersionStatus.DRAFT); + + SkillFile alphaFile = new SkillFile(101L, "SKILL.md", 5L, "text/markdown", "hash-a", "skills/11/101/SKILL.md"); + + Map roles = Map.of(2L, NamespaceRole.MEMBER); + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(visible, invisible, draftOnly)); + when(visibilityChecker.canAccess(visible, "user-1", roles)).thenReturn(true); + when(visibilityChecker.canAccess(invisible, "user-1", roles)).thenReturn(false); + when(visibilityChecker.canAccess(draftOnly, "user-1", roles)).thenReturn(true); + when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(visibleVersion)); + when(skillVersionRepository.findById(103L)).thenReturn(Optional.of(draftVersion)); + when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(false); + when(skillFileRepository.findByVersionId(101L)).thenReturn(List.of(alphaFile)); + when(objectStorageService.exists("skills/11/101/SKILL.md")).thenReturn(true); + when(objectStorageService.getObject("skills/11/101/SKILL.md")).thenReturn(new ByteArrayInputStream("alpha".getBytes())); + + SkillDownloadService.DownloadResult result = service.downloadNamespaceBundle( + "team-ai", + List.of(), + "user-1", + roles); + + try (ZipInputStream zipInputStream = new ZipInputStream(result.openContent())) { + var firstEntry = zipInputStream.getNextEntry(); + assertNotNull(firstEntry); + assertEquals("team-ai/alpha-1.0.0.zip", firstEntry.getName()); + assertNull(zipInputStream.getNextEntry()); + } + + verify(skillVersionRepository, never()).findById(102L); + verify(skillRepository).incrementDownloadCount(11L); + verify(skillRepository, never()).incrementDownloadCount(12L); + verify(skillRepository, never()).incrementDownloadCount(13L); + verify(skillVersionStatsRepository).incrementDownloadCount(101L, 11L); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(102L, 12L); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(103L, 13L); + verify(eventPublisher, times(1)).publishEvent(any(SkillDownloadedEvent.class)); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/web/e2e/namespace-search-download.spec.ts b/web/e2e/namespace-search-download.spec.ts index b935a558..208d02a8 100644 --- a/web/e2e/namespace-search-download.spec.ts +++ b/web/e2e/namespace-search-download.spec.ts @@ -241,7 +241,7 @@ test.describe('Namespace Search and Download', () => { )).toBe(true) }) - test('copies the namespace install manifest and gates selected download until a skill is checked', async ({ page, context }) => { + test('copies the current page install manifest and gates selected download until a skill is checked', async ({ page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']) await mockCommonApi(page, { authenticated: true }) await mockSearchApi(page) @@ -249,13 +249,13 @@ test.describe('Namespace Search and Download', () => { await page.goto(`/space/${namespaceSlug}`) - const selectedDownloadButton = page.getByRole('button', { name: 'Download selected' }) + const selectedDownloadButton = page.getByRole('button', { name: 'Download selected on this page' }) await expect(selectedDownloadButton).toBeDisabled() await page.getByLabel('Select Roadmap Agent').check() await expect(selectedDownloadButton).toBeEnabled() - await page.getByRole('button', { name: 'Copy install list' }).click() + await page.getByRole('button', { name: 'Copy current page install list' }).click() const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) expect(clipboardText).toContain(`skillhub install ${namespaceSlug}--roadmap-agent`) @@ -270,10 +270,14 @@ test.describe('Namespace Search and Download', () => { await page.goto(`/space/${namespaceSlug}`) await page.getByLabel('Select Roadmap Agent').check() + await page.getByRole('button', { name: 'Download selected on this page' }).click() + await expect(page.getByRole('dialog', { name: 'Confirm namespace download' })).toBeVisible() + await expect(page.getByText('This will request 1 skill package from @product-managers.')).toBeVisible() + const [request, response] = await Promise.all([ page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), page.waitForResponse((response) => response.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), - page.getByRole('button', { name: 'Download selected' }).click(), + page.getByRole('button', { name: 'Download', exact: true }).click(), ]) const downloadUrl = new URL(request.url()) @@ -288,10 +292,14 @@ test.describe('Namespace Search and Download', () => { await page.goto(`/space/${namespaceSlug}`) + await page.getByRole('button', { name: 'Download all' }).click() + await expect(page.getByRole('dialog', { name: 'Confirm namespace download' })).toBeVisible() + await expect(page.getByText('This will request 2 skill packages from @product-managers.')).toBeVisible() + const [request, response] = await Promise.all([ page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), page.waitForResponse((response) => response.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), - page.getByRole('button', { name: 'Download all' }).click(), + page.getByRole('button', { name: 'Download', exact: true }).click(), ]) const downloadUrl = new URL(request.url()) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index d556f3c9..1d2ad394 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -768,8 +768,12 @@ "emptyTitle": "No skills", "emptyDescription": "No skills have been published in this namespace yet", "downloadAll": "Download all", - "downloadSelected": "Download selected", - "copyInstallManifest": "Copy install list", + "downloadSelected": "Download selected on this page", + "copyInstallManifest": "Copy current page install list", + "downloadConfirmTitle": "Confirm namespace download", + "downloadConfirmDescription_one": "This will request {{count}} skill package from @{{namespace}}.", + "downloadConfirmDescription_other": "This will request {{count}} skill packages from @{{namespace}}.", + "downloadConfirmAction": "Download", "selectSkill": "Select {{name}}" }, "skillDetail": { diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 88daccf8..feb66a8b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -768,8 +768,12 @@ "emptyTitle": "暂无技能", "emptyDescription": "该命名空间下还没有发布任何技能", "downloadAll": "下载全部", - "downloadSelected": "下载选中", - "copyInstallManifest": "复制安装清单", + "downloadSelected": "下载本页选中", + "copyInstallManifest": "复制本页安装清单", + "downloadConfirmTitle": "确认命名空间下载", + "downloadConfirmDescription_one": "将请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", + "downloadConfirmDescription_other": "将请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", + "downloadConfirmAction": "下载", "selectSkill": "选择 {{name}}" }, "skillDetail": { diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index 58dc2271..5a628a0d 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -8,6 +8,7 @@ import { buildInstallTarget } from '@/features/skill/install-command' import { SkeletonList } from '@/shared/components/skeleton-loader' import { EmptyState } from '@/shared/components/empty-state' import { Pagination } from '@/shared/components/pagination' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { useSearchSkills } from '@/shared/hooks/use-skill-queries' import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' import { Button } from '@/shared/ui/button' @@ -23,13 +24,19 @@ export function NamespacePage() { const { namespace } = useParams({ from: '/space/$namespace' }) const [page, setPage] = useState(0) const [selectedSkillSlugs, setSelectedSkillSlugs] = useState([]) + const [pendingDownloadSlugs, setPendingDownloadSlugs] = useState(null) // Reset page when namespace changes useEffect(() => { setPage(0) setSelectedSkillSlugs([]) + setPendingDownloadSlugs(null) }, [namespace]) + useEffect(() => { + setSelectedSkillSlugs([]) + }, [page]) + const { data: namespaceData, isLoading: isLoadingNamespace } = useNamespaceDetail(namespace) const { data: skillsData, isLoading: isLoadingSkills } = useSearchSkills({ namespace, @@ -42,6 +49,9 @@ export function NamespacePage() { const selectedSlugSet = useMemo(() => new Set(selectedSkillSlugs), [selectedSkillSlugs]) const hasSkills = visibleSkills.length > 0 const selectedDownloadSlugs = selectedSkillSlugs.filter((slug) => visibleSkills.some((skill) => skill.slug === slug)) + const pendingDownloadCount = pendingDownloadSlugs + ? pendingDownloadSlugs.length || skillsData?.total || visibleSkills.length + : 0 const handleSkillClick = (slug: string) => { navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` }) @@ -64,11 +74,18 @@ export function NamespacePage() { } const handleDownloadAll = () => { - window.location.assign(buildNamespaceDownloadUrl([])) + setPendingDownloadSlugs([]) } const handleDownloadSelected = () => { - window.location.assign(buildNamespaceDownloadUrl(selectedDownloadSlugs)) + setPendingDownloadSlugs(selectedDownloadSlugs) + } + + const confirmDownload = () => { + if (!pendingDownloadSlugs) { + return + } + window.location.assign(buildNamespaceDownloadUrl(pendingDownloadSlugs)) } const handleCopyInstallManifest = async () => { @@ -150,6 +167,21 @@ export function NamespacePage() { /> )}
+ { + if (!open) { + setPendingDownloadSlugs(null) + } + }} + title={t('namespace.downloadConfirmTitle')} + description={t('namespace.downloadConfirmDescription', { + count: pendingDownloadCount, + namespace, + })} + confirmText={t('namespace.downloadConfirmAction')} + onConfirm={confirmDownload} + />
) } diff --git a/web/src/shared/components/confirm-dialog.tsx b/web/src/shared/components/confirm-dialog.tsx index b760ac45..aee74103 100644 --- a/web/src/shared/components/confirm-dialog.tsx +++ b/web/src/shared/components/confirm-dialog.tsx @@ -45,7 +45,7 @@ export function ConfirmDialog({ return ( - + {title} {description && {description}} From 1ec93db0d6a4d40dd082b3c1a74056fc7c56404c Mon Sep 17 00:00:00 2001 From: dongmucat <70678707+dongmucat@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:25:02 +0800 Subject: [PATCH 04/19] Revert "feat(bootstrap): initialize built-in skills (#481)" (#487) This reverts commit 90fc97e740dd5184e27699aaeeec746cf8b0afae. --- README.md | 7 - docs/20-builtin-skills-design.md | 382 ----------------- docs/openclaw-integration-en.md | 13 - docs/openclaw-integration.md | 13 - .../bootstrap/BuiltinSkillFingerprints.java | 42 -- .../bootstrap/BuiltinSkillInitializer.java | 295 -------------- .../bootstrap/BuiltinSkillPackageLoader.java | 107 ----- .../bootstrap/BuiltinSkillProperties.java | 21 - .../support/MultipartPackageExtractor.java | 10 +- .../support/SkillPackageArchiveExtractor.java | 26 +- .../SkillPackageContentTypeResolver.java | 35 -- .../support/ZipPackageExtractor.java | 26 +- .../src/main/resources/application.yml | 2 - .../builtin-skills/skillhub-hello/README.md | 5 - .../builtin-skills/skillhub-hello/SKILL.md | 8 - .../BuiltinSkillInitializerTest.java | 383 ------------------ .../BuiltinSkillPackageLoaderTest.java | 124 ------ .../BuiltinSkillPropertiesBindingTest.java | 47 --- .../SkillPackageContentTypeResolverTest.java | 17 - 19 files changed, 59 insertions(+), 1504 deletions(-) delete mode 100644 docs/20-builtin-skills-design.md delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java delete mode 100644 server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md delete mode 100644 server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md delete mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java delete mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java delete mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java delete mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java diff --git a/README.md b/README.md index 8f0e4860..d47ef5cc 100644 --- a/README.md +++ b/README.md @@ -119,10 +119,6 @@ skillhub login --token sk_xxx --registry https://skill.xfyun.cn skillhub search pdf skillhub install pdf-parser --agent codex -# Verify the bundled example skill after a fresh deployment -skillhub search skillhub-hello -skillhub install skillhub-hello --agent codex - # List installed skills skillhub list ``` @@ -290,7 +286,6 @@ Recommended production baseline: - keep PostgreSQL / Redis bound to `127.0.0.1` - use external S3 / OSS via `SKILLHUB_STORAGE_S3_*` - change `BOOTSTRAP_ADMIN_PASSWORD` to a strong password (`validate-release-config.sh` rejects the default `ChangeMe!2026`) -- set `SKILLHUB_BUILTIN_SKILLS_ENABLED=false` if you do not want the bundled `skillhub-hello` skill initialized in `@global` - rotate or disable the bootstrap admin after initial setup - run `make validate-release-config` before `docker compose up -d` @@ -426,8 +421,6 @@ clawhub login --token YOUR_API_TOKEN npx clawhub search email npx clawhub install my-skill npx clawhub install my-namespace--my-skill -npx clawhub search skillhub-hello -npx clawhub install skillhub-hello # Publish to global namespace npx clawhub publish ./my-skill --slug my-skill --version 1.0.0 diff --git a/docs/20-builtin-skills-design.md b/docs/20-builtin-skills-design.md deleted file mode 100644 index d968df5a..00000000 --- a/docs/20-builtin-skills-design.md +++ /dev/null @@ -1,382 +0,0 @@ -# Built-in Skills Initialization Design - -## Context - -New SkillHub deployments currently start without a guaranteed installable skill in the registry. -Users must first understand publishing or find an external package before they can verify search, -detail, download, and CLI installation flows. - -This design adds a small, built-in example skill that is bundled with the Java service and published -automatically to `@global` during application startup. - -The MVP example skill is `skillhub-hello`. It is intentionally generic and does not encode an -AgentGuard-specific product decision. Future official skills can reuse the same initialization -mechanism. - -## Goals - -- Bundle one or more directory-form skills in the Java service resources. -- Enable built-in skill initialization by default. -- Publish built-in skills to the fixed `global` namespace. -- Publish as `PUBLIC` and `PUBLISHED` so the skill is immediately searchable and installable. -- Use a fixed system publisher, `builtin-skill-publisher`, for owner and audit traceability. -- Reuse the existing `SkillPublishService.publishFromEntries(...)` pipeline. -- Keep initialization idempotent across repeated container deployments. -- Treat published versions as immutable: same version with changed content is skipped with a warning. -- Avoid new seed state tables and distributed locks in the MVP. -- Keep initialization failures non-fatal to application startup. -- Document `skillhub-hello` as an out-of-the-box verification skill. - -## Non-Goals - -- No new database table for seed state. -- No Redis or database distributed lock. -- No zip-based built-in skill packages. -- No configurable target namespace; built-in skills always publish to `global`. -- No label creation or binding for `skillhub-hello`. -- No landing page or frontend recommendation slot. -- No direct SQL/JPA insertion into `skill`, `skill_version`, or `skill_file`. -- No changes to ordinary publish, review, promotion, or lifecycle behavior. - -## Key Decisions - -| Decision | Choice | Reason | -|----------|--------|--------| -| Source location | Java service classpath resources | The runtime artifact always contains the built-in package | -| Directory | `server/skillhub-app/src/main/resources/builtin-skills/` | Spring Boot resource packaging is predictable | -| First built-in skill | `skillhub-hello` | Generic verification skill, not product-specific | -| Startup default | Enabled | Supports out-of-the-box discovery and installation | -| Target namespace | Fixed `global` | Built-in examples are platform-level public skills | -| Publication state | `PUBLIC + PUBLISHED` | Immediately searchable and installable | -| Publisher | `builtin-skill-publisher` | Stable owner and audit source | -| Version mutability | Same version is never overwritten | Published versions remain reproducible | -| Seed state table | None | Existing skill/version/file records are enough for MVP idempotency | -| Distributed lock | None | Conflict-tolerant startup is sufficient for a small built-in set | -| Labels | None | MVP validates the built-in publish mechanism only | -| Failure behavior | Log and continue | A sample skill must not make the service unavailable | -| Package format | Directory only | Easier review and classpath loading | - -## Resource Layout - -Built-in skills live under the `skillhub-app` resource tree: - -```text -server/skillhub-app/src/main/resources/builtin-skills/ - skillhub-hello/ - SKILL.md - README.md -``` - -Each direct child directory under `builtin-skills/` is treated as one skill package. - -Rules: - -- The directory must contain root-level `SKILL.md`. -- File paths are relative to the skill directory. -- Files are converted into `PackageEntry` values. -- Files outside the skill directory are ignored. -- Zip packages are not supported in the MVP. - -Suggested `skillhub-hello/SKILL.md`: - -```markdown ---- -name: skillhub-hello -description: A built-in example skill that verifies SkillHub discovery and installation. -version: 1.0.0 ---- -# SkillHub Hello - -This skill is bundled with SkillHub as a minimal example for validating discovery and installation. -``` - -Published coordinate: - -```text -@global/skillhub-hello -``` - -ClawHub canonical slug: - -```text -skillhub-hello -``` - -## Configuration - -Add one configuration property: - -```yaml -skillhub: - builtin-skills: - enabled: true -``` - -Environment override: - -```bash -SKILLHUB_BUILTIN_SKILLS_ENABLED=false -``` - -The MVP does not expose a namespace or locations property. The implementation uses the fixed -classpath location: - -```text -classpath*:builtin-skills/*/SKILL.md -``` - -## Backend Design - -### Components - -Add the following app-layer bootstrap components: - -- `BuiltinSkillProperties` -- `BuiltinSkillPackageLoader` -- `BuiltinSkillInitializer` - -Responsibilities: - -| Component | Responsibility | -|-----------|----------------| -| `BuiltinSkillProperties` | Bind `skillhub.builtin-skills.enabled` | -| `BuiltinSkillPackageLoader` | Read classpath skill directories and construct `PackageEntry` values | -| `BuiltinSkillInitializer` | Ensure publisher, evaluate idempotency, and call the publish pipeline | - -These classes belong in: - -```text -server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/ -``` - -### Publish Pipeline - -The initializer must call the existing domain publish service: - -```java -skillPublishService.publishFromEntries( - "global", - entries, - "builtin-skill-publisher", - SkillVisibility.PUBLIC, - Set.of("SUPER_ADMIN"), - false -); -``` - -This preserves existing behavior for: - -- package policy validation -- `SKILL.md` parsing -- slug generation -- `Skill` creation or reuse -- `SkillVersion` creation -- `PUBLISHED` state assignment -- `latestVersionId` updates -- `SkillFile` records -- object storage writes -- bundle zip creation -- `SkillPublishedEvent` -- after-commit search index rebuild - -The initializer must not create skill/version/file rows directly. -Passing `false` for warning confirmation means built-in packages with validation warnings are treated -as package quality failures and skipped instead of being silently accepted. - -### System Publisher - -The initializer ensures this system user exists: - -```text -userId: builtin-skill-publisher -displayName: SkillHub Built-in Publisher -email: builtin-skill-publisher@example.invalid -``` - -Requirements: - -- Create `UserAccount` if missing. -- Ensure the user is an `OWNER` member of `@global`. -- Do not create a local login credential. -- Do not require a persisted platform role binding. -- Pass `Set.of("SUPER_ADMIN")` only for the publish call to reuse auto-publish behavior. - -## Idempotency And Version Policy - -### Content Fingerprint - -The initializer computes a package fingerprint from current classpath resources: - -1. Sort entries by normalized path. -2. Hash each file content with SHA-256. -3. Build a canonical stream of `path + fileSha256`. -4. Hash that stream to produce the package fingerprint. - -For an existing published version, the initializer recomputes the same fingerprint from `skill_file`: - -1. Query the target `SkillVersion`. -2. Query its `SkillFile` rows. -3. Sort by `filePath`. -4. Build the canonical stream from `filePath + sha256`. -5. Hash that stream. - -No new persistence field is added. - -### Startup Rules - -For each built-in skill: - -| Existing state | Action | -|----------------|--------| -| Skill does not exist | Publish | -| Skill exists, same version does not exist | Publish new version | -| Same version is `PUBLISHED` and fingerprint matches | Skip | -| Same version is `PUBLISHED` and fingerprint differs | Warn and skip | -| Same version exists but is not `PUBLISHED` | Warn and skip | - -Same-version content changes must bump the version in `SKILL.md`. The initializer must never -overwrite a published version. - -### Concurrent Startup - -The MVP does not use a distributed lock. - -If multiple application instances start at the same time: - -- each instance performs the idempotency check; -- one instance may publish first; -- later instances may hit an existing-version conflict; -- conflict handling should re-read the existing version and skip when a valid published version is present; -- conflicts must be logged but must not fail application startup. - -## Failure Behavior - -Built-in skill initialization is best-effort. - -Rules: - -- Failure in one built-in skill does not prevent other built-in skills from being processed. -- Any initialization failure is logged at error level. -- Same-version fingerprint drift is logged at warning level. -- Same-version matching content is logged at info level. -- Successful publishing is logged at info level. -- Exceptions are contained inside the initializer and do not abort Spring Boot startup. - -Log context should include: - -- skill directory -- resolved slug -- resolved version -- namespace `global` -- action -- error message when applicable - -## Object Storage And Search - -Classpath resources are only the source for initialization. Published files still go through the -configured object storage backend: - -- LocalFile -- MinIO -- S3 - -The initializer does not write object storage directly. - -Search index updates also remain event-driven. The publish service emits `SkillPublishedEvent`, and -the existing search listener rebuilds the search document after transaction commit. - -## User Experience - -The MVP does not add frontend UI. - -After startup, users can discover and install the built-in skill through existing flows: - -- search for `skillhub-hello`; -- open the skill detail page; -- copy the existing install command; -- install with ClawHub/OpenClaw. - -Expected command: - -```bash -npx clawhub install skillhub-hello --registry -``` - -Because labels and recommendation slots are out of scope, this design does not guarantee permanent -homepage prominence for `skillhub-hello`. - -## Documentation - -Update the user-facing docs to mention the built-in verification skill: - -- `README.md` -- `docs/openclaw-integration.md` -- `docs/openclaw-integration-en.md` - -Recommended example: - -```bash -npx clawhub search skillhub-hello --registry -npx clawhub install skillhub-hello --registry -``` - -The docs should explain: - -- `skillhub-hello` is bundled with SkillHub; -- it validates registry search and installation; -- operators can disable initialization with `SKILLHUB_BUILTIN_SKILLS_ENABLED=false`. - -## Testing Strategy - -### Unit Tests - -Add backend tests for: - -- `enabled=false` skips all publishing. -- first startup publishes `skillhub-hello`; -- same version and same fingerprint skips; -- same version and different fingerprint warns and skips; -- same version in a non-`PUBLISHED` state warns and skips; -- publish exceptions are swallowed after logging; -- missing system publisher is created; -- missing `@global` membership is created; -- loader reads classpath directories into stable `PackageEntry` order; -- loader reports a directory missing `SKILL.md`. - -### Local Validation - -Run: - -```bash -make test-backend-app -``` - -If implementation touches runtime packaging or staging startup behavior, also run: - -```bash -make staging -``` - -### Manual Validation - -After local startup: - -```bash -curl "http://localhost:8080/api/web/skills?q=skillhub-hello" -npx clawhub search skillhub-hello --registry http://localhost:8080 -npx clawhub install skillhub-hello --registry http://localhost:8080 -``` - -## Future Extensions - -Potential follow-up work: - -- external filesystem source locations; -- zip package support; -- seed state table; -- distributed lock; -- official label binding; -- landing page official/recommended slot; -- AgentGuard or other official skills built on the same mechanism. - -These are outside the MVP. diff --git a/docs/openclaw-integration-en.md b/docs/openclaw-integration-en.md index 77d93e5e..32cb2878 100644 --- a/docs/openclaw-integration-en.md +++ b/docs/openclaw-integration-en.md @@ -72,10 +72,6 @@ npx clawhub search find-skills npx clawhub search find-skills --limit 5 npx clawhub inspect find-skills -# Bundled verification skill initialized by default on new deployments -npx clawhub search skillhub-hello -npx clawhub inspect skillhub-hello - # Help npx clawhub search --help npx clawhub inspect --help @@ -104,9 +100,6 @@ npx clawhub list npx clawhub --dir ~/.claude/skills install find-skills CLAWHUB_WORKDIR=~/.claude/skills npx clawhub install find-skills -# Install the bundled verification skill -npx clawhub install skillhub-hello - # Help npx clawhub install --help npx clawhub update --help @@ -217,12 +210,6 @@ export CLAWHUB_REGISTRY=https://skillhub.your-company.com clawhub login --token sk_your_api_token_here ``` -New SkillHub deployments initialize the bundled `skillhub-hello` skill in `@global` by default. To disable built-in skill initialization, set this before starting the backend: - -```bash -export SKILLHUB_BUILTIN_SKILLS_ENABLED=false -``` - ## FAQ ### Q: How do I switch back to public ClawHub? diff --git a/docs/openclaw-integration.md b/docs/openclaw-integration.md index 353676c3..f85f588e 100644 --- a/docs/openclaw-integration.md +++ b/docs/openclaw-integration.md @@ -72,10 +72,6 @@ npx clawhub search find-skills npx clawhub search find-skills --limit 5 npx clawhub inspect find-skills -# 新部署默认内置的验证 Skill -npx clawhub search skillhub-hello -npx clawhub inspect skillhub-hello - # 使用帮助 npx clawhub search --help npx clawhub inspect --help @@ -104,9 +100,6 @@ npx clawhub list npx clawhub --dir ~/.claude/skills install find-skills CLAWHUB_WORKDIR=~/.claude/skills npx clawhub install find-skills -# 安装默认内置的验证 Skill -npx clawhub install skillhub-hello - # 使用帮助 npx clawhub install --help npx clawhub update --help @@ -217,12 +210,6 @@ export CLAWHUB_REGISTRY=https://skillhub.your-company.com clawhub login --token sk_your_api_token_here ``` -SkillHub 新部署默认会在 `@global` 初始化内置 `skillhub-hello`。如需关闭内置 Skill 初始化,请在启动后端前设置: - -```bash -export SKILLHUB_BUILTIN_SKILLS_ENABLED=false -``` - ## 常见问题 ### Q: 如何切换回公共 ClawHub? diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java deleted file mode 100644 index 836f733f..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillFingerprints.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -import com.iflytek.skillhub.domain.skill.SkillFile; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Comparator; -import java.util.HexFormat; -import java.util.List; - -final class BuiltinSkillFingerprints { - - private BuiltinSkillFingerprints() { - } - - static String fromEntries(List entries) { - StringBuilder canonical = new StringBuilder(); - entries.stream() - .sorted(Comparator.comparing(PackageEntry::path)) - .map(entry -> entry.path() + "\0" + sha256(entry.content())) - .forEach(line -> canonical.append(line).append('\n')); - return sha256(canonical.toString().getBytes(StandardCharsets.UTF_8)); - } - - static String fromFiles(List files) { - StringBuilder canonical = new StringBuilder(); - files.stream() - .sorted(Comparator.comparing(SkillFile::getFilePath)) - .map(file -> file.getFilePath() + "\0" + file.getSha256()) - .forEach(line -> canonical.append(line).append('\n')); - return sha256(canonical.toString().getBytes(StandardCharsets.UTF_8)); - } - - private static String sha256(byte[] content) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - return HexFormat.of().formatHex(digest.digest(content)); - } catch (Exception exception) { - throw new IllegalStateException("Failed to calculate SHA-256 fingerprint", exception); - } - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java deleted file mode 100644 index 21c58f95..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ /dev/null @@ -1,295 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -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; -import com.iflytek.skillhub.domain.skill.Skill; -import com.iflytek.skillhub.domain.skill.SkillFileRepository; -import com.iflytek.skillhub.domain.skill.SkillRepository; -import com.iflytek.skillhub.domain.skill.SkillVersion; -import com.iflytek.skillhub.domain.skill.SkillVersionRepository; -import com.iflytek.skillhub.domain.skill.SkillVersionStatus; -import com.iflytek.skillhub.domain.skill.SkillVisibility; -import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; -import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; -import com.iflytek.skillhub.domain.skill.service.SkillPublishService; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; -import com.iflytek.skillhub.domain.skill.validation.ValidationResult; -import com.iflytek.skillhub.domain.namespace.SlugValidator; -import com.iflytek.skillhub.domain.user.UserAccount; -import com.iflytek.skillhub.domain.user.UserAccountRepository; -import com.iflytek.skillhub.domain.user.UserStatus; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.stereotype.Component; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.support.TransactionTemplate; - -/** - * Publishes bundled example skills into the global namespace during startup. - */ -@Component -public class BuiltinSkillInitializer implements ApplicationRunner { - - static final String BUILTIN_PUBLISHER_ID = "builtin-skill-publisher"; - static final String GLOBAL_NAMESPACE = "global"; - private static final String BUILTIN_PUBLISHER_NAME = "SkillHub Built-in Publisher"; - private static final String BUILTIN_PUBLISHER_EMAIL = "builtin-skill-publisher@example.invalid"; - private static final Logger log = LoggerFactory.getLogger(BuiltinSkillInitializer.class); - - private final BuiltinSkillProperties properties; - private final BuiltinSkillPackageLoader packageLoader; - private final SkillMetadataParser metadataParser; - private final SkillPackageValidator packageValidator; - private final SkillPublishService skillPublishService; - private final NamespaceRepository namespaceRepository; - private final NamespaceMemberRepository namespaceMemberRepository; - private final UserAccountRepository userAccountRepository; - private final SkillRepository skillRepository; - private final SkillVersionRepository skillVersionRepository; - private final SkillFileRepository skillFileRepository; - private final TransactionTemplate transactionTemplate; - - public BuiltinSkillInitializer(BuiltinSkillProperties properties, - BuiltinSkillPackageLoader packageLoader, - SkillMetadataParser metadataParser, - SkillPackageValidator packageValidator, - SkillPublishService skillPublishService, - NamespaceRepository namespaceRepository, - NamespaceMemberRepository namespaceMemberRepository, - UserAccountRepository userAccountRepository, - SkillRepository skillRepository, - SkillVersionRepository skillVersionRepository, - SkillFileRepository skillFileRepository, - PlatformTransactionManager transactionManager) { - this.properties = properties; - this.packageLoader = packageLoader; - this.metadataParser = metadataParser; - this.packageValidator = packageValidator; - this.skillPublishService = skillPublishService; - this.namespaceRepository = namespaceRepository; - this.namespaceMemberRepository = namespaceMemberRepository; - this.userAccountRepository = userAccountRepository; - this.skillRepository = skillRepository; - this.skillVersionRepository = skillVersionRepository; - this.skillFileRepository = skillFileRepository; - this.transactionTemplate = new TransactionTemplate(transactionManager); - } - - @Override - public void run(ApplicationArguments args) { - if (!properties.isEnabled()) { - log.info("Built-in skill initialization is disabled"); - return; - } - - Namespace globalNamespace; - try { - globalNamespace = ensurePublisher(); - } catch (RuntimeException exception) { - log.error("Failed to prepare built-in skill publisher, skipping built-in skill initialization", - exception); - return; - } - if (globalNamespace == null) { - return; - } - - List packages; - try { - packages = packageLoader.loadPackages(); - } catch (Exception exception) { - log.error("Failed to load built-in skill packages", exception); - return; - } - - for (BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage : packages) { - try { - initializePackage(globalNamespace, skillPackage); - } catch (Exception exception) { - log.error("Failed to initialize built-in skill package [directory={}]", - skillPackage.directory(), exception); - } - } - } - - private Namespace ensurePublisher() { - return transactionTemplate.execute(status -> { - Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE) - .orElse(null); - if (globalNamespace == null) { - log.error("Missing built-in global namespace, skipping built-in skill initialization"); - return null; - } - - UserAccount publisher = userAccountRepository.findById(BUILTIN_PUBLISHER_ID) - .orElseGet(() -> new UserAccount( - BUILTIN_PUBLISHER_ID, - BUILTIN_PUBLISHER_NAME, - BUILTIN_PUBLISHER_EMAIL, - null - )); - publisher.setDisplayName(BUILTIN_PUBLISHER_NAME); - publisher.setEmail(BUILTIN_PUBLISHER_EMAIL); - publisher.setStatus(UserStatus.ACTIVE); - userAccountRepository.save(publisher); - - NamespaceMember member = namespaceMemberRepository - .findByNamespaceIdAndUserId(globalNamespace.getId(), BUILTIN_PUBLISHER_ID) - .orElseGet(() -> new NamespaceMember(globalNamespace.getId(), BUILTIN_PUBLISHER_ID, NamespaceRole.OWNER)); - if (member.getRole() != NamespaceRole.OWNER) { - member.setRole(NamespaceRole.OWNER); - } - namespaceMemberRepository.save(member); - return globalNamespace; - }); - } - - private void initializePackage(Namespace globalNamespace, - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage) { - List entries = skillPackage.entries(); - SkillMetadata metadata = parseMetadata(skillPackage.directory(), entries); - if (metadata.version() == null || metadata.version().isBlank()) { - log.error("Built-in skill package is missing an explicit version [directory={}]", - skillPackage.directory()); - return; - } - String skillSlug = SlugValidator.slugify(metadata.name()); - - ValidationResult validation = packageValidator.validate(entries); - if (!validation.passed() || validation.hasWarnings()) { - log.error("Built-in skill package failed validation [directory={}, slug={}, version={}, errors={}, warnings={}]", - skillPackage.directory(), skillSlug, metadata.version(), validation.errors(), validation.warnings()); - return; - } - - if (hasPublishedOtherOwnerConflict(globalNamespace.getId(), skillSlug)) { - log.warn("Skipping built-in skill because another owner already published the slug [directory={}, namespace={}, slug={}]", - skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug); - return; - } - - Optional existingBuiltInSkill = - skillRepository.findByNamespaceIdAndSlugAndOwnerId(globalNamespace.getId(), skillSlug, BUILTIN_PUBLISHER_ID); - if (existingBuiltInSkill.isPresent() - && shouldSkipExistingVersion(skillPackage, existingBuiltInSkill.get(), metadata.version())) { - return; - } - - publishPackage(skillPackage, skillSlug, metadata.version()); - } - - private SkillMetadata parseMetadata(String directory, List entries) { - PackageEntry skillMd = entries.stream() - .filter(entry -> "SKILL.md".equals(entry.path())) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Built-in package missing SKILL.md: " + directory)); - return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8)); - } - - private boolean hasPublishedOtherOwnerConflict(Long namespaceId, String skillSlug) { - return skillRepository.findByNamespaceIdAndSlug(namespaceId, skillSlug).stream() - .filter(skill -> !BUILTIN_PUBLISHER_ID.equals(skill.getOwnerId())) - .anyMatch(skill -> !skillVersionRepository - .findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PUBLISHED) - .isEmpty()); - } - - private boolean shouldSkipExistingVersion(BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage, - Skill skill, - String version) { - Optional existingVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version); - if (existingVersion.isEmpty()) { - return false; - } - - SkillVersion skillVersion = existingVersion.get(); - if (skillVersion.getStatus() != SkillVersionStatus.PUBLISHED) { - log.warn("Skipping built-in skill because the same version is not published [directory={}, skillId={}, version={}, status={}]", - skillPackage.directory(), skill.getId(), version, skillVersion.getStatus()); - return true; - } - - String currentFingerprint = BuiltinSkillFingerprints.fromEntries(skillPackage.entries()); - String existingFingerprint = BuiltinSkillFingerprints.fromFiles(skillFileRepository.findByVersionId(skillVersion.getId())); - if (currentFingerprint.equals(existingFingerprint)) { - log.info("Built-in skill version already exists, skipping [directory={}, skillId={}, version={}]", - skillPackage.directory(), skill.getId(), version); - } else { - log.warn("Skipping built-in skill because same published version has different content [directory={}, skillId={}, version={}]", - skillPackage.directory(), skill.getId(), version); - } - return true; - } - - private void publishPackage(BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage, - String skillSlug, - String version) { - try { - SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( - GLOBAL_NAMESPACE, - skillPackage.entries(), - BUILTIN_PUBLISHER_ID, - SkillVisibility.PUBLIC, - Set.of("SUPER_ADMIN"), - false - ); - log.info("Published built-in skill [directory={}, namespace={}, slug={}, version={}, status={}]", - skillPackage.directory(), GLOBAL_NAMESPACE, result.slug(), - result.version().getVersion(), result.version().getStatus()); - } catch (RuntimeException exception) { - ConcurrentVersionState concurrentVersionState = - findConcurrentPublishedBuiltInVersionState(skillPackage, skillSlug, version); - if (concurrentVersionState == ConcurrentVersionState.MATCHING) { - log.warn("Built-in skill was already published concurrently, skipping [directory={}, namespace={}, slug={}, version={}]", - skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug, version); - return; - } - if (concurrentVersionState == ConcurrentVersionState.DIFFERENT) { - log.warn("Skipping built-in skill because concurrently published same version has different content [directory={}, namespace={}, slug={}, version={}]", - skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug, version); - return; - } - throw exception; - } - } - - private ConcurrentVersionState findConcurrentPublishedBuiltInVersionState( - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage, - String skillSlug, - String version) { - return namespaceRepository.findBySlug(GLOBAL_NAMESPACE) - .flatMap(namespace -> skillRepository.findByNamespaceIdAndSlugAndOwnerId( - namespace.getId(), - skillSlug, - BUILTIN_PUBLISHER_ID - )) - .flatMap(skill -> skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)) - .filter(skillVersion -> skillVersion.getStatus() == SkillVersionStatus.PUBLISHED) - .map(skillVersion -> { - String currentFingerprint = BuiltinSkillFingerprints.fromEntries(skillPackage.entries()); - String existingFingerprint = BuiltinSkillFingerprints.fromFiles( - skillFileRepository.findByVersionId(skillVersion.getId())); - if (currentFingerprint.equals(existingFingerprint)) { - return ConcurrentVersionState.MATCHING; - } - return ConcurrentVersionState.DIFFERENT; - }) - .orElse(ConcurrentVersionState.MISSING); - } - - private enum ConcurrentVersionState { - MISSING, - MATCHING, - DIFFERENT - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java deleted file mode 100644 index 9775691f..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -import com.iflytek.skillhub.controller.support.SkillPackageContentTypeResolver; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Set; -import java.util.TreeSet; -import org.springframework.core.io.Resource; -import org.springframework.core.io.support.PathMatchingResourcePatternResolver; -import org.springframework.core.io.support.ResourcePatternResolver; -import org.springframework.stereotype.Component; - -/** - * Loads directory-form built-in skill packages from classpath resources. - */ -@Component -public class BuiltinSkillPackageLoader { - - static final String BUILTIN_SKILLS_PATTERN = "classpath*:builtin-skills/*/SKILL.md"; - private static final String ROOT_MARKER = "builtin-skills/"; - private static final String SKILL_MD = "SKILL.md"; - - private final ResourcePatternResolver resourcePatternResolver; - - public BuiltinSkillPackageLoader() { - this(new PathMatchingResourcePatternResolver()); - } - - BuiltinSkillPackageLoader(ResourcePatternResolver resourcePatternResolver) { - this.resourcePatternResolver = resourcePatternResolver; - } - - public List loadPackages() throws IOException { - Set directories = discoverPackageDirectories(); - List packages = new ArrayList<>(); - for (String directory : directories) { - List packageEntries = loadPackageEntries(directory); - boolean hasSkillMd = packageEntries.stream().anyMatch(packageEntry -> SKILL_MD.equals(packageEntry.path())); - if (hasSkillMd) { - packages.add(new BuiltinSkillPackage(directory, packageEntries)); - } - } - return packages; - } - - private Set discoverPackageDirectories() throws IOException { - Resource[] skillMdResources = resourcePatternResolver.getResources(BUILTIN_SKILLS_PATTERN); - Set directories = new TreeSet<>(); - for (Resource resource : skillMdResources) { - String relativePath = relativeBuiltinPath(resource); - if (relativePath == null || relativePath.isBlank()) { - continue; - } - int separatorIndex = relativePath.indexOf('/'); - if (separatorIndex > 0 && SKILL_MD.equals(relativePath.substring(separatorIndex + 1))) { - directories.add(relativePath.substring(0, separatorIndex)); - } - } - return directories; - } - - private List loadPackageEntries(String directory) throws IOException { - Resource[] resources = resourcePatternResolver.getResources("classpath*:builtin-skills/" + directory + "/**"); - List entries = new ArrayList<>(); - for (Resource resource : resources) { - String relativePath = relativeBuiltinPath(resource); - if (relativePath == null || relativePath.isBlank() || relativePath.endsWith("/")) { - continue; - } - String directoryPrefix = directory + "/"; - if (!relativePath.startsWith(directoryPrefix) || directoryPrefix.length() == relativePath.length()) { - continue; - } - - String packagePath = SkillPackagePolicy.normalizeEntryPath(relativePath.substring(directoryPrefix.length())); - try (InputStream inputStream = resource.getInputStream()) { - byte[] content = inputStream.readAllBytes(); - entries.add(new PackageEntry( - packagePath, - content, - content.length, - SkillPackageContentTypeResolver.determineContentType(packagePath) - )); - } - } - return entries.stream() - .sorted(Comparator.comparing(PackageEntry::path)) - .toList(); - } - - private String relativeBuiltinPath(Resource resource) throws IOException { - String url = resource.getURL().toExternalForm(); - int markerIndex = url.lastIndexOf(ROOT_MARKER); - if (markerIndex < 0) { - return null; - } - return url.substring(markerIndex + ROOT_MARKER.length()); - } - - public record BuiltinSkillPackage(String directory, List entries) { - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java deleted file mode 100644 index 298a1af5..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -/** - * Configuration for publishing bundled example skills at application startup. - */ -@Component -@ConfigurationProperties(prefix = "skillhub.builtin-skills") -public class BuiltinSkillProperties { - private boolean enabled = true; - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java index 481cda72..0a9fc793 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -85,7 +85,7 @@ public class MultipartPackageExtractor { normalizedPath, content, content.length, - SkillPackageContentTypeResolver.determineContentType(normalizedPath) + determineContentType(normalizedPath) )); } } @@ -113,4 +113,12 @@ public class MultipartPackageExtractor { return path; } + private String determineContentType(String filename) { + if (filename.endsWith(".py")) return "text/x-python"; + if (filename.endsWith(".json")) return "application/json"; + if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; + if (filename.endsWith(".txt")) return "text/plain"; + if (filename.endsWith(".md")) return "text/markdown"; + return "application/octet-stream"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java index 489928bd..e2aa41c3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java @@ -74,7 +74,7 @@ public class SkillPackageArchiveExtractor { normalizedPath, content, content.length, - SkillPackageContentTypeResolver.determineContentType(normalizedPath) + determineContentType(normalizedPath) )); zis.closeEntry(); } @@ -189,4 +189,28 @@ public class SkillPackageArchiveExtractor { return outputStream.toByteArray(); } + private String determineContentType(String filename) { + String lower = filename.toLowerCase(); + if (lower.endsWith(".py")) return "text/x-python"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".md")) return "text/markdown"; + if (lower.endsWith(".html")) return "text/html"; + if (lower.endsWith(".css")) return "text/css"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".xml")) return "application/xml"; + if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript"; + if (lower.endsWith(".ts")) return "text/typescript"; + if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".ico")) return "image/x-icon"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".toml")) return "application/toml"; + return "application/octet-stream"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java deleted file mode 100644 index 25224e96..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.iflytek.skillhub.controller.support; - -/** - * Resolves package entry content types from filenames for upload and built-in package ingestion. - */ -public final class SkillPackageContentTypeResolver { - - private SkillPackageContentTypeResolver() { - } - - public static String determineContentType(String filename) { - String lower = filename.toLowerCase(); - if (lower.endsWith(".py")) return "text/x-python"; - if (lower.endsWith(".json")) return "application/json"; - if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; - if (lower.endsWith(".txt")) return "text/plain"; - if (lower.endsWith(".md")) return "text/markdown"; - if (lower.endsWith(".html")) return "text/html"; - if (lower.endsWith(".css")) return "text/css"; - if (lower.endsWith(".csv")) return "text/csv"; - if (lower.endsWith(".xml")) return "application/xml"; - if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript"; - if (lower.endsWith(".ts")) return "text/typescript"; - if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; - if (lower.endsWith(".png")) return "image/png"; - if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; - if (lower.endsWith(".gif")) return "image/gif"; - if (lower.endsWith(".svg")) return "image/svg+xml"; - if (lower.endsWith(".webp")) return "image/webp"; - if (lower.endsWith(".ico")) return "image/x-icon"; - if (lower.endsWith(".pdf")) return "application/pdf"; - if (lower.endsWith(".toml")) return "application/toml"; - return "application/octet-stream"; - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index e9c993f6..2beaec70 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -66,7 +66,7 @@ public class ZipPackageExtractor { normalizedPath, content, content.length, - SkillPackageContentTypeResolver.determineContentType(normalizedPath) + determineContentType(normalizedPath) )); zis.closeEntry(); } @@ -119,4 +119,28 @@ public class ZipPackageExtractor { } } + private String determineContentType(String filename) { + String lower = filename.toLowerCase(); + if (lower.endsWith(".py")) return "text/x-python"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".md")) return "text/markdown"; + if (lower.endsWith(".html")) return "text/html"; + if (lower.endsWith(".css")) return "text/css"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".xml")) return "application/xml"; + if (lower.endsWith(".js")) return "text/javascript"; + if (lower.endsWith(".ts")) return "text/typescript"; + if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".ico")) return "image/x-icon"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".toml")) return "application/toml"; + return "application/octet-stream"; + } } diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 7d9a1cbc..a592b035 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -93,8 +93,6 @@ spring: enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false} skillhub: - builtin-skills: - enabled: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:true} auth: mock: enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false} diff --git a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md b/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md deleted file mode 100644 index 4cff8bc4..00000000 --- a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# SkillHub Hello - -This built-in example skill is published to `@global` when SkillHub starts. - -Use it to verify that skill discovery and CLI installation are working in a new deployment. diff --git a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md b/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md deleted file mode 100644 index 8a313b0c..00000000 --- a/server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: skillhub-hello -description: A built-in example skill that verifies SkillHub discovery and installation. -version: 1.0.0 ---- -# SkillHub Hello - -This skill is bundled with SkillHub as a minimal example for validating discovery and installation. diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java deleted file mode 100644 index 299a4ddb..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ /dev/null @@ -1,383 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -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; -import com.iflytek.skillhub.domain.skill.Skill; -import com.iflytek.skillhub.domain.skill.SkillFile; -import com.iflytek.skillhub.domain.skill.SkillFileRepository; -import com.iflytek.skillhub.domain.skill.SkillRepository; -import com.iflytek.skillhub.domain.skill.SkillVersion; -import com.iflytek.skillhub.domain.skill.SkillVersionRepository; -import com.iflytek.skillhub.domain.skill.SkillVersionStatus; -import com.iflytek.skillhub.domain.skill.SkillVisibility; -import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; -import com.iflytek.skillhub.domain.skill.service.SkillPublishService; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; -import com.iflytek.skillhub.domain.skill.validation.ValidationResult; -import com.iflytek.skillhub.domain.user.UserAccount; -import com.iflytek.skillhub.domain.user.UserAccountRepository; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.HexFormat; -import java.util.List; -import java.util.Optional; -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.boot.DefaultApplicationArguments; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.support.SimpleTransactionStatus; - -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class BuiltinSkillInitializerTest { - - private static final String PUBLISHER_ID = BuiltinSkillInitializer.BUILTIN_PUBLISHER_ID; - - @Mock private BuiltinSkillPackageLoader packageLoader; - @Mock private SkillPackageValidator packageValidator; - @Mock private SkillPublishService skillPublishService; - @Mock private NamespaceRepository namespaceRepository; - @Mock private NamespaceMemberRepository namespaceMemberRepository; - @Mock private UserAccountRepository userAccountRepository; - @Mock private SkillRepository skillRepository; - @Mock private SkillVersionRepository skillVersionRepository; - @Mock private SkillFileRepository skillFileRepository; - @Mock private PlatformTransactionManager transactionManager; - - private BuiltinSkillProperties properties; - private BuiltinSkillInitializer initializer; - private Namespace globalNamespace; - - @BeforeEach - void setUp() { - properties = new BuiltinSkillProperties(); - globalNamespace = new Namespace("global", "Global", "system"); - ReflectionTestUtils.setField(globalNamespace, "id", 1L); - lenient().when(transactionManager.getTransaction(any())).thenAnswer(ignored -> new SimpleTransactionStatus()); - lenient().when(packageValidator.validate(any())).thenReturn(ValidationResult.pass()); - - initializer = new BuiltinSkillInitializer( - properties, - packageLoader, - new SkillMetadataParser(), - packageValidator, - skillPublishService, - namespaceRepository, - namespaceMemberRepository, - userAccountRepository, - skillRepository, - skillVersionRepository, - skillFileRepository, - transactionManager - ); - } - - @Test - void disabledInitializerDoesNotLoadPackages() throws Exception { - properties.setEnabled(false); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(packageLoader, never()).loadPackages(); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); - } - - @Test - void firstStartupCreatesPublisherMembershipAndPublishesPackage() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.empty()); - when(skillPublishService.publishFromEntries( - eq("global"), - eq(skillPackage.entries()), - eq(PUBLISHER_ID), - eq(SkillVisibility.PUBLIC), - eq(Set.of("SUPER_ADMIN")), - eq(false) - )).thenReturn(publishResult("1.0.0")); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(userAccountRepository).save(any(UserAccount.class)); - verify(namespaceMemberRepository).save(any(NamespaceMember.class)); - verify(skillPublishService).publishFromEntries( - "global", - skillPackage.entries(), - PUBLISHER_ID, - SkillVisibility.PUBLIC, - Set.of("SUPER_ADMIN"), - false - ); - } - - @Test - void existingPublisherAndMembershipAreReusedIdempotently() throws Exception { - UserAccount existingPublisher = new UserAccount(PUBLISHER_ID, "Old name", "old@example.invalid", null); - NamespaceMember existingMember = new NamespaceMember(1L, PUBLISHER_ID, NamespaceRole.MEMBER); - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(globalNamespace)); - when(userAccountRepository.findById(PUBLISHER_ID)).thenReturn(Optional.of(existingPublisher)); - when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER_ID)) - .thenReturn(Optional.of(existingMember)); - when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(packageLoader.loadPackages()).thenReturn(List.of()); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(userAccountRepository).save(existingPublisher); - verify(namespaceMemberRepository).save(existingMember); - org.assertj.core.api.Assertions.assertThat(existingMember.getRole()).isEqualTo(NamespaceRole.OWNER); - } - - @Test - void validationWarningsSkipPublishWithoutConfirmingWarnings() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(packageValidator.validate(skillPackage.entries())) - .thenReturn(new ValidationResult(true, List.of(), List.of("warning"))); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); - } - - @Test - void samePublishedVersionWithSameFingerprintSkipsPublish() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - Skill skill = builtInSkill(11L); - SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill)); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.of(skill)); - when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); - when(skillFileRepository.findByVersionId(22L)).thenReturn(filesFor(version.getId(), skillPackage.entries())); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); - } - - @Test - void samePublishedVersionWithDifferentFingerprintSkipsPublish() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - Skill skill = builtInSkill(11L); - SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill)); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.of(skill)); - when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); - when(skillFileRepository.findByVersionId(22L)).thenReturn(List.of( - new SkillFile(22L, "SKILL.md", 10L, "text/markdown", "different", "key") - )); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); - } - - @Test - void sameNonPublishedVersionSkipsPublish() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - Skill skill = builtInSkill(11L); - SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.UPLOADED); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill)); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.of(skill)); - when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillFileRepository, never()).findByVersionId(any()); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); - } - - @Test - void newerBuiltInVersionPublishesWhenOlderVersionExists() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.1", "Hello"); - Skill skill = builtInSkill(11L); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill)); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.of(skill)); - when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.1")).thenReturn(Optional.empty()); - when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false))) - .thenReturn(publishResult("1.0.1")); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillPublishService).publishFromEntries( - "global", - skillPackage.entries(), - PUBLISHER_ID, - SkillVisibility.PUBLIC, - Set.of("SUPER_ADMIN"), - false - ); - } - - @Test - void userOwnedPublishedSlugSkipsBuiltInPublish() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - Skill otherSkill = new Skill(1L, "skillhub-hello", "user-1", SkillVisibility.PUBLIC); - ReflectionTestUtils.setField(otherSkill, "id", 33L); - SkillVersion otherPublishedVersion = version(33L, 44L, "1.0.0", SkillVersionStatus.PUBLISHED); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); - when(skillVersionRepository.findBySkillIdAndStatus(33L, SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of(otherPublishedVersion)); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillRepository, never()).findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); - } - - @Test - void publishFailureIsContainedAndDoesNotAbortStartup() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.empty()); - when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false))) - .thenThrow(new IllegalStateException("storage unavailable")); - - assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0]))) - .doesNotThrowAnyException(); - } - - @Test - void concurrentPublishedSameFingerprintSkipsAfterPublishFailure() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - Skill skill = builtInSkill(11L); - SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.empty(), Optional.of(skill)); - when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false))) - .thenThrow(new IllegalStateException("version exists")); - when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); - when(skillFileRepository.findByVersionId(22L)).thenReturn(filesFor(version.getId(), skillPackage.entries())); - - assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0]))) - .doesNotThrowAnyException(); - } - - @Test - void concurrentPublishedDifferentFingerprintSkipsAfterPublishFailure() throws Exception { - BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello"); - Skill skill = builtInSkill(11L); - SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED); - setupPublisher(); - when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage)); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); - when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID)) - .thenReturn(Optional.empty(), Optional.of(skill)); - when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false))) - .thenThrow(new IllegalStateException("version exists")); - when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); - when(skillFileRepository.findByVersionId(22L)).thenReturn(List.of( - new SkillFile(22L, "SKILL.md", 10L, "text/markdown", "different", "key") - )); - - assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0]))) - .doesNotThrowAnyException(); - } - - private void setupPublisher() { - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(globalNamespace)); - when(userAccountRepository.findById(PUBLISHER_ID)).thenReturn(Optional.empty()); - when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER_ID)).thenReturn(Optional.empty()); - when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0)); - } - - private BuiltinSkillPackageLoader.BuiltinSkillPackage packageWithVersion(String version, String body) { - byte[] skillMd = (""" - --- - name: skillhub-hello - description: SkillHub hello - version: %s - --- - # SkillHub Hello - %s - """.formatted(version, body)).getBytes(StandardCharsets.UTF_8); - byte[] readme = "# SkillHub Hello\n".getBytes(StandardCharsets.UTF_8); - return new BuiltinSkillPackageLoader.BuiltinSkillPackage("skillhub-hello", List.of( - new PackageEntry("README.md", readme, readme.length, "text/markdown"), - new PackageEntry("SKILL.md", skillMd, skillMd.length, "text/markdown") - )); - } - - private Skill builtInSkill(Long id) { - Skill skill = new Skill(1L, "skillhub-hello", PUBLISHER_ID, SkillVisibility.PUBLIC); - ReflectionTestUtils.setField(skill, "id", id); - return skill; - } - - private SkillVersion version(Long skillId, Long versionId, String version, SkillVersionStatus status) { - SkillVersion skillVersion = new SkillVersion(skillId, version, PUBLISHER_ID); - skillVersion.setStatus(status); - ReflectionTestUtils.setField(skillVersion, "id", versionId); - return skillVersion; - } - - private SkillPublishService.PublishResult publishResult(String version) { - SkillVersion skillVersion = version(11L, 22L, version, SkillVersionStatus.PUBLISHED); - return new SkillPublishService.PublishResult(11L, "skillhub-hello", skillVersion); - } - - private List filesFor(Long versionId, List entries) { - return entries.stream() - .map(entry -> new SkillFile( - versionId, - entry.path(), - entry.size(), - entry.contentType(), - sha256(entry.content()), - "skills/11/" + versionId + "/" + entry.path() - )) - .toList(); - } - - private String sha256(byte[] content) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - return HexFormat.of().formatHex(digest.digest(content)); - } catch (Exception exception) { - throw new IllegalStateException(exception); - } - } -} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java deleted file mode 100644 index 143da879..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; -import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; -import java.net.URLClassLoader; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.jar.JarEntry; -import java.util.jar.JarOutputStream; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.springframework.core.io.support.PathMatchingResourcePatternResolver; - -import static org.assertj.core.api.Assertions.assertThat; - -class BuiltinSkillPackageLoaderTest { - - @TempDir - Path tempDir; - - @Test - void loadsSkillhubHelloFromClasspath() throws Exception { - BuiltinSkillPackageLoader loader = - new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver()); - - var packages = loader.loadPackages(); - - var skillhubHello = packages.stream() - .filter(skillPackage -> skillPackage.directory().equals("skillhub-hello")) - .findFirst() - .orElseThrow(); - assertThat(skillhubHello.entries()) - .extracting(entry -> entry.path()) - .containsExactly("README.md", "SKILL.md"); - assertThat(skillhubHello.entries()) - .allSatisfy(entry -> assertThat(entry.contentType()).isEqualTo("text/markdown")); - } - - @Test - void productionSkillhubHelloResourcePassesPackageValidation() throws Exception { - BuiltinSkillPackageLoader loader = - new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver()); - SkillPackageValidator validator = new SkillPackageValidator(new SkillMetadataParser()); - - var skillhubHello = loader.loadPackages().stream() - .filter(skillPackage -> skillPackage.directory().equals("skillhub-hello")) - .findFirst() - .orElseThrow(); - - var result = validator.validate(skillhubHello.entries()); - assertThat(result.passed()).isTrue(); - assertThat(result.warnings()).isEmpty(); - } - - @Test - void loadsBuiltInPackageFromJarClasspath() throws Exception { - Path jarPath = tempDir.resolve("builtin-skills.jar"); - try (JarOutputStream jarOutputStream = new JarOutputStream(Files.newOutputStream(jarPath))) { - writeJarDirectory(jarOutputStream, "builtin-skills/"); - writeJarDirectory(jarOutputStream, "builtin-skills/skillhub-hello/"); - writeJarEntry(jarOutputStream, "builtin-skills/skillhub-hello/SKILL.md", """ - --- - name: skillhub-hello - description: SkillHub hello - version: 1.0.0 - --- - # SkillHub Hello - """); - writeJarEntry(jarOutputStream, "builtin-skills/skillhub-hello/README.md", "# SkillHub Hello\n"); - } - - try (URLClassLoader classLoader = new URLClassLoader( - new java.net.URL[]{jarPath.toUri().toURL()}, - null - )) { - BuiltinSkillPackageLoader loader = - new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver(classLoader)); - - var packages = loader.loadPackages(); - - assertThat(packages).hasSize(1); - assertThat(packages.getFirst().directory()).isEqualTo("skillhub-hello"); - assertThat(packages.getFirst().entries()) - .extracting(entry -> entry.path()) - .containsExactly("README.md", "SKILL.md"); - } - } - - @Test - void fingerprintsAreStableAcrossEntryOrdering() { - byte[] skillMd = """ - --- - name: demo - description: Demo - version: 1.0.0 - --- - # Demo - """.getBytes(java.nio.charset.StandardCharsets.UTF_8); - byte[] readme = "# Demo\n".getBytes(java.nio.charset.StandardCharsets.UTF_8); - var first = List.of( - new com.iflytek.skillhub.domain.skill.validation.PackageEntry( - "SKILL.md", skillMd, skillMd.length, "text/markdown"), - new com.iflytek.skillhub.domain.skill.validation.PackageEntry( - "README.md", readme, readme.length, "text/markdown") - ); - var second = List.of(first.get(1), first.get(0)); - - assertThat(BuiltinSkillFingerprints.fromEntries(first)) - .isEqualTo(BuiltinSkillFingerprints.fromEntries(second)); - } - - private void writeJarDirectory(JarOutputStream jarOutputStream, String name) throws Exception { - jarOutputStream.putNextEntry(new JarEntry(name)); - jarOutputStream.closeEntry(); - } - - private void writeJarEntry(JarOutputStream jarOutputStream, String name, String content) throws Exception { - jarOutputStream.putNextEntry(new JarEntry(name)); - jarOutputStream.write(content.getBytes(StandardCharsets.UTF_8)); - jarOutputStream.closeEntry(); - } -} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java deleted file mode 100644 index 04461428..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.iflytek.skillhub.bootstrap; - -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.springframework.boot.context.properties.bind.Binder; -import org.springframework.boot.context.properties.source.ConfigurationPropertySources; -import org.springframework.boot.env.YamlPropertySourceLoader; -import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.StandardEnvironment; -import org.springframework.core.env.SystemEnvironmentPropertySource; -import org.springframework.core.io.ClassPathResource; - -import static org.assertj.core.api.Assertions.assertThat; - -class BuiltinSkillPropertiesBindingTest { - - @Test - void defaultConfigEnablesBuiltinSkills() throws Exception { - BuiltinSkillProperties properties = bindProperties(Map.of()); - - assertThat(properties.isEnabled()).isTrue(); - } - - @Test - void environmentVariableCanDisableBuiltinSkills() throws Exception { - BuiltinSkillProperties properties = bindProperties(Map.of("SKILLHUB_BUILTIN_SKILLS_ENABLED", "false")); - - assertThat(properties.isEnabled()).isFalse(); - } - - private BuiltinSkillProperties bindProperties(Map envVars) throws Exception { - ConfigurableEnvironment environment = new StandardEnvironment(); - environment.getPropertySources().addFirst(new SystemEnvironmentPropertySource("test-env", envVars)); - - YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); - for (org.springframework.core.env.PropertySource propertySource : - loader.load("application.yml", new ClassPathResource("application.yml"))) { - environment.getPropertySources().addLast(propertySource); - } - ConfigurationPropertySources.attach(environment); - - return Binder.get(environment) - .bind("skillhub.builtin-skills", BuiltinSkillProperties.class) - .orElseThrow(() -> new IllegalStateException("Failed to bind built-in skill properties")); - } -} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java deleted file mode 100644 index 90166104..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.iflytek.skillhub.controller.support; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class SkillPackageContentTypeResolverTest { - - @Test - void determinesKnownContentTypes() { - assertThat(SkillPackageContentTypeResolver.determineContentType("SKILL.md")).isEqualTo("text/markdown"); - assertThat(SkillPackageContentTypeResolver.determineContentType("script.py")).isEqualTo("text/x-python"); - assertThat(SkillPackageContentTypeResolver.determineContentType("config.yaml")).isEqualTo("application/x-yaml"); - assertThat(SkillPackageContentTypeResolver.determineContentType("image.png")).isEqualTo("image/png"); - assertThat(SkillPackageContentTypeResolver.determineContentType("archive.bin")).isEqualTo("application/octet-stream"); - } -} From 04caf21e76b2564bd60bfb3a6b325b9b5cb7938a Mon Sep 17 00:00:00 2001 From: dongmucat <70678707+dongmucat@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:28:12 +0800 Subject: [PATCH 05/19] fix(audit): resolve 8-hour timezone offset in audit log timestamps (#472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Audit log timestamps displayed 8 hours later than actual time when JVM default timezone != UTC. Root cause: `audit_log.created_at` was `TIMESTAMP without time zone`, and `rs.getTimestamp()` interprets bare values using JVM timezone. ## Solution ### Backend - **V42 migration**: Upgrade `audit_log.created_at` from `TIMESTAMP` to `TIMESTAMPTZ`, anchor historical data as UTC via `USING created_at AT TIME ZONE 'UTC'` (same pattern as V18/V19/V23/V25/V36) - **Read path**: `AdminAuditLogAppService.readInstant()` uses `rs.getObject(col, OffsetDateTime.class).toInstant()`, result independent of JVM timezone - **Write path (filter params)**: `startTime`/`endTime` binding changed from `Timestamp.from()` to `OffsetDateTime.ofInstant(instant, ZoneOffset.UTC)` via `toUtcOffsetDateTime()` helper, symmetric with read path ### Migration Safety - `SET LOCAL lock_timeout = '30s'` (transaction-scoped, won't leak to pool) - `DO $$ ... IF data_type = 'timestamp without time zone' THEN ... ELSE ... END $$` idempotent guard with dual-branch `RAISE NOTICE` - Safe retry: re-running won't double-apply `AT TIME ZONE 'UTC'` ### Test Coverage (10 tests, 477 total suite) - `rowMapper_readsCreatedAtAsInstant` — UTC offset regression - `rowMapper_normalisesNonUtcOffsetToInstant` — Non-UTC offset (+08:00) - `rowMapper_returnsNullTimestampWhenColumnIsNull` — Null path - `rowMapper_isIndependentOfJvmDefaultTimezone` — JVM TZ=Asia/Shanghai drift prevention with `verify(rs, never()).getTimestamp()` - `@ParameterizedTest buildWhereClause_bindsTimeRangeAsOffsetDateTime` — 3 cases (both/startOnly/endOnly) for filter param binding - `@BeforeEach setUp()` — Mock isolation to prevent cross-test stub accumulation ## Quality Gates - [x] `make test-backend-app` passes (477 tests, 0 failures) - [x] No Controller changes, `make generate-api` not needed - [x] No frontend changes, typecheck/lint/e2e not needed ## Deployment V42 must run before new code (guaranteed by Spring Boot startup sequence → Flyway executes before app accepts traffic). Rolling deployment: - New pod + migrated column: correct - Old pod + migrated column: old code reads TIMESTAMPTZ correctly (pgjdbc returns absolute instant) ## Related Docs - `docs/15-backend-time-governance-plan.md` §3.1: V42 progress registered - `docs/16-backend-time-inventory.md` §3.1: V42 listed - Same migration pattern: V18/V19/V23/V25/V36 --- docs/15-backend-time-governance-plan.md | 4 + docs/16-backend-time-inventory.md | 2 + .../service/AdminAuditLogAppService.java | 24 +++- .../V42__audit_log_created_at_timestamptz.sql | 39 +++++ .../service/AdminAuditLogAppServiceTest.java | 135 +++++++++++++++++- 5 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V42__audit_log_created_at_timestamptz.sql diff --git a/docs/15-backend-time-governance-plan.md b/docs/15-backend-time-governance-plan.md index 17952e50..6db6eac6 100644 --- a/docs/15-backend-time-governance-plan.md +++ b/docs/15-backend-time-governance-plan.md @@ -57,6 +57,10 @@ - 数据库列统一为 `TIMESTAMPTZ` - 读写都按 UTC 绝对时间处理 +进度登记: + +- `audit_log.created_at` 已通过 V42 迁移到 `TIMESTAMPTZ`,详见 `docs/16-backend-time-inventory.md` §3.1 + ### 3.2 业务输入时间 适用场景: diff --git a/docs/16-backend-time-inventory.md b/docs/16-backend-time-inventory.md index 565b444f..d0c8bfcb 100644 --- a/docs/16-backend-time-inventory.md +++ b/docs/16-backend-time-inventory.md @@ -131,6 +131,8 @@ - `review_task.submitted_at / reviewed_at` - `promotion_request.submitted_at / reviewed_at` - `idempotency_record.created_at / expires_at` +- `V42__audit_log_created_at_timestamptz.sql` + - `audit_log.created_at` ### 3.2 当前状态 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java index 0de4d935..4ca927b7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java @@ -8,8 +8,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import java.sql.Timestamp; +import java.sql.ResultSet; +import java.sql.SQLException; import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.Collection; import java.util.List; @@ -109,7 +112,7 @@ public class AdminAuditLogAppService { rs.getString("request_id"), rs.getString("target_type"), toResourceId(rs.getObject("target_id")), - toInstant(rs.getTimestamp("created_at"))) + readInstant(rs, "created_at")) ); return new PageResponse<>(items, total == null ? 0 : total, page, size); @@ -151,15 +154,21 @@ public class AdminAuditLogAppService { } if (startTime != null) { clause.append(" AND al.created_at >= :startTime"); - parameters.addValue("startTime", Timestamp.from(startTime)); + parameters.addValue("startTime", toUtcOffsetDateTime(startTime)); } if (endTime != null) { clause.append(" AND al.created_at <= :endTime"); - parameters.addValue("endTime", Timestamp.from(endTime)); + parameters.addValue("endTime", toUtcOffsetDateTime(endTime)); } return clause.toString(); } + // Bind via OffsetDateTime so pgjdbc sends a TIMESTAMPTZ literal anchored to UTC, + // bypassing JVM-default-timezone interpretation that caused the 8h-offset bug. + private static OffsetDateTime toUtcOffsetDateTime(Instant instant) { + return OffsetDateTime.ofInstant(instant, ZoneOffset.UTC); + } + private String renderDetails(String detailJson, String targetType, Object targetId) { if (StringUtils.hasText(detailJson)) { return detailJson; @@ -170,8 +179,11 @@ public class AdminAuditLogAppService { return targetType + ":" + targetId; } - private Instant toInstant(Timestamp timestamp) { - return timestamp == null ? null : timestamp.toInstant(); + // Read via getObject(OffsetDateTime.class) to bypass JVM-TZ interpretation + // that caused the 8h-offset bug (getTimestamp() applies JVM default TZ). + private static Instant readInstant(ResultSet rs, String column) throws SQLException { + OffsetDateTime odt = rs.getObject(column, OffsetDateTime.class); + return odt == null ? null : odt.toInstant(); } private String toResourceId(Object targetId) { diff --git a/server/skillhub-app/src/main/resources/db/migration/V42__audit_log_created_at_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V42__audit_log_created_at_timestamptz.sql new file mode 100644 index 00000000..5939f144 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V42__audit_log_created_at_timestamptz.sql @@ -0,0 +1,39 @@ +-- Fix audit_log.created_at timezone issue +-- Background: TIMESTAMP (without timezone) causes 8-hour offset when JVM timezone != UTC +-- Solution: Upgrade to TIMESTAMPTZ and anchor existing data as UTC +-- Related: docs/15-backend-time-governance-plan.md section 3.1 +-- +-- Operational notes: +-- * ALTER COLUMN ... TYPE rewrites the entire audit_log table and rebuilds +-- idx_audit_log_created_at, idx_audit_log_actor_time, idx_audit_log_action_time +-- under ACCESS EXCLUSIVE lock. Run during a low-traffic window. +-- * Before applying in production, check table size: +-- SELECT pg_size_pretty(pg_total_relation_size('audit_log')); +-- Tables in the multi-GB range may need a maintenance window. +-- * SET LOCAL lock_timeout below makes a contended ALTER fail fast (rather than +-- queueing behind long-running readers); operators may re-run the migration +-- after clearing contention. The DO block guards against re-running on a +-- column that has already been migrated, so retries are safe. + +SET LOCAL lock_timeout = '30s'; + +DO $$ +DECLARE + current_type text; +BEGIN + SELECT data_type + INTO current_type + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'audit_log' + AND column_name = 'created_at'; + + IF current_type = 'timestamp without time zone' THEN + ALTER TABLE audit_log + ALTER COLUMN created_at TYPE TIMESTAMPTZ + USING created_at AT TIME ZONE 'UTC'; + RAISE NOTICE 'V42: audit_log.created_at -> TIMESTAMPTZ (UTC anchored)'; + ELSE + RAISE NOTICE 'V42: audit_log.created_at already % (skipped)', current_type; + END IF; +END $$; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java index 2b2b255e..3687f414 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java @@ -2,13 +2,21 @@ package com.iflytek.skillhub.service; import com.iflytek.skillhub.dto.AuditLogItemResponse; import com.iflytek.skillhub.dto.PageResponse; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import java.sql.ResultSet; import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.List; +import java.util.TimeZone; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; @@ -16,8 +24,14 @@ import static org.mockito.Mockito.*; class AdminAuditLogAppServiceTest { - private final NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class); - private final AdminAuditLogAppService service = new AdminAuditLogAppService(jdbcTemplate); + private NamedParameterJdbcTemplate jdbcTemplate; + private AdminAuditLogAppService service; + + @BeforeEach + void setUp() { + jdbcTemplate = mock(NamedParameterJdbcTemplate.class); + service = new AdminAuditLogAppService(jdbcTemplate); + } @Test void listAuditLogs_returnsJdbcBackedPage() { @@ -62,4 +76,121 @@ class AdminAuditLogAppServiceTest { any(MapSqlParameterSource.class), any(RowMapper.class)); } + + /** + * Regression for the 8-hour offset bug: row mapper must read created_at via + * getObject(OffsetDateTime.class) so the returned Instant is independent of + * the JVM default timezone. + */ + @Test + void rowMapper_readsCreatedAtAsInstant() throws Exception { + RowMapper rowMapper = captureRowMapper(); + ResultSet rs = stubRowWithCreatedAt( + OffsetDateTime.of(2026, 5, 29, 8, 53, 0, 0, ZoneOffset.UTC)); + + AuditLogItemResponse item = rowMapper.mapRow(rs, 0); + + assertThat(item).isNotNull(); + assertThat(item.timestamp()).isEqualTo(Instant.parse("2026-05-29T08:53:00Z")); + verify(rs, never()).getTimestamp(anyString()); + } + + @Test + void rowMapper_normalisesNonUtcOffsetToInstant() throws Exception { + RowMapper rowMapper = captureRowMapper(); + ResultSet rs = stubRowWithCreatedAt( + OffsetDateTime.of(2026, 5, 29, 16, 53, 0, 0, ZoneOffset.ofHours(8))); + + AuditLogItemResponse item = rowMapper.mapRow(rs, 0); + + assertThat(item.timestamp()).isEqualTo(Instant.parse("2026-05-29T08:53:00Z")); + } + + @Test + void rowMapper_returnsNullTimestampWhenColumnIsNull() throws Exception { + RowMapper rowMapper = captureRowMapper(); + ResultSet rs = stubRowWithCreatedAt(null); + + AuditLogItemResponse item = rowMapper.mapRow(rs, 0); + + assertThat(item.timestamp()).isNull(); + } + + @ParameterizedTest + @CsvSource(nullValues = "NULL", value = { + "2026-03-13T00:00:00Z, 2026-03-14T00:00:00Z", + "2026-03-13T00:00:00Z, NULL", + "NULL, 2026-03-14T00:00:00Z" + }) + void buildWhereClause_bindsTimeRangeAsOffsetDateTime(String startStr, String endStr) { + when(jdbcTemplate.queryForObject(contains("COUNT(*)"), any(MapSqlParameterSource.class), eq(Long.class))) + .thenReturn(0L); + when(jdbcTemplate.query(contains("FROM audit_log"), any(MapSqlParameterSource.class), any(RowMapper.class))) + .thenReturn(List.of()); + Instant startTime = startStr == null ? null : Instant.parse(startStr); + Instant endTime = endStr == null ? null : Instant.parse(endStr); + + service.listAuditLogs(0, 20, null, null, null, null, null, null, startTime, endTime); + + ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(MapSqlParameterSource.class); + verify(jdbcTemplate).query(contains("FROM audit_log"), paramsCaptor.capture(), any(RowMapper.class)); + MapSqlParameterSource params = paramsCaptor.getValue(); + if (startTime != null) { + assertThat(params.getValue("startTime")) + .isEqualTo(OffsetDateTime.ofInstant(startTime, ZoneOffset.UTC)); + } else { + assertThat(params.hasValue("startTime")).isFalse(); + } + if (endTime != null) { + assertThat(params.getValue("endTime")) + .isEqualTo(OffsetDateTime.ofInstant(endTime, ZoneOffset.UTC)); + } else { + assertThat(params.hasValue("endTime")).isFalse(); + } + } + + @Test + void rowMapper_isIndependentOfJvmDefaultTimezone() throws Exception { + TimeZone original = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + RowMapper rowMapper = captureRowMapper(); + ResultSet rs = stubRowWithCreatedAt( + OffsetDateTime.of(2026, 5, 29, 8, 53, 0, 0, ZoneOffset.UTC)); + + AuditLogItemResponse item = rowMapper.mapRow(rs, 0); + + assertThat(item).isNotNull(); + assertThat(item.timestamp()).isEqualTo(Instant.parse("2026-05-29T08:53:00Z")); + verify(rs, never()).getTimestamp(anyString()); + } finally { + TimeZone.setDefault(original); + } + } + + @SuppressWarnings("unchecked") + private RowMapper captureRowMapper() { + when(jdbcTemplate.queryForObject(contains("COUNT(*)"), any(MapSqlParameterSource.class), eq(Long.class))) + .thenReturn(0L); + ArgumentCaptor> captor = ArgumentCaptor.forClass(RowMapper.class); + when(jdbcTemplate.query(contains("FROM audit_log"), any(MapSqlParameterSource.class), captor.capture())) + .thenReturn(List.of()); + service.listAuditLogs(0, 20, null, null, null, null, null, null, null, null); + return captor.getValue(); + } + + private static ResultSet stubRowWithCreatedAt(OffsetDateTime createdAt) throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(1L); + when(rs.getString("action")).thenReturn("PROMOTION_SUBMIT"); + when(rs.getString("actor_user_id")).thenReturn("user-1"); + when(rs.getString("display_name")).thenReturn("alice"); + when(rs.getString("detail_json")).thenReturn("{}"); + when(rs.getString("target_type")).thenReturn("PROMOTION"); + when(rs.getObject("target_id")).thenReturn(42L); + when(rs.getString("client_ip")).thenReturn("127.0.0.1"); + when(rs.getString("request_id")).thenReturn("req-1"); + when(rs.getObject("created_at", OffsetDateTime.class)).thenReturn(createdAt); + return rs; + } } From 2bb7dedaf4057bd0062ff8e3f04a35fee05c174e Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:20:34 +0800 Subject: [PATCH 06/19] feat(cli,domain): support non-global namespace skill download (#497) * feat(cli,domain): support non-global namespace skill download Parse namespace from skill name using -- separator (e.g., astroclaw--api-gateway) so users don't need --namespace flag. Allow anonymous download for any PUBLIC skill regardless of namespace. CLI changes: - Add cli/src/shared/skill-name-parser.ts utility - Update install and remove commands to parse skill name argument - 10 unit tests covering edge cases Domain changes: - SkillDownloadService.isAnonymousDownloadAllowed: drop namespace type check, only require PUBLIC visibility - Update test to expect success for team-namespace public skill Synced from SAAS commit 26c67e31b1221249cf9b73321d1b726d8ba6e6df * fix(cli): use bun:test instead of vitest in skill-name-parser test --- cli/src/commands/install.ts | 8 +- cli/src/commands/remove.ts | 8 +- cli/src/shared/skill-name-parser.ts | 27 ++++++ .../unit/shared/skill-name-parser.test.ts | 90 +++++++++++++++++++ .../skill/service/SkillDownloadService.java | 8 +- .../service/SkillDownloadServiceTest.java | 26 ++++-- 6 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 cli/src/shared/skill-name-parser.ts create mode 100644 cli/test/unit/shared/skill-name-parser.test.ts diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index e9937a7b..0feed791 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -5,6 +5,7 @@ import { installSkill } from '../services/install-service' import { resolveInstallTargets } from '../agents/resolver' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' +import { parseSkillName } from '../shared/skill-name-parser' export interface InstallCommandOptions { namespace?: string | undefined @@ -74,7 +75,7 @@ async function defaultPromptScope(): Promise<'user' | 'project'> { } export async function installCommand( - slug: string, + skillNameArg: string, options: InstallCommandOptions, deps: InstallCommandDeps = {} ): Promise { @@ -92,7 +93,10 @@ export async function installCommand( const credentialsStore = new CredentialsStore() const registry = resolveRegistry(options, process.env, await configStore.read()) const token = resolveToken(options, process.env, await credentialsStore.getToken(registry)) - const namespace = options.namespace ?? 'global' + + const parsed = parseSkillName(skillNameArg) + const namespace = options.namespace ?? parsed.namespace + const slug = parsed.slug const resolveTargets = deps.resolveInstallTargets ?? resolveInstallTargets const targets = await resolveTargets({ diff --git a/cli/src/commands/remove.ts b/cli/src/commands/remove.ts index 67f47f1d..4e8543b7 100644 --- a/cli/src/commands/remove.ts +++ b/cli/src/commands/remove.ts @@ -5,6 +5,7 @@ import { resolveRegistry, resolveToken } from '../services/registry-service' import { removeLocalSkill } from '../services/remove-service' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' +import { parseSkillName } from '../shared/skill-name-parser' export interface RemoveCommandOptions { agent?: string[] | undefined @@ -17,7 +18,7 @@ export interface RemoveCommandOptions { json?: boolean | undefined } -export async function removeCommand(slug: string, options: RemoveCommandOptions): Promise { +export async function removeCommand(skillNameArg: string, options: RemoveCommandOptions): Promise { if (options.all && options.agent?.length) { throw new CliError('--all cannot be used with --agent', EXIT.usage) } @@ -29,9 +30,12 @@ export async function removeCommand(slug: string, options: RemoveCommandOptions) const credentialsStore = new CredentialsStore() const registry = resolveRegistry(options, process.env, await configStore.read()) + const parsed = parseSkillName(skillNameArg) + const namespace = options.namespace ?? parsed.namespace + const slug = parsed.slug + if (options.remote) { const token = resolveToken(options, process.env, await credentialsStore.getToken(registry)) - const namespace = options.namespace ?? 'global' if (!options.hard && process.stdout.isTTY) { const prompts = await import('prompts') diff --git a/cli/src/shared/skill-name-parser.ts b/cli/src/shared/skill-name-parser.ts new file mode 100644 index 00000000..05e0662b --- /dev/null +++ b/cli/src/shared/skill-name-parser.ts @@ -0,0 +1,27 @@ +export interface ParsedSkillName { + namespace: string + slug: string +} + +export function parseSkillName(skillName: string, defaultNamespace = 'global'): ParsedSkillName { + const separatorIndex = skillName.indexOf('--') + + if (separatorIndex <= 0) { + return { + namespace: defaultNamespace, + slug: separatorIndex === 0 ? skillName.slice(2) : skillName + } + } + + if (separatorIndex === skillName.length - 2) { + return { + namespace: defaultNamespace, + slug: skillName.slice(0, -2) + } + } + + return { + namespace: skillName.slice(0, separatorIndex), + slug: skillName.slice(separatorIndex + 2) + } +} diff --git a/cli/test/unit/shared/skill-name-parser.test.ts b/cli/test/unit/shared/skill-name-parser.test.ts new file mode 100644 index 00000000..b86771ce --- /dev/null +++ b/cli/test/unit/shared/skill-name-parser.test.ts @@ -0,0 +1,90 @@ +import { describe, test, expect } from 'bun:test' +import { parseSkillName } from '../../../src/shared/skill-name-parser' + +describe('parseSkillName', () => { + describe('with namespace--slug format', () => { + test('should parse namespace and slug separated by double dash', () => { + const result = parseSkillName('astroclaw--api-gateway') + expect(result).toEqual({ + namespace: 'astroclaw', + slug: 'api-gateway' + }) + }) + + test('should handle namespace and slug with single dashes', () => { + const result = parseSkillName('my-org--my-skill-name') + expect(result).toEqual({ + namespace: 'my-org', + slug: 'my-skill-name' + }) + }) + + test('should handle multiple double dashes by using first as separator', () => { + const result = parseSkillName('namespace--slug--with--dashes') + expect(result).toEqual({ + namespace: 'namespace', + slug: 'slug--with--dashes' + }) + }) + }) + + describe('with slug only format', () => { + test('should use default namespace when no separator present', () => { + const result = parseSkillName('api-gateway') + expect(result).toEqual({ + namespace: 'global', + slug: 'api-gateway' + }) + }) + + test('should use custom default namespace when provided', () => { + const result = parseSkillName('api-gateway', 'myorg') + expect(result).toEqual({ + namespace: 'myorg', + slug: 'api-gateway' + }) + }) + + test('should handle slug with single dashes', () => { + const result = parseSkillName('my-skill-name') + expect(result).toEqual({ + namespace: 'global', + slug: 'my-skill-name' + }) + }) + }) + + describe('edge cases', () => { + test('should handle separator at start', () => { + const result = parseSkillName('--api-gateway') + expect(result).toEqual({ + namespace: 'global', + slug: 'api-gateway' + }) + }) + + test('should handle separator at end', () => { + const result = parseSkillName('astroclaw--') + expect(result).toEqual({ + namespace: 'global', + slug: 'astroclaw' + }) + }) + + test('should handle empty string', () => { + const result = parseSkillName('') + expect(result).toEqual({ + namespace: 'global', + slug: '' + }) + }) + + test('should handle just separator', () => { + const result = parseSkillName('--') + expect(result).toEqual({ + namespace: 'global', + slug: '' + }) + }) + }) +}) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 3bb194ff..6a62b749 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -4,7 +4,6 @@ import com.iflytek.skillhub.domain.event.SkillDownloadedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -268,7 +267,7 @@ public class SkillDownloadService { Skill skill, String currentUserId, Map userNsRoles) { - if (currentUserId == null && !isAnonymousDownloadAllowed(namespace, skill)) { + if (currentUserId == null && !isAnonymousDownloadAllowed(skill)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { @@ -276,9 +275,8 @@ public class SkillDownloadService { } } - private boolean isAnonymousDownloadAllowed(Namespace namespace, Skill skill) { - return namespace.getType() == NamespaceType.GLOBAL - && skill.getVisibility() == SkillVisibility.PUBLIC; + private boolean isAnonymousDownloadAllowed(Skill skill) { + return skill.getVisibility() == SkillVisibility.PUBLIC; } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index ba24003b..4e8251d1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -318,26 +318,38 @@ class SkillDownloadServiceTest { } @Test - void testDownloadVersion_RejectsAnonymousForTeamNamespacePublicSkill() throws Exception { + void testDownloadVersion_AllowsAnonymousForTeamNamespacePublicSkill() throws Exception { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); setId(namespace, 2L); namespace.setType(NamespaceType.TEAM); Skill skill = new Skill(2L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setDisplayName("Demo Skill"); skill.setStatus(SkillStatus.ACTIVE); skill.setLatestVersionId(10L); + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(2L, "demo-skill")).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(version)); + when(objectStorageService.exists("packages/1/10/bundle.zip")).thenReturn(false); + when(skillFileRepository.findByVersionId(10L)).thenReturn(List.of( + new SkillFile(10L, "SKILL.md", 4L, "text/markdown", "hash", "skills/1/10/SKILL.md"))); + when(objectStorageService.exists("skills/1/10/SKILL.md")).thenReturn(true); + when(objectStorageService.getObject("skills/1/10/SKILL.md")).thenReturn(new ByteArrayInputStream("test".getBytes())); - assertThrows(DomainForbiddenException.class, () -> - service.downloadVersion("team-ai", "demo-skill", "1.0.0", null, Map.of())); + SkillDownloadService.DownloadResult result = service.downloadVersion("team-ai", "demo-skill", "1.0.0", null, Map.of()); - verify(visibilityChecker, never()).canAccess(any(), any(), anyMap()); - verify(skillRepository, never()).incrementDownloadCount(anyLong()); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); - verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + assertNotNull(result); + assertEquals("Demo Skill-1.0.0.zip", result.filename()); + verify(skillRepository).incrementDownloadCount(1L); + verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L); + verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } private void setId(Object entity, Long id) throws Exception { From 31b25fb6c56890e1f19034c58fe9d2f5a13397c7 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:21:33 +0800 Subject: [PATCH 07/19] fix(domain): clear skill.latest_version_id before deleting skill_version (#495) The PG FK constraint fk_skill_latest_version blocks deleting a SkillVersion whenever Skill.latest_version_id still references it. Two services had the wrong order: - SkillPublishService.deleteReplaceableVersionArtifacts: triggered when re-uploading the same version (UPLOADED -> overwritten). Reproduced by AstronClaw client retrying personal-skills upload. - SkillGovernanceService.deleteVersion: triggered when admin deletes a draft version that happens to be skill.latest_version_id. Fix: clear skill.latest_version_id and flush BEFORE deleting the SkillVersion row, so PG sees no live reference at delete time. Synced from SAAS commit 4626f0c117d9c0544c4dc1115c3aac7468f0d277 --- .../domain/skill/service/SkillGovernanceService.java | 5 ++++- .../domain/skill/service/SkillPublishService.java | 12 ++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 820bbba9..bbb748f8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -182,12 +182,15 @@ public class SkillGovernanceService { deleteStorageAfterCommit(skill, namespaceSlug, storageKeys); skillFileRepository.deleteByVersionId(version.getId()); securityScanService.softDeleteByVersionId(version.getId()); - skillVersionRepository.delete(version); + // FK 约束 fk_skill_latest_version 阻止删除 skill_version 当 skill.latest_version_id 还指向它。 + // 必须先解开引用并 flush,让 PG 在 delete 时看不到引用。 if (version.getId().equals(skill.getLatestVersionId())) { skill.setLatestVersionId(findLatestPublishedVersionId(skill.getId())); skill.setUpdatedBy(actorUserId); skillRepository.save(skill); + skillRepository.flush(); } + skillVersionRepository.delete(version); auditLogService.record( actorUserId, "DELETE_SKILL_VERSION", 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 fed90cfc..d4271bf8 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 @@ -564,6 +564,14 @@ public class SkillPublishService { throw new DomainBadRequestException("error.skill.version.exists", version.getVersion()); } + // FK 约束 fk_skill_latest_version 阻止删除 skill_version 当 skill.latest_version_id 还指向它。 + // 必须先解开引用并 flush,让 PG 在 delete 时看不到引用。 + if (version.getId().equals(skill.getLatestVersionId())) { + skill.setLatestVersionId(null); + skillRepository.save(skill); + skillRepository.flush(); + } + reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING) .ifPresent(reviewTaskRepository::delete); @@ -579,10 +587,6 @@ public class SkillPublishService { securityScanService.softDeleteByVersionId(version.getId()); skillVersionRepository.delete(version); skillVersionRepository.flush(); - - if (version.getId().equals(skill.getLatestVersionId())) { - skill.setLatestVersionId(null); - } } private String resolveNamespaceSlug(Long namespaceId) { From 531d59caf273fce620c1c33c6520960dbef309a6 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:24:51 +0800 Subject: [PATCH 08/19] feat(my-skills): add keyword search, namespace filter and clickable pagination (#493) * feat(my-skills): add keyword search, namespace filter and clickable pagination Add comprehensive filtering and search capabilities to the My Skills page: - Keyword search: search by skill name, slug, or description - Namespace filter: filter skills by namespace - Clickable pagination: page number buttons with smart ellipsis - State preservation: sync search state to URL, restore when returning from detail page - Debounced search: 300ms debounce to avoid excessive queries - Fix: hide stale rejected preview badge when newer version is published Backend changes: - MySkillAppService: add keyword and namespace filtering logic - SkillLifecycleProjectionService: only show preview versions newer than published - MeController: add keyword and namespace query parameters - 6 new test cases covering search and filter scenarios Frontend changes: - my-skills.tsx: search input, namespace dropdown, URL state sync - pagination.tsx: clickable page numbers with ellipsis - use-user-queries.ts: prevent flicker on query transitions - skill-detail.tsx: remove invalid rejected badge display - router.tsx: URL parameter validation - i18n: add search-related translation keys Synced from SAAS commits: - 939fa749 (feat: search and filters) - dc14df6c (fix: search flicker) - 0168ea81 (fix: rejected badge) - c9eefa93 (fix: stale preview) Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(tests): address test failures in PR #493 Backend test fixes: - Remove unnecessary Mockito stubbing for filtered-out skills - Add missing findBySkillIdAndStatus stub for published version lookup - Update MeController test mocks to match new method signature (keyword, namespace params) Frontend fixes: - Fix TypeScript error: useMyNamespaces returns ManagedNamespace[] not PagedResponse - Add type annotation for namespace map callback parameter E2E test fix: - Update URL regex to allow query parameters (returnTo from search page) Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * fix(tests): resolve test failures in PR #493 Backend: - Remove unnecessary mock stubbings for skillId 2 and 3 in MySkillAppServiceTest.listMySkills_combinesKeywordNamespaceAndStatusFilters - The test filters results to only return skill with id=1, so mocks for id 2 and 3 were never called, causing UnnecessaryStubbingException Frontend: - Add missing mocks for useLocation, useSearch, useMyNamespaces, and useDebounce in my-skills.test.ts - MySkillsPage component uses these hooks but the test setup didn't provide mocks, causing 'No QueryClient set' and 'No export' errors All 4 frontend tests now pass locally. Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --------- Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../controller/portal/MeController.java | 4 +- .../skillhub/service/MySkillAppService.java | 90 +++++++++-- .../skillhub/controller/MeControllerTest.java | 4 +- .../service/MySkillAppServiceTest.java | 101 +++++++++++- .../SkillLifecycleProjectionService.java | 58 +++---- web/e2e/public-skill-detail-anonymous.spec.ts | 2 +- web/src/api/client.ts | 8 +- web/src/app/router.tsx | 6 + web/src/i18n/locales/en.json | 9 +- web/src/i18n/locales/zh.json | 9 +- web/src/pages/dashboard/my-skills.test.ts | 10 ++ web/src/pages/dashboard/my-skills.tsx | 150 +++++++++++++++--- web/src/pages/search.tsx | 2 +- web/src/pages/skill-detail.tsx | 3 +- web/src/shared/components/pagination.tsx | 68 +++++++- web/src/shared/hooks/use-user-queries.ts | 7 +- 16 files changed, 447 insertions(+), 84 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java index f5869eb1..0dd5f2ba 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java @@ -34,6 +34,8 @@ public class MeController extends BaseApiController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) String filter, + @RequestParam(required = false) String q, + @RequestParam(required = false) String namespace, @AuthenticationPrincipal PlatformPrincipal principal) { if (principal == null) { throw new UnauthorizedException("error.auth.required"); @@ -41,7 +43,7 @@ public class MeController extends BaseApiController { return ok( "response.success.read", - mySkillAppService.listMySkills(principal.userId(), page, size, filter, principal.platformRoles()) + mySkillAppService.listMySkills(principal.userId(), page, size, filter, q, namespace, principal.platformRoles()) ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index 9ad9e6f3..e36dc201 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.service; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; @@ -36,6 +38,7 @@ public class MySkillAppService { private final SkillSubscriptionRepository skillSubscriptionRepository; private final MySkillQueryRepository mySkillQueryRepository; private final SkillLifecycleProjectionService skillLifecycleProjectionService; + private final NamespaceRepository namespaceRepository; public MySkillAppService( SkillRepository skillRepository, @@ -43,17 +46,19 @@ public class MySkillAppService { SkillStarRepository skillStarRepository, SkillSubscriptionRepository skillSubscriptionRepository, MySkillQueryRepository mySkillQueryRepository, - SkillLifecycleProjectionService skillLifecycleProjectionService) { + SkillLifecycleProjectionService skillLifecycleProjectionService, + NamespaceRepository namespaceRepository) { this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillStarRepository = skillStarRepository; this.skillSubscriptionRepository = skillSubscriptionRepository; this.mySkillQueryRepository = mySkillQueryRepository; this.skillLifecycleProjectionService = skillLifecycleProjectionService; + this.namespaceRepository = namespaceRepository; } public PageResponse listMySkills(String userId, int page, int size) { - return listMySkills(userId, page, size, null, java.util.Set.of()); + return listMySkills(userId, page, size, null, null, null, java.util.Set.of()); } public PageResponse listMySkills(String userId, @@ -61,10 +66,27 @@ public class MySkillAppService { int size, String filter, java.util.Set platformRoles) { + return listMySkills(userId, page, size, filter, null, null, platformRoles); + } + + public PageResponse listMySkills(String userId, + int page, + int size, + String filter, + String keyword, + String namespace, + java.util.Set platformRoles) { MySkillFilter normalizedFilter = parseFilter(filter); - Page skillPage = normalizedFilter == MySkillFilter.ALL - ? skillRepository.findByOwnerId(userId, PageRequest.of(page, size)) - : filterSkillsByLifecycle(userId, page, size, normalizedFilter, platformRoles); + + Page skillPage; + if (normalizedFilter == MySkillFilter.ALL + && (keyword == null || keyword.isBlank()) + && (namespace == null || namespace.isBlank())) { + skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size)); + } else { + skillPage = filterSkills(userId, page, size, normalizedFilter, keyword, namespace, platformRoles); + } + List items = mySkillQueryRepository.getSkillSummaries(skillPage.getContent(), userId); return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize()); @@ -118,15 +140,34 @@ public class MySkillAppService { return new PageResponse<>(items, subPage.getTotalElements(), subPage.getNumber(), subPage.getSize()); } - private Page filterSkillsByLifecycle(String userId, - int page, - int size, - MySkillFilter filter, - java.util.Set platformRoles) { + private Page filterSkills(String userId, + int page, + int size, + MySkillFilter filter, + String keyword, + String namespace, + java.util.Set platformRoles) { List skills = skillRepository.findByOwnerId(userId); + + // Namespace filter + Long namespaceId = null; + if (namespace != null && !namespace.isBlank()) { + namespaceId = namespaceRepository.findBySlug(namespace.trim()) + .map(Namespace::getId) + .orElse(-1L); + } + + final Long finalNamespaceId = namespaceId; + String normalizedKeyword = keyword != null && !keyword.isBlank() + ? keyword.trim().toLowerCase(java.util.Locale.ROOT) + : null; + List filtered = skills.stream() + .filter(skill -> matchesNamespace(skill, finalNamespaceId)) + .filter(skill -> matchesKeyword(skill, normalizedKeyword)) .filter(skill -> matchesFilter(skill, filter, platformRoles)) .toList(); + int fromIndex = Math.min(page * size, filtered.size()); int toIndex = Math.min(fromIndex + size, filtered.size()); return new PageImpl<>( @@ -136,6 +177,35 @@ public class MySkillAppService { ); } + private boolean matchesNamespace(Skill skill, Long namespaceId) { + if (namespaceId == null) { + return true; + } + if (namespaceId == -1L) { + return false; + } + return skill.getNamespaceId().equals(namespaceId); + } + + private boolean matchesKeyword(Skill skill, String keyword) { + if (keyword == null) { + return true; + } + String displayName = skill.getDisplayName() != null ? skill.getDisplayName().toLowerCase(java.util.Locale.ROOT) : ""; + String slug = skill.getSlug() != null ? skill.getSlug().toLowerCase(java.util.Locale.ROOT) : ""; + String summary = skill.getSummary() != null ? skill.getSummary().toLowerCase(java.util.Locale.ROOT) : ""; + + return displayName.contains(keyword) || slug.contains(keyword) || summary.contains(keyword); + } + + private Page filterSkillsByLifecycle(String userId, + int page, + int size, + MySkillFilter filter, + java.util.Set platformRoles) { + return filterSkills(userId, page, size, filter, null, null, platformRoles); + } + private boolean matchesFilter(Skill skill, MySkillFilter filter, java.util.Set platformRoles) { if (filter == MySkillFilter.HIDDEN) { return platformRoles.contains("SUPER_ADMIN") && skill.isHidden(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java index af16f4dd..e4e7b80b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java @@ -56,7 +56,7 @@ class MeControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) ); - given(mySkillAppService.listMySkills("user-42", 1, 5, null, Set.of("USER"))) + given(mySkillAppService.listMySkills("user-42", 1, 5, null, null, null, Set.of("USER"))) .willReturn(new PageResponse<>( List.of(new SkillSummaryResponse( 7L, @@ -103,7 +103,7 @@ class MeControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) ); - given(mySkillAppService.listMySkills("user-42", 0, 10, "HIDDEN", Set.of("SUPER_ADMIN"))) + given(mySkillAppService.listMySkills("user-42", 0, 10, "HIDDEN", null, null, Set.of("SUPER_ADMIN"))) .willReturn(new PageResponse<>(List.of(), 0, 0, 10)); mockMvc.perform(get("/api/v1/me/skills") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index c4faf136..7c8bbb3e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -75,7 +75,8 @@ class MySkillAppServiceTest { skillStarRepository, skillSubscriptionRepository, mySkillQueryRepository, - skillLifecycleProjectionService + skillLifecycleProjectionService, + namespaceRepository ); } @@ -265,6 +266,104 @@ class MySkillAppServiceTest { assertThat(result.items().get(0).headlineVersion().status()).isEqualTo("REJECTED"); } + @Test + void listMySkills_hidesStaleRejectedVersionOlderThanPublished() { + Skill skill = createSkill(6L, 101L, "recovered-skill", "user-1"); + SkillVersion rejectedVersion = createVersion(6L, 60L, "1.0.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z"); + SkillVersion publishedVersion = createVersion(6L, 61L, "2.0.0", SkillVersionStatus.PUBLISHED, "2026-03-16T09:30:00Z"); + + given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10))) + .willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1)); + given(skillVersionRepository.findBySkillIdAndStatus(6L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion)); + given(skillVersionRepository.findBySkillId(6L)).willReturn(List.of(rejectedVersion, publishedVersion)); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); + + var result = service.listMySkills("user-1", 0, 10); + + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).headlineVersion().status()).isEqualTo("PUBLISHED"); + assertThat(result.items().get(0).headlineVersion().version()).isEqualTo("2.0.0"); + assertThat(result.items().get(0).ownerPreviewVersion()).isNull(); + } + + @Test + void listMySkills_filtersByKeywordAcrossDisplayNameSlugAndSummary() { + Skill alpha = createSkill(1L, 101L, "alpha-tool", "user-1"); + alpha.setDisplayName("Alpha Assistant"); + Skill beta = createSkill(2L, 101L, "beta-tool", "user-1"); + beta.setDisplayName("Beta Tool"); + beta.setSummary("This tool helps with alpha testing"); + Skill gamma = createSkill(3L, 101L, "gamma-tool", "user-1"); + gamma.setDisplayName("Gamma Service"); + SkillVersion publishedVersion = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(alpha, beta, gamma)); + given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(publishedVersion)); + given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of()); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); + + var result = service.listMySkills("user-1", 0, 10, null, "alpha", null, Set.of("USER")); + + assertThat(result.total()).isEqualTo(2); + assertThat(result.items()).extracting("slug") + .containsExactlyInAnyOrder("alpha-tool", "beta-tool"); + } + + @Test + void listMySkills_filtersByNamespaceSlug() { + Skill aiSkill = createSkill(1L, 101L, "ai-tool", "user-1"); + Skill mlSkill = createSkill(2L, 102L, "ml-tool", "user-1"); + SkillVersion v1 = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(aiSkill, mlSkill)); + given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(v1)); + given(namespaceRepository.findBySlug("team-ai")).willReturn(java.util.Optional.of(namespace(101L, "team-ai"))); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); + + var result = service.listMySkills("user-1", 0, 10, null, null, "team-ai", Set.of("USER")); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.items()).extracting("slug").containsExactly("ai-tool"); + } + + @Test + void listMySkills_returnsEmptyWhenNamespaceSlugNotFound() { + Skill skill = createSkill(1L, 101L, "ai-tool", "user-1"); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(skill)); + given(namespaceRepository.findBySlug("missing-namespace")).willReturn(java.util.Optional.empty()); + + var result = service.listMySkills("user-1", 0, 10, null, null, "missing-namespace", Set.of("USER")); + + assertThat(result.total()).isZero(); + assertThat(result.items()).isEmpty(); + } + + @Test + void listMySkills_combinesKeywordNamespaceAndStatusFilters() { + Skill aiAlpha = createSkill(1L, 101L, "ai-alpha", "user-1"); + aiAlpha.setDisplayName("AI Alpha"); + Skill aiBeta = createSkill(2L, 101L, "ai-beta", "user-1"); + aiBeta.setDisplayName("AI Beta"); + Skill mlAlpha = createSkill(3L, 102L, "ml-alpha", "user-1"); + mlAlpha.setDisplayName("ML Alpha"); + SkillVersion v1 = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); + SkillVersion v2 = createVersion(2L, 20L, "1.0.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z"); + SkillVersion v3 = createVersion(3L, 30L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(aiAlpha, aiBeta, mlAlpha)); + given(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(v1)); + given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(v1)); + given(namespaceRepository.findBySlug("team-ai")).willReturn(java.util.Optional.of(namespace(101L, "team-ai"))); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); + + var result = service.listMySkills("user-1", 0, 10, "PUBLISHED", "alpha", "team-ai", Set.of("USER")); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.items()).extracting("slug").containsExactly("ai-alpha"); + } + + private Skill createSkill(Long id, Long namespaceId, String slug, String ownerId) { Skill skill = new Skill(namespaceId, slug, ownerId, SkillVisibility.PUBLIC); skill.setDisplayName(slug); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 711ff60d..368ae850 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -39,6 +39,10 @@ public class SkillLifecycleProjectionService { ResolutionMode resolutionMode ) {} + private static final Comparator RECENCY = Comparator + .comparing(SkillVersion::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder())); + private final SkillVersionRepository skillVersionRepository; public SkillLifecycleProjectionService(SkillVersionRepository skillVersionRepository) { @@ -46,22 +50,26 @@ public class SkillLifecycleProjectionService { } public Projection projectForViewer(Skill skill, String currentUserId, Map userNsRoles) { - VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill)); - VersionProjection ownerPreviewVersion = toProjection(resolveOwnerPendingPreview(skill, currentUserId, userNsRoles)); - VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion; - ResolutionMode resolutionMode = headlineVersion == null - ? ResolutionMode.NONE - : publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW; - return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode); + SkillVersion published = resolvePublishedVersion(skill); + SkillVersion preview = canManage(skill, currentUserId, userNsRoles) + ? resolveNewerNonPublishedVersion(skill, published) + : null; + return buildProjection(published, preview); } public Projection projectForOwnerSummary(Skill skill) { - VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill)); - VersionProjection ownerPreviewVersion = toProjection(resolveNewestNonPublishedVersion(skill)); + SkillVersion published = resolvePublishedVersion(skill); + SkillVersion preview = resolveNewerNonPublishedVersion(skill, published); + return buildProjection(published, preview); + } + + private Projection buildProjection(SkillVersion published, SkillVersion preview) { + VersionProjection publishedVersion = toProjection(published); + VersionProjection ownerPreviewVersion = toProjection(preview); VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion; - ResolutionMode resolutionMode = headlineVersion == null - ? ResolutionMode.NONE - : publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW; + ResolutionMode resolutionMode = headlineVersion == null ? ResolutionMode.NONE + : publishedVersion != null ? ResolutionMode.PUBLISHED + : ResolutionMode.OWNER_PREVIEW; return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode); } @@ -116,27 +124,19 @@ public class SkillLifecycleProjectionService { } /** - * Returns the newest non-published version the owner can preview. - * Includes PENDING_REVIEW, REJECTED, DRAFT, SCANNING, SCAN_FAILED — any status - * that isn't already covered by the published projection and isn't yanked. + * Returns the newest non-published version (PENDING_REVIEW, REJECTED, DRAFT, SCANNING, + * SCAN_FAILED) that represents a NEW round of work layered on top of the current published + * version. A non-published version that is older than the published version is treated as + * settled history (e.g. an early rejected attempt later superseded by a published release) + * and is intentionally not surfaced, so the owner does not see a stale preview/rejected badge + * next to an already-published skill. */ - private SkillVersion resolveOwnerPendingPreview(Skill skill, String currentUserId, Map userNsRoles) { - if (!canManage(skill, currentUserId, userNsRoles)) { - return null; - } + private SkillVersion resolveNewerNonPublishedVersion(Skill skill, SkillVersion publishedVersion) { return skillVersionRepository.findBySkillId(skill.getId()).stream() - .filter(v -> v.getStatus() != SkillVersionStatus.PUBLISHED - && v.getStatus() != SkillVersionStatus.YANKED) - .max(versionComparator()) - .orElse(null); - } - - private SkillVersion resolveNewestNonPublishedVersion(Skill skill) { - List versions = skillVersionRepository.findBySkillId(skill.getId()); - return versions.stream() .filter(version -> version.getStatus() != SkillVersionStatus.PUBLISHED && version.getStatus() != SkillVersionStatus.YANKED) - .max(versionComparator()) + .filter(version -> publishedVersion == null || RECENCY.compare(version, publishedVersion) > 0) + .max(RECENCY) .orElse(null); } diff --git a/web/e2e/public-skill-detail-anonymous.spec.ts b/web/e2e/public-skill-detail-anonymous.spec.ts index 56ba8885..568deff6 100644 --- a/web/e2e/public-skill-detail-anonymous.spec.ts +++ b/web/e2e/public-skill-detail-anonymous.spec.ts @@ -36,7 +36,7 @@ test.describe('Public Skill Detail Anonymous Access (Real API)', () => { await card.click() - await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}$`)) + await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}(\\?|$)`)) await expect(page).not.toHaveURL(/\/login\?returnTo=/) await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible() await expect(page.getByText('Install', { exact: true })).toBeVisible() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3204d56a..16d2e7fe 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1024,13 +1024,19 @@ export const governanceApi = { } export const meApi = { - async getSkills(params?: { page?: number; size?: number; filter?: string }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> { + async getSkills(params?: { page?: number; size?: number; filter?: string; q?: string; namespace?: string }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> { const searchParams = new URLSearchParams() searchParams.set('page', String(params?.page ?? 0)) searchParams.set('size', String(params?.size ?? 10)) if (params?.filter) { searchParams.set('filter', params.filter) } + if (params?.q) { + searchParams.set('q', params.q) + } + if (params?.namespace) { + searchParams.set('namespace', params.namespace) + } return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/skills?${searchParams.toString()}`) }, diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 8cabf0b3..b053ca79 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -253,6 +253,12 @@ const dashboardSkillsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/skills', beforeLoad: requireAuth, + validateSearch: (search: Record): { page?: number; q?: string; namespace?: string; filter?: string } => ({ + page: typeof search.page === 'number' ? search.page : undefined, + q: typeof search.q === 'string' && search.q ? search.q : undefined, + namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace : undefined, + filter: typeof search.filter === 'string' && search.filter ? search.filter : undefined, + }), component: MySkillsPage, }) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3ff964a4..9c408cae 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -339,6 +339,12 @@ "mySkills": { "title": "My Skills", "subtitle": "Manage your published skills", + "searchPlaceholder": "Search by name, slug, or description", + "namespaceFilterLabel": "Filter by namespace", + "namespaceFilterAll": "All namespaces", + "clearSearch": "Clear filters", + "emptySearchTitle": "No matching skills", + "emptySearchDescription": "Try adjusting your keyword or switching namespace.", "filters": { "ALL": "All", "PENDING_REVIEW": "Pending Review", @@ -1277,7 +1283,8 @@ "prev": "Previous", "next": "Next", "pagePrefix": "Page", - "pageSuffix": "" + "pageSuffix": "", + "goToPage": "Go to page {{page}}" }, "user": { "menu": { diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 85cdc73b..b2940eda 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -339,6 +339,12 @@ "mySkills": { "title": "我的技能", "subtitle": "管理你发布的技能", + "searchPlaceholder": "搜索技能名称、Slug 或描述", + "namespaceFilterLabel": "按命名空间过滤", + "namespaceFilterAll": "全部命名空间", + "clearSearch": "清除筛选", + "emptySearchTitle": "未找到匹配的技能", + "emptySearchDescription": "试试调整关键字或切换命名空间", "filters": { "ALL": "全部", "PENDING_REVIEW": "待审核", @@ -1278,7 +1284,8 @@ "prev": "上一页", "next": "下一页", "pagePrefix": "第", - "pageSuffix": "页" + "pageSuffix": "页", + "goToPage": "第 {{page}} 页" }, "user": { "menu": { diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index f2c58112..c82faf93 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -8,6 +8,8 @@ const useMySkillsMock = vi.fn() vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, + useLocation: () => ({ pathname: '/dashboard/skills' }), + useSearch: () => ({}), })) vi.mock('react-i18next', async () => { @@ -69,6 +71,14 @@ vi.mock('@/shared/hooks/use-user-queries', () => ({ useSubmitPromotion: () => ({ mutateAsync: vi.fn(), isPending: false }), })) +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useMyNamespaces: () => ({ data: [] }), +})) + +vi.mock('@/shared/hooks/use-debounce', () => ({ + useDebounce: (value: string) => value, +})) + vi.mock('@/shared/lib/skill-lifecycle', () => ({ getHeadlineVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }), getPublishedVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }), diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 78192c32..0d90f928 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -1,22 +1,28 @@ -import { useState } from 'react' -import { useNavigate } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { useLocation, useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { EmptyState } from '@/shared/components/empty-state' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' import { useArchiveSkill, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries' +import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { useMySkills, useSubmitPromotion } from '@/shared/hooks/use-user-queries' +import { useDebounce } from '@/shared/hooks/use-debounce' import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle' import { formatCompactCount } from '@/shared/lib/number-format' import { toast } from '@/shared/lib/toast' +import { buildReturnTo } from '@/shared/lib/auth-route' import { ApiError } from '@/api/client' import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters' const PAGE_SIZE = 10 +const ALL_NAMESPACES_VALUE = '__all_namespaces__' /** * Dashboard page for skills owned by the current user. @@ -36,18 +42,61 @@ function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' export function MySkillsPage() { const navigate = useNavigate() + const location = useLocation() + const search = useSearch({ from: '/dashboard/skills' }) const { t } = useTranslation() const { hasRole } = useAuth() - const [page, setPage] = useState(0) - const [filter, setFilter] = useState('ALL') + + // The URL is the source of truth for page / filter / namespace / keyword so the + // search context survives navigating into a skill and back via the returnTo link. + const page = search.page ?? 0 + const filter = (search.filter as MySkillFilter) ?? 'ALL' + const namespaceFilter = search.namespace ?? '' + const keyword = search.q ?? '' + + // Keep an instant-feedback copy of the keyword input, debounced before it is + // pushed to the URL so each keystroke does not create a history entry or query. + const [keywordInput, setKeywordInput] = useState(keyword) + const debouncedKeyword = useDebounce(keywordInput.trim(), 300) + const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null) - const { data: skillPage, isLoading } = useMySkills({ page, size: PAGE_SIZE, filter: filter === 'ALL' ? undefined : filter }) + + const updateSearch = (next: Partial, options?: { replace?: boolean }) => { + navigate({ + to: '/dashboard/skills', + search: (prev) => ({ ...prev, ...next }), + replace: options?.replace, + }) + } + + // Push the debounced keyword to the URL (reset page to 0 when search changes) + useEffect(() => { + if (debouncedKeyword !== keyword) { + updateSearch({ q: debouncedKeyword || undefined, page: 0 }, { replace: true }) + } + }, [debouncedKeyword]) + + // Sync keywordInput when navigating back via returnTo + useEffect(() => { + setKeywordInput(keyword) + }, [keyword]) + + const { data: skillPage, isLoading } = useMySkills({ + page, + size: PAGE_SIZE, + filter: filter === 'ALL' ? undefined : filter, + q: keyword || undefined, + namespace: namespaceFilter || undefined, + }) + const { data: namespaceOptions } = useMyNamespaces() + const skills = skillPage?.items ?? [] const totalPages = skillPage ? Math.max(Math.ceil(skillPage.total / skillPage.size), 1) : 1 const availableFilters = getMySkillFilters(hasRole('SUPER_ADMIN')) + const hasActiveSearch = keyword.trim() !== '' || namespaceFilter !== '' const emptyStateKey = getMySkillEmptyStateKey(filter) const archiveMutation = useArchiveSkill() const unarchiveMutation = useUnarchiveSkill() @@ -57,10 +106,15 @@ export function MySkillsPage() { const handleSkillClick = (namespace: string, slug: string) => { navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}`, - search: { returnTo: '/dashboard/skills' }, + search: { returnTo: buildReturnTo(location) }, }) } + const handleClearSearch = () => { + setKeywordInput('') + updateSearch({ q: undefined, namespace: undefined, page: 0 }) + } + const handleUpdateSkill = (namespace: string, visibility?: string) => { navigate({ to: '/dashboard/publish', @@ -238,21 +292,61 @@ export function MySkillsPage() { )} /> -
- {availableFilters.map((option) => ( - + ) : null} +
+ +
+ {availableFilters.map((option) => ( + + ))} +
{skillPage && skillPage.total > 0 ? ( @@ -400,17 +494,23 @@ export function MySkillsPage() { {skillPage.total > PAGE_SIZE ? ( - + updateSearch({ page: next })} /> ) : null} ) : ( navigate({ to: '/dashboard/publish' })}> - {t('mySkills.publishSkill')} - + hasActiveSearch ? ( + + ) : ( + + ) } /> )} diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 58c421db..a0865909 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -186,7 +186,7 @@ export function SearchPage() { } const handleSkillClick = (namespace: string, slug: string) => { - navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` }) + navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}`, search: { returnTo: `${window.location.pathname}${window.location.search}` } }) } const filteredStarredSkills = starredOnly diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 086f85a0..43c05b7f 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -172,7 +172,6 @@ export function SkillDetailPage() { && ['PENDING_REVIEW', 'SCANNING', 'SCAN_FAILED'].includes(headlineVersion?.status ?? '') const hasPendingOwnerPreview = ownerPreviewVersion?.status === 'PENDING_REVIEW' const hasRejectedOwnerPreview = ownerPreviewVersion?.status === 'REJECTED' - const hasRejectedVersion = versions?.some((v) => v.status === 'REJECTED') ?? false const hasPublishedPendingReview = Boolean(publishedVersion && hasPendingOwnerPreview) const canInteract = skill?.canInteract ?? true const canReport = skill?.canReport ?? true @@ -762,7 +761,7 @@ export function SkillDetailPage() { {t('skillDetail.versionStatusPendingReview')} )} - {!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview || hasRejectedVersion) && skill.canManageLifecycle && ( + {!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview) && skill.canManageLifecycle && ( {t('skillDetail.rejectedBadge')} diff --git a/web/src/shared/components/pagination.tsx b/web/src/shared/components/pagination.tsx index 5f54d904..069dbd85 100644 --- a/web/src/shared/components/pagination.tsx +++ b/web/src/shared/components/pagination.tsx @@ -7,8 +7,43 @@ interface PaginationProps { onPageChange: (page: number) => void } +type PageItem = number | 'ellipsis' + +/** + * Builds the list of page slots to render. Always shows the first and last page, + * the current page, and one neighbour on each side, collapsing the rest into + * ellipsis markers. Pages are 0-indexed internally; labels are 1-indexed. + */ +function buildPageItems(current: number, totalPages: number): PageItem[] { + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, i) => i) + } + + const items: PageItem[] = [] + const first = 0 + const last = totalPages - 1 + const start = Math.max(first + 1, current - 1) + const end = Math.min(last - 1, current + 1) + + items.push(first) + if (start > first + 1) { + items.push('ellipsis') + } + for (let i = start; i <= end; i += 1) { + items.push(i) + } + if (end < last - 1) { + items.push('ellipsis') + } + items.push(last) + + return items +} + export function Pagination({ page, totalPages, onPageChange }: PaginationProps) { const { t } = useTranslation() + const pageItems = buildPageItems(page, totalPages) + return (
-
- {t('pagination.pagePrefix')} - {page + 1} - / - {totalPages} - {t('pagination.pageSuffix') && {t('pagination.pageSuffix')}} + +
+ {pageItems.map((item, index) => + item === 'ellipsis' ? ( + + ) : ( + + ), + )}
+
) } + +export function InstallCommand({ namespace, slug }: InstallCommandProps) { + const { t } = useTranslation() + const baseUrl = useMemo(() => getBaseUrl(), []) + const clawhubCommand = useMemo(() => buildInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug]) + const skillhubCommand = useMemo(() => buildSkillhubInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug]) + + return ( + + + + {t('skillDetail.installMethodClawhub')} + + + {t('skillDetail.installMethodSkillhub')} + + + + + + + + + + ) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 9c408cae..ea5e049d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -807,6 +807,8 @@ "namespaceLabel": "Namespace", "loginToRate": "Login to star and rate", "install": "Install", + "installMethodClawhub": "ClawHub CLI", + "installMethodSkillhub": "SkillHub CLI", "download": "Download", "labelsSectionTitle": "Labels", "labelsSectionDescription": "Attach or remove recommended labels that help users filter and discover this skill.", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index b2940eda..b8f0cd7a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -807,6 +807,8 @@ "namespaceLabel": "命名空间", "loginToRate": "登录后可以收藏和评分", "install": "安装", + "installMethodClawhub": "ClawHub CLI", + "installMethodSkillhub": "SkillHub CLI", "download": "下载", "labelsSectionTitle": "标签管理", "labelsSectionDescription": "为这个技能挂载或移除推荐标签,帮助用户筛选和发现。", diff --git a/web/src/shared/ui/tabs.test.ts b/web/src/shared/ui/tabs.test.ts index cc3d1909..9f54f4a9 100644 --- a/web/src/shared/ui/tabs.test.ts +++ b/web/src/shared/ui/tabs.test.ts @@ -1,3 +1,5 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' import { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs' @@ -28,4 +30,29 @@ describe('Tabs components', () => { expect(typeof TabsContent).toBe('function') expect(TabsContent.name).toBe('TabsContent') }) + + it('renders semantic tablist, tab, and tabpanel roles', () => { + const html = renderToStaticMarkup( + createElement(Tabs, { + defaultValue: 'clawhub', + children: [ + createElement(TabsList, { + key: 'list', + children: [ + createElement(TabsTrigger, { key: 'clawhub', value: 'clawhub', children: 'ClawHub CLI' }), + createElement(TabsTrigger, { key: 'skillhub', value: 'skillhub', children: 'SkillHub CLI' }), + ], + }), + createElement(TabsContent, { key: 'clawhub-content', value: 'clawhub', children: 'clawhub command' }), + createElement(TabsContent, { key: 'skillhub-content', value: 'skillhub', children: 'skillhub command' }), + ], + }), + ) + + expect(html).toContain('role="tablist"') + expect(html).toContain('role="tab"') + expect(html).toContain('aria-selected="true"') + expect(html).toContain('aria-selected="false"') + expect(html).toContain('role="tabpanel"') + }) }) diff --git a/web/src/shared/ui/tabs.tsx b/web/src/shared/ui/tabs.tsx index 70984e9e..05a40f31 100644 --- a/web/src/shared/ui/tabs.tsx +++ b/web/src/shared/ui/tabs.tsx @@ -43,6 +43,7 @@ interface TabsListProps { export function TabsList({ children, className }: TabsListProps) { return (
context.setValue(value)} data-state={isActive ? 'active' : 'inactive'} className={cn( @@ -96,5 +99,5 @@ export function TabsContent({ value, children, className }: TabsContentProps) { if (context.value !== value) return null - return
{children}
+ return
{children}
} From 6e094f4199f61b6e3b5cc886b58c6c81c9682aa3 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 8 Jun 2026 10:46:30 +0800 Subject: [PATCH 10/19] fix(skill): cap namespace bundle downloads Signed-off-by: dongmucat <1127093059@qq.com> --- .../src/main/resources/messages.properties | 3 + .../src/main/resources/messages_zh.properties | 3 + .../skill/service/SkillDownloadService.java | 76 ++++++- .../service/SkillDownloadServiceTest.java | 152 ++++++++++++++ web/src/api/generated/schema.d.ts | 195 ++++++++++++++++-- 5 files changed, 409 insertions(+), 20 deletions(-) diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 32a072e6..cbe51214 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -144,6 +144,9 @@ error.skill.version.confirm.notUploaded=Version ''{0}'' is not in UPLOADED statu error.skill.confirm.notPrivate=Only PRIVATE skills can use confirm-publish error.skill.version.notDownloadable=Version ''{0}'' is not available for download error.namespace.skills.download.empty=No downloadable skills found in namespace ''{0}'' +error.namespace.skills.download.selectionRequired=Anonymous namespace bundle downloads require explicit skill selection +error.namespace.skills.download.tooMany=Namespace bundle download supports up to {0} skills at a time +error.namespace.skills.download.tooLarge=Namespace bundle download supports up to {0} bytes at a time # Profile update error.profile.displayName.length=Display name must be between 2 and 32 characters diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 057c2f03..55c35ce8 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -144,6 +144,9 @@ error.skill.version.confirm.notUploaded=版本"{0}"不在 UPLOADED 状态,无 error.skill.confirm.notPrivate=只有 PRIVATE 技能可以使用确认发布功能 error.skill.version.notDownloadable=版本"{0}"不可下载 error.namespace.skills.download.empty=命名空间“{0}”下没有可下载的技能 +error.namespace.skills.download.selectionRequired=匿名命名空间批量下载需要显式选择技能 +error.namespace.skills.download.tooMany=命名空间批量下载一次最多支持 {0} 个技能 +error.namespace.skills.download.tooLarge=命名空间批量下载一次最多支持 {0} 字节 # 用户资料修改 error.profile.displayName.length=昵称长度需在 2-32 个字符之间 diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index aed9a91b..05b32944 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -37,6 +37,8 @@ import java.util.zip.ZipOutputStream; @Service public class SkillDownloadService { private static final Logger log = LoggerFactory.getLogger(SkillDownloadService.class); + private static final int MAX_NAMESPACE_BUNDLE_SKILL_COUNT = 20; + private static final long MAX_NAMESPACE_BUNDLE_TOTAL_BYTES = 100L * 1024 * 1024; private final NamespaceRepository namespaceRepository; private final SkillRepository skillRepository; @@ -182,18 +184,56 @@ public class SkillDownloadService { .map(slug -> slug.trim().replaceFirst("^@", "")) .toList()); - List entries = skillRepository.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE) + if (currentUserId == null && selected.isEmpty()) { + throw new DomainBadRequestException("error.namespace.skills.download.selectionRequired"); + } + + List candidates = skillRepository.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE) .stream() .filter(skill -> selected.isEmpty() || selected.contains(skill.getSlug())) .sorted(Comparator.comparing(Skill::getSlug)) - .map(skill -> toNamespaceBundleEntry(namespace, skill, currentUserId, userNsRoles)) + .map(skill -> toNamespaceBundleCandidate(namespace, skill, currentUserId, userNsRoles)) .flatMap(java.util.Optional::stream) .toList(); - if (entries.isEmpty()) { + if (candidates.isEmpty()) { throw new DomainBadRequestException("error.namespace.skills.download.empty", namespaceSlug); } + if (candidates.size() > MAX_NAMESPACE_BUNDLE_SKILL_COUNT) { + throw new DomainBadRequestException( + "error.namespace.skills.download.tooMany", + MAX_NAMESPACE_BUNDLE_SKILL_COUNT + ); + } + + long estimatedBundleBytes = candidates.stream() + .mapToLong(this::estimateNamespaceBundleEntryBytes) + .sum(); + if (estimatedBundleBytes > MAX_NAMESPACE_BUNDLE_TOTAL_BYTES) { + throw new DomainBadRequestException( + "error.namespace.skills.download.tooLarge", + MAX_NAMESPACE_BUNDLE_TOTAL_BYTES + ); + } + + List entries = candidates.stream() + .map(candidate -> new NamespaceBundleEntry( + candidate.skill(), + candidate.version(), + buildDownloadResult(candidate.skill(), candidate.version()))) + .toList(); + + long totalBundleBytes = entries.stream() + .mapToLong(entry -> entry.downloadResult().contentLength()) + .sum(); + if (totalBundleBytes > MAX_NAMESPACE_BUNDLE_TOTAL_BYTES) { + throw new DomainBadRequestException( + "error.namespace.skills.download.tooLarge", + MAX_NAMESPACE_BUNDLE_TOTAL_BYTES + ); + } + byte[] bundle = createNamespaceBundle(namespace.getSlug(), entries); entries.forEach(entry -> recordPublishedDownload(entry.skill(), entry.version())); @@ -207,6 +247,23 @@ public class SkillDownloadService { ); } + private long estimateNamespaceBundleEntryBytes(NamespaceBundleCandidate candidate) { + String storageKey = buildBundleStorageKey(candidate.skill(), candidate.version()); + if (objectStorageService.exists(storageKey)) { + return objectStorageService.getMetadata(storageKey).size(); + } + + List files = skillFileRepository.findByVersionId(candidate.version().getId()).stream() + .filter(file -> objectStorageService.exists(file.getStorageKey())) + .toList(); + if (files.isEmpty()) { + throw new DomainBadRequestException("error.skill.bundle.notFound"); + } + return files.stream() + .mapToLong(file -> file.getFileSize() != null ? file.getFileSize() : 0L) + .sum(); + } + private DownloadResult downloadVersion(Skill skill, SkillVersion version) { assertPublishedAccessible(skill); assertDownloadableVersion(skill, version); @@ -219,7 +276,7 @@ public class SkillDownloadService { return result; } - private java.util.Optional toNamespaceBundleEntry( + private java.util.Optional toNamespaceBundleCandidate( Namespace namespace, Skill skill, String currentUserId, @@ -235,7 +292,7 @@ public class SkillDownloadService { if (version.getStatus() != SkillVersionStatus.PUBLISHED) { return java.util.Optional.empty(); } - return java.util.Optional.of(new NamespaceBundleEntry(skill, version, buildDownloadResult(skill, version))); + return java.util.Optional.of(new NamespaceBundleCandidate(skill, version)); } private boolean canIncludeInNamespaceBundle( @@ -276,9 +333,12 @@ public class SkillDownloadService { private record NamespaceBundleEntry(Skill skill, SkillVersion version, DownloadResult downloadResult) { } + private record NamespaceBundleCandidate(Skill skill, SkillVersion version) { + } + private DownloadResult buildDownloadResult(Skill skill, SkillVersion version) { - String storageKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); + String storageKey = buildBundleStorageKey(skill, version); DownloadResult result; if (objectStorageService.exists(storageKey)) { @@ -305,6 +365,10 @@ public class SkillDownloadService { return result; } + private String buildBundleStorageKey(Skill skill, SkillVersion version) { + return String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); + } + private DownloadResult buildBundleFromFiles(Skill skill, SkillVersion version) { List files = skillFileRepository.findByVersionId(version.getId()).stream() .filter(file -> objectStorageService.exists(file.getStorageKey())) diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 22be0a3f..de2a8815 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -22,6 +22,7 @@ import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.lang.reflect.Field; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -482,6 +483,157 @@ class SkillDownloadServiceTest { verify(eventPublisher, times(1)).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadNamespaceBundle_RejectsAnonymousAllSkillsRequest() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setId(namespace, 1L); + namespace.setType(NamespaceType.GLOBAL); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadNamespaceBundle("global", List.of(), null, Map.of())); + + assertEquals("error.namespace.skills.download.selectionRequired", ex.getMessage()); + verifyNoInteractions(objectStorageService); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadNamespaceBundle_RejectsTooManyEligibleSkills() throws Exception { + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + setId(namespace, 2L); + namespace.setType(NamespaceType.TEAM); + + List skills = new ArrayList<>(); + for (int i = 1; i <= 21; i++) { + Skill skill = new Skill(2L, "skill-" + i, "owner-1", SkillVisibility.PUBLIC); + setId(skill, (long) i); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(100L + i); + skills.add(skill); + + SkillVersion version = new SkillVersion((long) i, "1.0.0", "owner-1"); + setId(version, 100L + i); + version.setStatus(SkillVersionStatus.PUBLISHED); + when(visibilityChecker.canAccess(skill, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(skillVersionRepository.findById(100L + i)).thenReturn(Optional.of(version)); + } + + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(skills); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadNamespaceBundle("team-ai", List.of(), "user-1", Map.of(2L, NamespaceRole.MEMBER))); + + assertEquals("error.namespace.skills.download.tooMany", ex.getMessage()); + verifyNoInteractions(objectStorageService); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadNamespaceBundle_RejectsOversizedAggregateBundle() throws Exception { + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + setId(namespace, 2L); + namespace.setType(NamespaceType.TEAM); + + Skill alpha = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); + setId(alpha, 11L); + alpha.setDisplayName("Alpha Skill"); + alpha.setStatus(SkillStatus.ACTIVE); + alpha.setLatestVersionId(101L); + + Skill beta = new Skill(2L, "beta", "owner-1", SkillVisibility.PUBLIC); + setId(beta, 12L); + beta.setDisplayName("Beta Skill"); + beta.setStatus(SkillStatus.ACTIVE); + beta.setLatestVersionId(102L); + + SkillVersion alphaVersion = new SkillVersion(11L, "1.0.0", "owner-1"); + setId(alphaVersion, 101L); + alphaVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion betaVersion = new SkillVersion(12L, "1.0.0", "owner-1"); + setId(betaVersion, 102L); + betaVersion.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(alpha, beta)); + when(visibilityChecker.canAccess(alpha, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(visibilityChecker.canAccess(beta, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(alphaVersion)); + when(skillVersionRepository.findById(102L)).thenReturn(Optional.of(betaVersion)); + when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(true); + when(objectStorageService.exists("packages/12/102/bundle.zip")).thenReturn(true); + when(objectStorageService.getMetadata("packages/11/101/bundle.zip")) + .thenReturn(new ObjectMetadata(60L * 1024 * 1024, "application/zip", Instant.now())); + when(objectStorageService.getMetadata("packages/12/102/bundle.zip")) + .thenReturn(new ObjectMetadata(60L * 1024 * 1024, "application/zip", Instant.now())); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadNamespaceBundle("team-ai", List.of(), "user-1", Map.of(2L, NamespaceRole.MEMBER))); + + assertEquals("error.namespace.skills.download.tooLarge", ex.getMessage()); + verify(objectStorageService, never()).getObject(anyString()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadNamespaceBundle_RejectsOversizedFallbackBundleBeforeReadingFiles() throws Exception { + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + setId(namespace, 2L); + namespace.setType(NamespaceType.TEAM); + + Skill alpha = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); + setId(alpha, 11L); + alpha.setDisplayName("Alpha Skill"); + alpha.setStatus(SkillStatus.ACTIVE); + alpha.setLatestVersionId(101L); + + Skill beta = new Skill(2L, "beta", "owner-1", SkillVisibility.PUBLIC); + setId(beta, 12L); + beta.setDisplayName("Beta Skill"); + beta.setStatus(SkillStatus.ACTIVE); + beta.setLatestVersionId(102L); + + SkillVersion alphaVersion = new SkillVersion(11L, "1.0.0", "owner-1"); + setId(alphaVersion, 101L); + alphaVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion betaVersion = new SkillVersion(12L, "1.0.0", "owner-1"); + setId(betaVersion, 102L); + betaVersion.setStatus(SkillVersionStatus.PUBLISHED); + + SkillFile alphaFile = new SkillFile(101L, "SKILL.md", 60L * 1024 * 1024, "text/markdown", "hash-a", "skills/11/101/SKILL.md"); + SkillFile betaFile = new SkillFile(102L, "README.md", 60L * 1024 * 1024, "text/markdown", "hash-b", "skills/12/102/README.md"); + + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(alpha, beta)); + when(visibilityChecker.canAccess(alpha, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(visibilityChecker.canAccess(beta, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); + when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(alphaVersion)); + when(skillVersionRepository.findById(102L)).thenReturn(Optional.of(betaVersion)); + when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(false); + when(objectStorageService.exists("packages/12/102/bundle.zip")).thenReturn(false); + when(skillFileRepository.findByVersionId(101L)).thenReturn(List.of(alphaFile)); + when(skillFileRepository.findByVersionId(102L)).thenReturn(List.of(betaFile)); + when(objectStorageService.exists("skills/11/101/SKILL.md")).thenReturn(true); + when(objectStorageService.exists("skills/12/102/README.md")).thenReturn(true); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadNamespaceBundle("team-ai", List.of(), "user-1", Map.of(2L, NamespaceRole.MEMBER))); + + assertEquals("error.namespace.skills.download.tooLarge", ex.getMessage()); + verify(objectStorageService, never()).getObject(anyString()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 5f0a970a..4e9631dc 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -916,6 +916,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/namespaces/{slug}/transfer-ownership": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["transferOwnership"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/transfer-ownership": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["transferOwnership_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/namespaces/{slug}/restore": { parameters: { query?: never; @@ -2500,6 +2532,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/namespaces/{slug}/skills/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadNamespaceSkills"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/namespaces/{slug}/skills/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadNamespaceSkills_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/namespaces/{slug}/member-candidates": { parameters: { query?: never; @@ -3685,6 +3749,21 @@ export interface components { /** Format: int64 */ targetNamespaceId?: number; }; + TransferOwnershipRequest: { + newOwnerId: string; + }; + ApiResponseMessageResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MessageResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MessageResponse: { + message?: string; + }; BatchMemberRequest: { members: components["schemas"]["MemberRequest"][]; }; @@ -3781,18 +3860,6 @@ export interface components { AuthorizeRequest: { userCode?: string; }; - ApiResponseMessageResponse: { - /** Format: int32 */ - code?: number; - msg?: string; - data?: components["schemas"]["MessageResponse"]; - /** Format: date-time */ - timestamp?: string; - requestId?: string; - }; - MessageResponse: { - message?: string; - }; SessionBootstrapRequest: { provider: string; }; @@ -3977,8 +4044,8 @@ export interface components { valid?: boolean; errors?: string[]; warnings?: string[]; - resolvedSlug?: string | null; - resolvedVersion?: string | null; + resolvedSlug?: string; + resolvedVersion?: string; }; UpdateProfileRequest: { displayName?: string; @@ -7035,6 +7102,58 @@ export interface operations { }; }; }; + transferOwnership: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TransferOwnershipRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + transferOwnership_1: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TransferOwnershipRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; restoreNamespace: { parameters: { query?: never; @@ -9703,6 +9822,54 @@ export interface operations { }; }; }; + downloadNamespaceSkills: { + parameters: { + query?: { + skill?: string[]; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + downloadNamespaceSkills_1: { + parameters: { + query?: { + skill?: string[]; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; searchMemberCandidates: { parameters: { query: { From 7e23508a32e2e30d7e266b0726666e62f0266724 Mon Sep 17 00:00:00 2001 From: Xudong Sun Date: Mon, 8 Jun 2026 17:31:07 +0800 Subject: [PATCH 11/19] chore(logging): include idempotency cleanup threshold --- .../java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java index ea40dfee..c5b320c3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java @@ -42,7 +42,7 @@ public class IdempotencyCleanupTask { Instant threshold = Instant.now(clock).minusSeconds(STALE_THRESHOLD_MINUTES * 60); int updated = idempotencyRecordRepository.markStaleAsFailed(threshold); if (updated > 0) { - logger.info("Marked {} stale processing records as failed", updated); + logger.info("Marked {} stale processing records as failed before threshold={}", updated, threshold); } } } From b5edfb850eb8975921e0d21429afc8a0510b3677 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 9 Jun 2026 10:02:26 +0800 Subject: [PATCH 12/19] fix(web): clarify namespace bundle download limits Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/namespace-search-download.spec.ts | 6 ++++-- web/src/i18n/locales/en.json | 5 +++-- web/src/i18n/locales/zh.json | 5 +++-- web/src/pages/namespace.tsx | 22 ++++++++++++++++++---- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/web/e2e/namespace-search-download.spec.ts b/web/e2e/namespace-search-download.spec.ts index 208d02a8..163b44a7 100644 --- a/web/e2e/namespace-search-download.spec.ts +++ b/web/e2e/namespace-search-download.spec.ts @@ -272,7 +272,8 @@ test.describe('Namespace Search and Download', () => { await page.getByRole('button', { name: 'Download selected on this page' }).click() await expect(page.getByRole('dialog', { name: 'Confirm namespace download' })).toBeVisible() - await expect(page.getByText('This will request 1 skill package from @product-managers.')).toBeVisible() + await expect(page.getByText('This will request up to 1 skill package from @product-managers.')).toBeVisible() + await expect(page.getByText('Unavailable skills may be skipped. Synchronous downloads are limited to 20 skills and 100 MB.')).toBeVisible() const [request, response] = await Promise.all([ page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), @@ -294,7 +295,8 @@ test.describe('Namespace Search and Download', () => { await page.getByRole('button', { name: 'Download all' }).click() await expect(page.getByRole('dialog', { name: 'Confirm namespace download' })).toBeVisible() - await expect(page.getByText('This will request 2 skill packages from @product-managers.')).toBeVisible() + await expect(page.getByText('This will request up to 2 skill packages from @product-managers.')).toBeVisible() + await expect(page.getByText('Unavailable skills may be skipped. Synchronous downloads are limited to 20 skills and 100 MB.')).toBeVisible() const [request, response] = await Promise.all([ page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1d2ad394..176ba286 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -771,8 +771,9 @@ "downloadSelected": "Download selected on this page", "copyInstallManifest": "Copy current page install list", "downloadConfirmTitle": "Confirm namespace download", - "downloadConfirmDescription_one": "This will request {{count}} skill package from @{{namespace}}.", - "downloadConfirmDescription_other": "This will request {{count}} skill packages from @{{namespace}}.", + "downloadConfirmDescription_one": "This will request up to {{count}} skill package from @{{namespace}}.", + "downloadConfirmDescription_other": "This will request up to {{count}} skill packages from @{{namespace}}.", + "downloadConfirmLimitHint": "Unavailable skills may be skipped. Synchronous downloads are limited to {{maxSkills}} skills and {{maxSize}}.", "downloadConfirmAction": "Download", "selectSkill": "Select {{name}}" }, diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index feb66a8b..583977b7 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -771,8 +771,9 @@ "downloadSelected": "下载本页选中", "copyInstallManifest": "复制本页安装清单", "downloadConfirmTitle": "确认命名空间下载", - "downloadConfirmDescription_one": "将请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", - "downloadConfirmDescription_other": "将请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", + "downloadConfirmDescription_one": "将最多请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", + "downloadConfirmDescription_other": "将最多请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", + "downloadConfirmLimitHint": "不可用的 skill 可能会被跳过;同步下载一次最多支持 {{maxSkills}} 个 skill、{{maxSize}}。", "downloadConfirmAction": "下载", "selectSkill": "选择 {{name}}" }, diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index 5a628a0d..04a90772 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -14,6 +14,8 @@ import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' import { Button } from '@/shared/ui/button' const PAGE_SIZE = 20 +const NAMESPACE_BUNDLE_MAX_SKILLS = 20 +const NAMESPACE_BUNDLE_MAX_SIZE = '100 MB' /** * Public namespace page showing namespace metadata and the skills currently discoverable inside it. @@ -175,10 +177,22 @@ export function NamespacePage() { } }} title={t('namespace.downloadConfirmTitle')} - description={t('namespace.downloadConfirmDescription', { - count: pendingDownloadCount, - namespace, - })} + description={( + + + {t('namespace.downloadConfirmDescription', { + count: pendingDownloadCount, + namespace, + })} + + + {t('namespace.downloadConfirmLimitHint', { + maxSkills: NAMESPACE_BUNDLE_MAX_SKILLS, + maxSize: NAMESPACE_BUNDLE_MAX_SIZE, + })} + + + )} confirmText={t('namespace.downloadConfirmAction')} onConfirm={confirmDownload} /> From ed13a41ed87824762ee9d5a46d1e53fb7297495a Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 9 Jun 2026 14:25:17 +0800 Subject: [PATCH 13/19] fix(skill): align anonymous download helper with main Signed-off-by: dongmucat <1127093059@qq.com> --- .../domain/skill/service/SkillDownloadService.java | 10 ++++------ .../domain/skill/service/SkillDownloadServiceTest.java | 5 +++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 05b32944..27e9c376 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -4,7 +4,6 @@ import com.iflytek.skillhub.domain.event.SkillDownloadedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -300,7 +299,7 @@ public class SkillDownloadService { Skill skill, String currentUserId, Map userNsRoles) { - if (currentUserId == null && !isAnonymousDownloadAllowed(namespace, skill)) { + if (currentUserId == null && !isAnonymousDownloadAllowed(skill)) { return false; } return visibilityChecker.canAccess(skill, currentUserId, userNsRoles); @@ -432,7 +431,7 @@ public class SkillDownloadService { Skill skill, String currentUserId, Map userNsRoles) { - if (currentUserId == null && !isAnonymousDownloadAllowed(namespace, skill)) { + if (currentUserId == null && !isAnonymousDownloadAllowed(skill)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { @@ -440,9 +439,8 @@ public class SkillDownloadService { } } - private boolean isAnonymousDownloadAllowed(Namespace namespace, Skill skill) { - return namespace.getType() == NamespaceType.GLOBAL - && skill.getVisibility() == SkillVisibility.PUBLIC; + private boolean isAnonymousDownloadAllowed(Skill skill) { + return skill.getVisibility() == SkillVisibility.PUBLIC; } private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index de2a8815..298df05d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -319,7 +319,7 @@ class SkillDownloadServiceTest { } @Test - void testDownloadVersion_RejectsAnonymousForTeamNamespacePublicSkill() throws Exception { + void testDownloadVersion_RejectsAnonymousWhenVisibilityCheckerDeniesAccess() throws Exception { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); setId(namespace, 2L); namespace.setType(NamespaceType.TEAM); @@ -331,11 +331,12 @@ class SkillDownloadServiceTest { when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(2L, "demo-skill")).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(false); assertThrows(DomainForbiddenException.class, () -> service.downloadVersion("team-ai", "demo-skill", "1.0.0", null, Map.of())); - verify(visibilityChecker, never()).canAccess(any(), any(), anyMap()); + verify(visibilityChecker).canAccess(skill, null, Map.of()); verify(skillRepository, never()).incrementDownloadCount(anyLong()); verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); From 6779c1eecdb234dffbd2c84024ac2e912517f55c Mon Sep 17 00:00:00 2001 From: dongmucat <70678707+dongmucat@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:31:22 +0800 Subject: [PATCH 14/19] feat(web): preview relative markdown package links (#502) Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/helpers/test-data-builder.ts | 25 +++- web/e2e/skill-detail-relative-links.spec.ts | 55 +++++++ .../features/skill/markdown-renderer.test.tsx | 27 +++- web/src/features/skill/markdown-renderer.tsx | 9 +- .../skill/package-relative-link.test.ts | 80 +++++++++++ .../features/skill/package-relative-link.ts | 112 +++++++++++++++ web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/zh.json | 2 + web/src/i18n/skill-detail-locale.test.ts | 7 + web/src/pages/skill-detail.test.tsx | 134 +++++++++++++++++- web/src/pages/skill-detail.tsx | 38 ++++- 11 files changed, 473 insertions(+), 18 deletions(-) create mode 100644 web/e2e/skill-detail-relative-links.spec.ts create mode 100644 web/src/features/skill/package-relative-link.test.ts create mode 100644 web/src/features/skill/package-relative-link.ts diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index f2cb47d6..a6b77adc 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { execFileSync } from 'node:child_process' import path from 'node:path' @@ -65,6 +65,11 @@ export interface SeedSkillOptions { description?: string version?: string readmeHeading?: string + readmeBody?: string + extraFiles?: Array<{ + path: string + content: string + }> } function asApiErrorBody(value: unknown): string { @@ -130,8 +135,13 @@ function buildSkillPackageZipBuffer(suffix: string, options?: SeedSkillOptions): execFileSync('mkdir', ['-p', packageDir]) writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') - writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8') - execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) + writeFileSync(path.join(packageDir, 'README.md'), options?.readmeBody ?? `# ${readmeHeading}\n`, 'utf8') + for (const extraFile of options?.extraFiles ?? []) { + const targetPath = path.join(packageDir, extraFile.path) + mkdirSync(path.dirname(targetPath), { recursive: true }) + writeFileSync(targetPath, extraFile.content, 'utf8') + } + execFileSync('zip', ['-q', '-r', zipPath, '.'], { cwd: packageDir }) return readFileSync(zipPath) } finally { rmSync(tempRoot, { recursive: true, force: true }) @@ -146,8 +156,13 @@ function createSkillPackageZipFile(suffix: string, options?: SeedSkillOptions): execFileSync('mkdir', ['-p', packageDir]) writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') - writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8') - execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) + writeFileSync(path.join(packageDir, 'README.md'), options?.readmeBody ?? `# ${readmeHeading}\n`, 'utf8') + for (const extraFile of options?.extraFiles ?? []) { + const targetPath = path.join(packageDir, extraFile.path) + mkdirSync(path.dirname(targetPath), { recursive: true }) + writeFileSync(targetPath, extraFile.content, 'utf8') + } + execFileSync('zip', ['-q', '-r', zipPath, '.'], { cwd: packageDir }) return { filePath: zipPath, diff --git a/web/e2e/skill-detail-relative-links.spec.ts b/web/e2e/skill-detail-relative-links.spec.ts new file mode 100644 index 00000000..5f1f353c --- /dev/null +++ b/web/e2e/skill-detail-relative-links.spec.ts @@ -0,0 +1,55 @@ +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('Skill Detail Relative Links (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('previews package files from overview relative links and reports missing files', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = `relative-links-${Date.now().toString(36)}` + const skill = await builder.publishSkill(namespace.slug, { + name: skillName, + readmeBody: [ + `# ${skillName}`, + '', + '[Usage](docs/usage.md)', + '', + '[Missing](docs/missing.md)', + ].join('\n'), + extraFiles: [ + { + path: 'docs/usage.md', + content: '# Usage\n\nThis is linked documentation.', + }, + ], + }) + + await page.goto(`/space/${encodeURIComponent(namespace.slug)}/${encodeURIComponent(skill.slug)}`) + + await expect(page).toHaveURL(new RegExp(`/space/${namespace.slug}/${skill.slug}$`)) + await expect(page.getByRole('link', { name: 'Usage' })).toBeVisible() + await page.getByRole('link', { name: 'Usage' }).click() + await expect(page.getByRole('dialog')).toContainText('usage.md') + await expect(page.getByRole('dialog')).toContainText('This is linked documentation.') + + await page.getByRole('button', { name: 'Close' }).click() + await expect(page.getByRole('dialog')).toBeHidden() + + await page.getByRole('link', { name: 'Missing' }).click() + await expect(page).toHaveURL(new RegExp(`/space/${namespace.slug}/${skill.slug}$`)) + await expect(page.getByText('File not found')).toBeVisible() + await expect(page.getByText('not included in the current skill version')).toBeVisible() + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/src/features/skill/markdown-renderer.test.tsx b/web/src/features/skill/markdown-renderer.test.tsx index d7f39c4c..20fe3413 100644 --- a/web/src/features/skill/markdown-renderer.test.tsx +++ b/web/src/features/skill/markdown-renderer.test.tsx @@ -1,5 +1,10 @@ -import { describe, expect, it } from 'vitest' -import { MARKDOWN_IMAGE_CLASS_NAME } from './markdown-renderer' +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MARKDOWN_IMAGE_CLASS_NAME, MarkdownRenderer } from './markdown-renderer' + +afterEach(() => cleanup()) describe('MARKDOWN_IMAGE_CLASS_NAME', () => { it('keeps markdown images at their intrinsic width while remaining responsive', () => { @@ -10,3 +15,21 @@ describe('MARKDOWN_IMAGE_CLASS_NAME', () => { expect(classNames).not.toContain('w-full') }) }) + +describe('MarkdownRenderer links', () => { + it('passes the raw markdown href to the optional link click handler', () => { + const onLinkClick = vi.fn() + + render() + fireEvent.click(screen.getByRole('link', { name: 'Usage' })) + + expect(onLinkClick).toHaveBeenCalledTimes(1) + expect(onLinkClick.mock.calls[0][0]).toBe('docs/usage.md') + }) + + it('keeps links renderable without a click handler', () => { + render() + + expect(screen.getByRole('link', { name: 'Usage' }).getAttribute('href')).toBe('docs/usage.md') + }) +}) diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 1f787021..8bb0c198 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, type MouseEvent } from 'react' import ReactMarkdown from 'react-markdown' import rehypeHighlight from 'rehype-highlight' import rehypeSanitize from 'rehype-sanitize' @@ -12,6 +12,7 @@ export const MARKDOWN_IMAGE_CLASS_NAME = 'h-auto max-w-full' interface MarkdownRendererProps { content: string className?: string + onLinkClick?: (href: string, event: MouseEvent) => void } /** @@ -20,7 +21,7 @@ interface MarkdownRendererProps { * dedicated UI sections and should not appear twice in the document body. * Memoized to prevent re-parsing on every render. */ -export function MarkdownRenderer({ content, className }: MarkdownRendererProps) { +export function MarkdownRenderer({ content, className, onLinkClick }: MarkdownRendererProps) { const containerClassName = [ className, 'max-w-none break-words text-sm text-foreground/90 [overflow-wrap:anywhere]', @@ -45,13 +46,15 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {children}

), - a: ({ className: linkClassName, children, ...props }) => ( + a: ({ className: linkClassName, children, href, ...props }) => ( onLinkClick?.(href ?? '', event)} > {children} diff --git a/web/src/features/skill/package-relative-link.test.ts b/web/src/features/skill/package-relative-link.test.ts new file mode 100644 index 00000000..737be487 --- /dev/null +++ b/web/src/features/skill/package-relative-link.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import type { SkillFile } from '@/api/types' +import { resolvePackageRelativeLink } from './package-relative-link' + +function file(filePath: string): SkillFile { + return { + id: filePath.length, + filePath, + fileSize: 128, + contentType: 'text/markdown', + sha256: `sha-${filePath}`, + } +} + +const packageFiles = [ + file('README.md'), + file('docs/SKILL.md'), + file('docs/usage.md'), + file('shared.md'), + file('space name.md'), + file('使用.md'), +] + +describe('resolvePackageRelativeLink', () => { + it('matches same-directory and explicit current-directory links from the package root', () => { + expect(resolvePackageRelativeLink('docs/usage.md', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'docs/usage.md', + }) + expect(resolvePackageRelativeLink('./docs/usage.md', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'docs/usage.md', + }) + }) + + it('normalizes parent-directory links against the current documentation file', () => { + expect(resolvePackageRelativeLink('../shared.md', 'docs/SKILL.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'shared.md', + }) + }) + + it('keeps fragment information while matching the file path', () => { + expect(resolvePackageRelativeLink('docs/usage.md#intro', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'docs/usage.md', + fragment: 'intro', + }) + }) + + it('decodes encoded file paths before matching package files', () => { + expect(resolvePackageRelativeLink('space%20name.md', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'space name.md', + }) + expect(resolvePackageRelativeLink('%E4%BD%BF%E7%94%A8.md', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: '使用.md', + }) + }) + + it('ignores links that should keep native browser behavior', () => { + for (const href of ['https://example.com', 'mailto:team@example.com', '#intro', '/absolute/path.md', '']) { + expect(resolvePackageRelativeLink(href, 'README.md', packageFiles)).toMatchObject({ + status: 'ignored', + }) + } + }) + + it('returns missing for relative links that do not resolve to a package file', () => { + expect(resolvePackageRelativeLink('docs/missing.md', 'README.md', packageFiles)).toMatchObject({ + status: 'missing', + path: 'docs/missing.md', + }) + expect(resolvePackageRelativeLink('../../outside.md', 'docs/SKILL.md', packageFiles)).toMatchObject({ + status: 'missing', + path: null, + }) + }) +}) diff --git a/web/src/features/skill/package-relative-link.ts b/web/src/features/skill/package-relative-link.ts new file mode 100644 index 00000000..15c7bb25 --- /dev/null +++ b/web/src/features/skill/package-relative-link.ts @@ -0,0 +1,112 @@ +import type { SkillFile } from '@/api/types' + +export type PackageRelativeLinkResolution = + | { + status: 'ignored' + href: string + } + | { + status: 'matched' + href: string + path: string + fragment: string | null + file: SkillFile + } + | { + status: 'missing' + href: string + path: string | null + fragment: string | null + } + +function splitHref(href: string) { + const hashIndex = href.indexOf('#') + const beforeHash = hashIndex >= 0 ? href.slice(0, hashIndex) : href + const fragment = hashIndex >= 0 ? href.slice(hashIndex + 1) : null + const queryIndex = beforeHash.indexOf('?') + return { + path: queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash, + fragment, + } +} + +function decodePath(path: string) { + try { + return decodeURIComponent(path) + } catch { + return path + } +} + +function directoryOf(filePath?: string | null) { + if (!filePath) { + return '' + } + const normalized = filePath.replace(/^\/+/, '') + const lastSlash = normalized.lastIndexOf('/') + return lastSlash >= 0 ? normalized.slice(0, lastSlash) : '' +} + +function normalizePackagePath(baseDirectory: string, relativePath: string) { + const stack: string[] = [] + const rawParts = [...baseDirectory.split('/'), ...relativePath.split('/')] + + for (const part of rawParts) { + if (!part || part === '.') { + continue + } + if (part === '..') { + if (stack.length === 0) { + return null + } + stack.pop() + continue + } + stack.push(part) + } + + return stack.join('/') +} + +function shouldIgnoreLink(href: string, rawPath: string) { + if (!href.trim()) { + return true + } + if (!rawPath || href.startsWith('#')) { + return true + } + if (rawPath.startsWith('/') || rawPath.startsWith('//')) { + return true + } + return /^[a-z][a-z0-9+.-]*:/i.test(rawPath) +} + +export function resolvePackageRelativeLink( + href: string, + currentFilePath: string | null | undefined, + files: SkillFile[] | null | undefined, +): PackageRelativeLinkResolution { + const { path: rawPath, fragment } = splitHref(href) + + if (shouldIgnoreLink(href, rawPath)) { + return { status: 'ignored', href } + } + + const normalizedPath = normalizePackagePath(directoryOf(currentFilePath), decodePath(rawPath)) + if (!normalizedPath) { + return { status: 'missing', href, path: null, fragment } + } + + const matchedFile = (files ?? []).find((file) => file.filePath === normalizedPath) + if (!matchedFile) { + return { status: 'missing', href, path: normalizedPath, fragment } + } + + return { + status: 'matched', + href, + path: normalizedPath, + fragment, + file: matchedFile, + } +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ea5e049d..b63da59f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -790,6 +790,8 @@ "documentationSource": "Source: {{path}}", "documentationUnavailableTitle": "Documentation is unavailable", "documentationUnavailable": "The documentation file could not be loaded. You can still inspect the package contents in the file list.", + "packageLinkMissingTitle": "File not found", + "packageLinkMissingDescription": "This link points to a file that is not included in the current skill version.", "authorLabel": "By {{name}}", "expandOverview": "Expand full overview", "collapseOverview": "Collapse content", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index b8f0cd7a..243ec719 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -790,6 +790,8 @@ "documentationSource": "来源:{{path}}", "documentationUnavailableTitle": "文档暂时不可用", "documentationUnavailable": "当前无法读取这个技能版本的文档文件。你仍然可以在文件列表里查看包内容。", + "packageLinkMissingTitle": "文件未找到", + "packageLinkMissingDescription": "该链接指向的文件不在当前技能版本中。", "authorLabel": "作者 {{name}}", "expandOverview": "展开全文", "collapseOverview": "收起内容", diff --git a/web/src/i18n/skill-detail-locale.test.ts b/web/src/i18n/skill-detail-locale.test.ts index 6434f4d5..a237ef60 100644 --- a/web/src/i18n/skill-detail-locale.test.ts +++ b/web/src/i18n/skill-detail-locale.test.ts @@ -7,4 +7,11 @@ describe('skill detail lifecycle locales', () => { expect(zh.skillDetail.unarchiveSkill).toBe('恢复技能') expect(en.skillDetail.unarchiveSkill).toBe('Restore Skill') }) + + it('defines package relative link missing messages in both locales', () => { + expect(zh.skillDetail.packageLinkMissingTitle).toBe('文件未找到') + expect(zh.skillDetail.packageLinkMissingDescription).toBe('该链接指向的文件不在当前技能版本中。') + expect(en.skillDetail.packageLinkMissingTitle).toBe('File not found') + expect(en.skillDetail.packageLinkMissingDescription).toBe('This link points to a file that is not included in the current skill version.') + }) }) diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index 87bdcb48..1c6374ce 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -1,11 +1,24 @@ +/** @vitest-environment jsdom */ + import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MouseEvent } from 'react' +import type { SkillFile } from '@/api/types' + +const toastMocks = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), +})) const navigateMock = vi.fn() const hasRoleMock = vi.fn<(role: string) => boolean>((role: string) => role === 'USER') const useSkillDetailMock = vi.fn() const useSkillLabelsMock = vi.fn() const useSkillVersionsMock = vi.fn() +const useSkillFilesMock = vi.fn() +const useSkillReadmeMock = vi.fn() +const useSkillFileMock = vi.fn() let authState: { user: { userId: string; platformRoles: string[] } | null hasRole: (role: string) => boolean @@ -47,7 +60,7 @@ vi.mock('@/features/report/use-skill-reports', () => ({ })) vi.mock('@/shared/lib/toast', () => ({ - toast: { success: vi.fn(), error: vi.fn() }, + toast: { success: toastMocks.success, error: toastMocks.error }, })) vi.mock('@/api/client', () => ({ @@ -76,7 +89,47 @@ vi.mock('@/shared/lib/number-format', () => ({ })) vi.mock('@/features/skill/markdown-renderer', () => ({ - MarkdownRenderer: () =>
markdown
, + MarkdownRenderer: ({ + content, + onLinkClick, + }: { + content: string + onLinkClick?: (href: string, event: MouseEvent) => void + }) => ( + + ), +})) + +vi.mock('@/features/skill/file-preview-dialog', () => ({ + FilePreviewDialog: ({ open, node }: { open: boolean; node: { path: string } | null }) => ( + open && node ?
preview:{node.path}
: null + ), })) vi.mock('@/features/skill/file-tree', () => ({ @@ -107,9 +160,9 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useDetachSkillLabel: () => ({ mutate: vi.fn(), isPending: false }), useSkillVersions: (...args: unknown[]) => useSkillVersionsMock(...args), useSkillVersionDetail: () => ({ data: undefined }), - useSkillFiles: () => ({ data: [] }), - useSkillReadme: () => ({ data: '# Demo', error: null }), - useSkillFile: () => ({ data: null, isLoading: false, error: null }), + useSkillFiles: (...args: unknown[]) => useSkillFilesMock(...args), + useSkillReadme: (...args: unknown[]) => useSkillReadmeMock(...args), + useSkillFile: (...args: unknown[]) => useSkillFileMock(...args), useArchiveSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), useDeleteSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), useDeleteSkillVersion: () => ({ mutateAsync: vi.fn(), isPending: false }), @@ -165,9 +218,26 @@ function createSkill(overrides: Record = {}) { } } +function createSkillFile(filePath: string): SkillFile { + return { + id: filePath.length, + filePath, + fileSize: 128, + contentType: 'text/markdown', + sha256: `sha-${filePath}`, + } +} + describe('SkillDetailPage', () => { + afterEach(() => cleanup()) + beforeEach(() => { navigateMock.mockReset() + useSkillFilesMock.mockReset() + useSkillReadmeMock.mockReset() + useSkillFileMock.mockReset() + toastMocks.success.mockReset() + toastMocks.error.mockReset() hasRoleMock.mockImplementation((role: string) => role === 'USER') authState = { user: { userId: 'owner-1', platformRoles: ['USER'] }, @@ -196,6 +266,9 @@ describe('SkillDetailPage', () => { useSkillLabelsMock.mockReturnValue({ data: undefined, }) + useSkillFilesMock.mockReturnValue({ data: [] }) + useSkillReadmeMock.mockReturnValue({ data: '# Demo', error: null }) + useSkillFileMock.mockReturnValue({ data: null, isLoading: false, error: null }) }) it('shows hard delete action for the skill owner', () => { @@ -401,4 +474,53 @@ describe('SkillDetailPage', () => { expect(html).toContain('break-all') expect(html).toContain('leading-snug') }) + + it('opens a file preview when overview markdown relative link matches a package file', () => { + useSkillFilesMock.mockReturnValue({ + data: [ + createSkillFile('README.md'), + createSkillFile('docs/usage.md'), + ], + }) + + render() + fireEvent.click(screen.getByRole('link', { name: 'Usage' })) + + expect(screen.getByRole('dialog').textContent).toContain('preview:docs/usage.md') + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('keeps the viewer on the detail page and shows a toast for missing package files', () => { + useSkillFilesMock.mockReturnValue({ + data: [ + createSkillFile('README.md'), + createSkillFile('docs/usage.md'), + ], + }) + + render() + fireEvent.click(screen.getByRole('link', { name: 'Missing' })) + + expect(screen.queryByRole('dialog')).toBeNull() + expect(toastMocks.error).toHaveBeenCalledWith( + 'skillDetail.packageLinkMissingTitle', + 'skillDetail.packageLinkMissingDescription', + ) + }) + + it('leaves external links and same-document anchors alone', () => { + useSkillFilesMock.mockReturnValue({ + data: [ + createSkillFile('README.md'), + createSkillFile('docs/usage.md'), + ], + }) + + render() + fireEvent.click(screen.getByRole('link', { name: 'External' })) + fireEvent.click(screen.getByRole('link', { name: 'Anchor' })) + + expect(screen.queryByRole('dialog')).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) }) diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 43c05b7f..77b0c291 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -1,12 +1,14 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type MouseEvent } from 'react' import { useTranslation } from 'react-i18next' import { useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router' import { useMutation, useQueryClient } from '@tanstack/react-query' import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' +import { resolvePackageRelativeLink } from '@/features/skill/package-relative-link' import { FileTree } from '@/features/skill/file-tree' import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' import type { FileTreeNode } from '@/features/skill/file-tree-builder' +import type { SkillFile } from '@/api/types' import { InstallCommand } from '@/features/skill/install-command' import { ShareButton } from '@/features/skill/share-button' import { SkillLabelPanel } from '@/features/skill/skill-label-panel' @@ -87,6 +89,20 @@ function parseMetadataJson(parsed?: string) { } } +function createPackageFilePreviewNode(file: SkillFile): FileTreeNode { + const pathParts = file.filePath.split('/').filter(Boolean) + const name = pathParts[pathParts.length - 1] ?? file.filePath + + return { + id: file.filePath, + name, + path: file.filePath, + type: 'file', + file, + depth: Math.max(pathParts.length - 1, 0), + } +} + function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | null { if (error.serverMessageKey === 'promotion.duplicate_pending') { return 'promotion.duplicate_pending' @@ -284,6 +300,24 @@ export function SkillDetailPage() { setPreviewDialogOpen(true) } + const handleOverviewLinkClick = (href: string, event: MouseEvent) => { + const resolution = resolvePackageRelativeLink(href, documentationPath, files) + + if (resolution.status === 'ignored') { + return + } + + event.preventDefault() + + if (resolution.status === 'matched') { + setPreviewNode(createPackageFilePreviewNode(resolution.file)) + setPreviewDialogOpen(true) + return + } + + toast.error(t('skillDetail.packageLinkMissingTitle'), t('skillDetail.packageLinkMissingDescription')) + } + // Download a single file from the skill version const handleDownloadFile = () => { const isAnonymousAllowed = namespace === 'global' && skill?.visibility === 'PUBLIC' @@ -844,7 +878,7 @@ export function SkillDetailPage() { style={!isOverviewExpanded && isOverviewCollapsible ? { maxHeight: `${overviewMaxHeight}px` } : undefined} >
- +
{!isOverviewExpanded && isOverviewCollapsible ? (
From 04348f5022ea4ed8e8973e5e469c602f0bf96605 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 18:07:15 +0800 Subject: [PATCH 15/19] =?UTF-8?q?chore:=20sync=20schema.d.ts=20=E2=80=94?= =?UTF-8?q?=20remove=20namespace=20bundle=20download=20paths=20and=20opera?= =?UTF-8?q?tions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: dongmucat <1127093059@qq.com> --- web/src/api/generated/schema.d.ts | 80 ------------------------------- 1 file changed, 80 deletions(-) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 4e9631dc..9e056dff 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -2532,38 +2532,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/namespaces/{slug}/skills/download": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["downloadNamespaceSkills"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/web/namespaces/{slug}/skills/download": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["downloadNamespaceSkills_1"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/web/namespaces/{slug}/member-candidates": { parameters: { query?: never; @@ -9822,54 +9790,6 @@ export interface operations { }; }; }; - downloadNamespaceSkills: { - parameters: { - query?: { - skill?: string[]; - }; - header?: never; - path: { - slug: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": string; - }; - }; - }; - }; - downloadNamespaceSkills_1: { - parameters: { - query?: { - skill?: string[]; - }; - header?: never; - path: { - slug: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": string; - }; - }; - }; - }; searchMemberCandidates: { parameters: { query: { From 201e63685837f14fdccfa6b6a4a3c9c5b80f1457 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 18:27:50 +0800 Subject: [PATCH 16/19] test(web): add namespace search-only regression Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/namespace-search-download.spec.ts | 311 --------------------- web/e2e/namespace-search.spec.ts | 106 +++++++ web/src/features/search/search-bar.test.ts | 2 +- web/src/features/search/search-bar.tsx | 4 +- web/src/pages/dashboard/my-skills.tsx | 8 +- web/src/shared/lib/search-query.test.ts | 10 + web/src/shared/lib/search-query.ts | 8 +- 7 files changed, 128 insertions(+), 321 deletions(-) delete mode 100644 web/e2e/namespace-search-download.spec.ts create mode 100644 web/e2e/namespace-search.spec.ts diff --git a/web/e2e/namespace-search-download.spec.ts b/web/e2e/namespace-search-download.spec.ts deleted file mode 100644 index 163b44a7..00000000 --- a/web/e2e/namespace-search-download.spec.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { expect, test, type Page } from '@playwright/test' -import { setEnglishLocale } from './helpers/auth-fixtures' - -const namespaceSlug = 'product-managers' - -const skillFixtures = [ - { - id: 7101, - slug: 'roadmap-agent', - displayName: 'Roadmap Agent', - summary: 'Turns product strategy into roadmap drafts.', - downloadCount: 12, - starCount: 3, - ratingAvg: 4.8, - ratingCount: 4, - namespace: namespaceSlug, - updatedAt: '2026-06-01T00:00:00Z', - canSubmitPromotion: false, - headlineVersion: { id: 8101, version: '1.0.0', status: 'PUBLISHED' }, - publishedVersion: { id: 8101, version: '1.0.0', status: 'PUBLISHED' }, - }, - { - id: 7102, - slug: 'requirements-agent', - displayName: 'Requirements Agent', - summary: 'Helps product managers refine user stories.', - downloadCount: 8, - starCount: 2, - ratingAvg: 4.5, - ratingCount: 2, - namespace: namespaceSlug, - updatedAt: '2026-06-02T00:00:00Z', - canSubmitPromotion: false, - headlineVersion: { id: 8102, version: '1.1.0', status: 'PUBLISHED' }, - publishedVersion: { id: 8102, version: '1.1.0', status: 'PUBLISHED' }, - }, - { - id: 7201, - slug: 'backend-agent', - displayName: 'Backend Agent', - summary: 'A skill outside the selected namespace.', - downloadCount: 20, - starCount: 6, - ratingAvg: 4.2, - ratingCount: 5, - namespace: 'developers', - updatedAt: '2026-06-03T00:00:00Z', - canSubmitPromotion: false, - headlineVersion: { id: 8201, version: '2.0.0', status: 'PUBLISHED' }, - publishedVersion: { id: 8201, version: '2.0.0', status: 'PUBLISHED' }, - }, -] - -function envelope(data: unknown, code = 0, msg = 'success') { - return JSON.stringify({ - code, - msg, - data, - timestamp: '2026-06-04T00:00:00Z', - requestId: 'e2e-namespace-search-download', - }) -} - -async function mockCommonApi(page: Page, options?: { authenticated?: boolean }) { - await page.route('**/api/v1/auth/me', async (route) => { - if (options?.authenticated) { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope({ - userId: 'e2e-product-manager', - displayName: 'E2E Product Manager', - email: 'pm@example.com', - platformRoles: [], - }), - }) - return - } - - await route.fulfill({ - status: 401, - contentType: 'application/json', - body: envelope(null, 401, 'Unauthorized'), - }) - }) - await page.route('**/api/v1/auth/providers**', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope([]), - }) - }) - await page.route('**/api/v1/auth/methods**', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope([]), - }) - }) - await page.route('**/api/web/labels', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope([]), - }) - }) - await page.route('**/api/web/me/namespaces', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope([]), - }) - }) - await page.route('**/api/web/notifications/unread-count', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope({ count: 0 }), - }) - }) - await page.route('**/api/web/notifications/sse', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: '', - }) - }) - await page.route(/\/api\/web\/skills\/\d+\/star$/, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope(false), - }) - }) -} - -async function mockSearchApi(page: Page) { - const requests: URL[] = [] - - await page.route(/\/api\/web\/skills\?/, async (route) => { - const url = new URL(route.request().url()) - requests.push(url) - - const q = (url.searchParams.get('q') ?? '').trim().toLowerCase() - const namespace = (url.searchParams.get('namespace') ?? '').trim().toLowerCase() - const pageNumber = Number(url.searchParams.get('page') ?? '0') - const pageSize = Number(url.searchParams.get('size') ?? '12') - const items = skillFixtures.filter((skill) => { - const matchesNamespace = !namespace || skill.namespace === namespace - const matchesQuery = !q - || skill.displayName.toLowerCase().includes(q) - || skill.summary.toLowerCase().includes(q) - || skill.slug.toLowerCase().includes(q) - return matchesNamespace && matchesQuery - }) - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope({ - items, - total: items.length, - page: pageNumber, - size: pageSize, - }), - }) - }) - - return requests -} - -async function mockNamespaceApi(page: Page) { - await page.route(`**/api/web/namespaces/${namespaceSlug}`, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: envelope({ - id: 5101, - slug: namespaceSlug, - displayName: 'Product Managers', - description: 'Skills curated for product and requirements work.', - type: 'TEAM', - status: 'ACTIVE', - createdAt: '2026-06-01T00:00:00Z', - updatedAt: '2026-06-02T00:00:00Z', - }), - }) - }) - - await page.route(`**/api/web/namespaces/${namespaceSlug}/skills/download**`, async (route) => { - await route.fulfill({ - status: 200, - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${namespaceSlug}-skills.zip"`, - }, - body: 'PK', - }) - }) -} - -test.describe('Namespace Search and Download', () => { - test.beforeEach(async ({ page }) => { - await setEnglishLocale(page) - }) - - test('submits @namespace search input as separate namespace and keyword URL parameters', async ({ page }) => { - await mockCommonApi(page) - const requests = await mockSearchApi(page) - - await page.goto('/search') - await page.getByPlaceholder('Search skills...').fill(`@${namespaceSlug} roadmap`) - await page.getByRole('button', { name: 'Search', exact: true }).click() - - await expect(page).toHaveURL(new RegExp(`namespace=${namespaceSlug}`)) - await expect(page).toHaveURL(/q=roadmap/) - await expect(page.getByRole('button', { name: `@${namespaceSlug}` })).toBeVisible() - await expect(page.getByRole('heading', { name: 'Roadmap Agent' })).toBeVisible() - await expect(page.getByRole('heading', { name: 'Backend Agent' })).toHaveCount(0) - await expect.poll(() => requests.some((url) => - url.searchParams.get('namespace') === namespaceSlug - && url.searchParams.get('q') === 'roadmap', - )).toBe(true) - }) - - test('clears the namespace filter while preserving the keyword and sort mode', async ({ page }) => { - await mockCommonApi(page) - const requests = await mockSearchApi(page) - - await page.goto(`/search?q=roadmap&namespace=${namespaceSlug}&sort=downloads&page=1&starredOnly=false`) - await page.getByRole('button', { name: `@${namespaceSlug}` }).click() - - await expect(page).toHaveURL(/q=roadmap/) - await expect(page).toHaveURL(/sort=downloads/) - await expect(page).toHaveURL(/page=0/) - await expect(page).not.toHaveURL(new RegExp(`namespace=${namespaceSlug}`)) - await expect.poll(() => requests.some((url) => - url.searchParams.get('q') === 'roadmap' - && !url.searchParams.has('namespace') - && url.searchParams.get('sort') === 'downloads', - )).toBe(true) - }) - - test('copies the current page install manifest and gates selected download until a skill is checked', async ({ page, context }) => { - await context.grantPermissions(['clipboard-read', 'clipboard-write']) - await mockCommonApi(page, { authenticated: true }) - await mockSearchApi(page) - await mockNamespaceApi(page) - - await page.goto(`/space/${namespaceSlug}`) - - const selectedDownloadButton = page.getByRole('button', { name: 'Download selected on this page' }) - await expect(selectedDownloadButton).toBeDisabled() - - await page.getByLabel('Select Roadmap Agent').check() - await expect(selectedDownloadButton).toBeEnabled() - - await page.getByRole('button', { name: 'Copy current page install list' }).click() - const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) - - expect(clipboardText).toContain(`skillhub install ${namespaceSlug}--roadmap-agent`) - expect(clipboardText).toContain(`skillhub install ${namespaceSlug}--requirements-agent`) - }) - - test('downloads only selected namespace skills with skill query parameters', async ({ page }) => { - await mockCommonApi(page, { authenticated: true }) - await mockSearchApi(page) - await mockNamespaceApi(page) - - await page.goto(`/space/${namespaceSlug}`) - await page.getByLabel('Select Roadmap Agent').check() - - await page.getByRole('button', { name: 'Download selected on this page' }).click() - await expect(page.getByRole('dialog', { name: 'Confirm namespace download' })).toBeVisible() - await expect(page.getByText('This will request up to 1 skill package from @product-managers.')).toBeVisible() - await expect(page.getByText('Unavailable skills may be skipped. Synchronous downloads are limited to 20 skills and 100 MB.')).toBeVisible() - - const [request, response] = await Promise.all([ - page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), - page.waitForResponse((response) => response.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), - page.getByRole('button', { name: 'Download', exact: true }).click(), - ]) - - const downloadUrl = new URL(request.url()) - expect(response.headers()['content-disposition']).toContain(`${namespaceSlug}-skills.zip`) - expect(downloadUrl.searchParams.getAll('skill')).toEqual(['roadmap-agent']) - }) - - test('downloads the full namespace bundle without skill query parameters', async ({ page }) => { - await mockCommonApi(page, { authenticated: true }) - await mockSearchApi(page) - await mockNamespaceApi(page) - - await page.goto(`/space/${namespaceSlug}`) - - await page.getByRole('button', { name: 'Download all' }).click() - await expect(page.getByRole('dialog', { name: 'Confirm namespace download' })).toBeVisible() - await expect(page.getByText('This will request up to 2 skill packages from @product-managers.')).toBeVisible() - await expect(page.getByText('Unavailable skills may be skipped. Synchronous downloads are limited to 20 skills and 100 MB.')).toBeVisible() - - const [request, response] = await Promise.all([ - page.waitForRequest((request) => request.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), - page.waitForResponse((response) => response.url().includes(`/api/web/namespaces/${namespaceSlug}/skills/download`)), - page.getByRole('button', { name: 'Download', exact: true }).click(), - ]) - - const downloadUrl = new URL(request.url()) - expect(response.headers()['content-disposition']).toContain(`${namespaceSlug}-skills.zip`) - expect(downloadUrl.searchParams.getAll('skill')).toEqual([]) - }) -}) diff --git a/web/e2e/namespace-search.spec.ts b/web/e2e/namespace-search.spec.ts new file mode 100644 index 00000000..39a48ec1 --- /dev/null +++ b/web/e2e/namespace-search.spec.ts @@ -0,0 +1,106 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +function waitForSkillSearch(page: Page, options: { namespace?: string; q?: string; sort?: string }) { + return page.waitForResponse((response) => { + if (!response.ok() || !response.url().includes('/api/web/skills?')) { + return false + } + + const url = new URL(response.url()) + const namespace = url.searchParams.get('namespace') ?? '' + const query = url.searchParams.get('q') ?? '' + const sort = url.searchParams.get('sort') ?? '' + + return namespace === (options.namespace ?? '') + && query === (options.q ?? '') + && (!options.sort || sort === options.sort) + }) +} + +test.describe('Namespace Search (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', + }) + }) + + test('submits @namespace keyword search and clears the namespace filter', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.createNamespace('e2e-pm-search') + const otherNamespace = await builder.createNamespace('e2e-dev-search') + const namespaceSkill = await builder.publishSkill(namespace.slug, { + name: 'roadmap-discovery', + description: 'Roadmap planning skill for namespace search regression.', + }) + const otherSkill = await builder.publishSkill(otherNamespace.slug, { + name: 'roadmap-backend', + description: 'Roadmap planning skill outside the selected namespace.', + }) + await builder.waitForSearchResults('roadmap', [namespaceSkill.slug, otherSkill.slug]) + + await page.goto('/search') + await page.getByPlaceholder('Search skills...').fill(`@${namespace.slug} roadmap`) + + const filteredSearch = waitForSkillSearch(page, { namespace: namespace.slug, q: 'roadmap' }) + await page.getByRole('button', { name: 'Search', exact: true }).click() + await filteredSearch + + await expect(page).toHaveURL(new RegExp(`namespace=${namespace.slug}`)) + await expect(page).toHaveURL(/q=roadmap/) + await expect(page.getByRole('button', { name: `@${namespace.slug}` })).toBeVisible() + await expect(page.getByRole('heading', { name: namespaceSkill.slug })).toBeVisible() + await expect(page.getByText(`@${otherNamespace.slug}`)).toHaveCount(0) + + await page.goto(`/search?q=roadmap&namespace=${namespace.slug}&sort=downloads&page=1&starredOnly=false`) + await expect(page.getByRole('button', { name: `@${namespace.slug}` })).toBeVisible() + + const unfilteredSearch = waitForSkillSearch(page, { q: 'roadmap', sort: 'downloads' }) + await page.getByRole('button', { name: `@${namespace.slug}` }).click() + await unfilteredSearch + + await expect(page).toHaveURL(/q=roadmap/) + await expect(page).toHaveURL(/sort=downloads/) + await expect(page).toHaveURL(/page=0/) + await expect(page).not.toHaveURL(new RegExp(`namespace=${namespace.slug}`)) + await expect(page.getByRole('heading', { name: namespaceSkill.slug })).toBeVisible() + await expect(page.getByRole('heading', { name: otherSkill.slug })).toBeVisible() + } finally { + await builder.cleanup() + } + }) + + test('supports a sixty-four character namespace slug in search input', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.createNamespace('e2e-namespace-64-slug-search-case-alphaab') + expect(namespace.slug).toHaveLength(64) + const skill = await builder.publishSkill(namespace.slug, { + name: 'boundary-search-agent', + description: 'Boundary namespace search regression skill.', + }) + await builder.waitForSearchResult('boundary', skill.slug) + + await page.goto('/search') + await page.getByPlaceholder('Search skills...').fill(`@${namespace.slug} boundary`) + + const filteredSearch = waitForSkillSearch(page, { namespace: namespace.slug, q: 'boundary' }) + await page.getByRole('button', { name: 'Search', exact: true }).click() + await filteredSearch + + await expect(page).toHaveURL(new RegExp(`namespace=${namespace.slug}`)) + await expect(page).toHaveURL(/q=boundary/) + await expect(page.getByRole('button', { name: `@${namespace.slug}` })).toBeVisible() + await expect(page.getByRole('heading', { name: skill.slug })).toBeVisible() + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/src/features/search/search-bar.test.ts b/web/src/features/search/search-bar.test.ts index 199a607d..1ca7ec20 100644 --- a/web/src/features/search/search-bar.test.ts +++ b/web/src/features/search/search-bar.test.ts @@ -3,7 +3,7 @@ import * as mod from './search-bar' /** * search-bar.tsx exports the SearchBar component. The component delegates - * its max-length constraint to the shared MAX_SEARCH_QUERY_LENGTH constant + * its max-length constraint to the shared namespace-aware search input limit * (tested in search-query.test.ts). Controlled/uncontrolled mode logic and * submit/clear handlers are component-internal with no exported helpers. * diff --git a/web/src/features/search/search-bar.tsx b/web/src/features/search/search-bar.tsx index 94be3f25..4978a530 100644 --- a/web/src/features/search/search-bar.tsx +++ b/web/src/features/search/search-bar.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Loader2, Search, X } from 'lucide-react' -import { MAX_SEARCH_QUERY_LENGTH } from '@/shared/lib/search-query' +import { MAX_SEARCH_INPUT_LENGTH } from '@/shared/lib/search-query' import { Input } from '@/shared/ui/input' import { Button } from '@/shared/ui/button' @@ -59,7 +59,7 @@ export function SearchBar({ defaultValue = '', value, placeholder, isSearching = type="text" value={currentQuery} onChange={(e) => handleChange(e.target.value)} - maxLength={MAX_SEARCH_QUERY_LENGTH} + maxLength={MAX_SEARCH_INPUT_LENGTH} placeholder={placeholder || t('searchBar.placeholder')} className="pl-10 pr-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12" /> diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 0d90f928..4dbe4765 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useLocation, useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' @@ -64,20 +64,20 @@ export function MySkillsPage() { const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null) - const updateSearch = (next: Partial, options?: { replace?: boolean }) => { + const updateSearch = useCallback((next: Partial, options?: { replace?: boolean }) => { navigate({ to: '/dashboard/skills', search: (prev) => ({ ...prev, ...next }), replace: options?.replace, }) - } + }, [navigate]) // Push the debounced keyword to the URL (reset page to 0 when search changes) useEffect(() => { if (debouncedKeyword !== keyword) { updateSearch({ q: debouncedKeyword || undefined, page: 0 }, { replace: true }) } - }, [debouncedKeyword]) + }, [debouncedKeyword, keyword, updateSearch]) // Sync keywordInput when navigating back via returnTo useEffect(() => { diff --git a/web/src/shared/lib/search-query.test.ts b/web/src/shared/lib/search-query.test.ts index 1643793a..5a6cbc86 100644 --- a/web/src/shared/lib/search-query.test.ts +++ b/web/src/shared/lib/search-query.test.ts @@ -29,6 +29,16 @@ describe('parseNamespaceSearchInput', () => { }) }) + it('extracts a sixty-four character namespace before limiting the keyword', () => { + const namespace = 'a'.repeat(64) + const query = 'release-notes '.repeat(8) + + expect(parseNamespaceSearchInput(`@${namespace} ${query}`)).toEqual({ + namespace, + query: query.trim().slice(0, MAX_SEARCH_QUERY_LENGTH), + }) + }) + it('leaves ordinary search text unchanged', () => { expect(parseNamespaceSearchInput('meeting assistant')).toEqual({ namespace: '', diff --git a/web/src/shared/lib/search-query.ts b/web/src/shared/lib/search-query.ts index 24f920fa..f816ed3f 100644 --- a/web/src/shared/lib/search-query.ts +++ b/web/src/shared/lib/search-query.ts @@ -1,4 +1,6 @@ export const MAX_SEARCH_QUERY_LENGTH = 50 +export const MAX_NAMESPACE_SLUG_LENGTH = 64 +export const MAX_SEARCH_INPUT_LENGTH = MAX_NAMESPACE_SLUG_LENGTH + MAX_SEARCH_QUERY_LENGTH + 2 export function normalizeSearchQuery(query: string): string { return query.trim().slice(0, MAX_SEARCH_QUERY_LENGTH) @@ -12,10 +14,10 @@ export interface NamespaceSearchInput { const LEADING_NAMESPACE_PATTERN = /^@([a-zA-Z0-9][a-zA-Z0-9-]{0,63})(?:\s+|$)(.*)$/ export function parseNamespaceSearchInput(input: string): NamespaceSearchInput { - const normalized = normalizeSearchQuery(input) - const match = normalized.match(LEADING_NAMESPACE_PATTERN) + const trimmed = input.trim() + const match = trimmed.match(LEADING_NAMESPACE_PATTERN) if (!match) { - return { namespace: '', query: normalized } + return { namespace: '', query: normalizeSearchQuery(trimmed) } } return { From 0298823d069c8c5aea177708cab6ac9386c953a6 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 18:48:05 +0800 Subject: [PATCH 17/19] fix(skill): remove namespace bundle backend residues Signed-off-by: dongmucat <1127093059@qq.com> --- .../portal/NamespaceController.java | 30 -- .../src/main/resources/messages.properties | 4 - .../src/main/resources/messages_zh.properties | 4 - .../portal/SkillControllerDownloadTest.java | 25 -- .../policy/RouteSecurityPolicyRegistry.java | 4 - .../RouteSecurityPolicyRegistryTest.java | 18 +- .../skill/service/SkillDownloadService.java | 156 --------- .../service/SkillDownloadServiceTest.java | 295 ------------------ 8 files changed, 9 insertions(+), 527 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 461b086d..69be5fa3 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 @@ -3,7 +3,6 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.BatchMemberRequest; @@ -19,7 +18,6 @@ import com.iflytek.skillhub.dto.NamespaceResponse; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.TransferOwnershipRequest; import com.iflytek.skillhub.dto.UpdateMemberRoleRequest; -import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.GovernanceWorkflowAppService; import com.iflytek.skillhub.service.NamespacePortalCommandAppService; @@ -27,11 +25,7 @@ import com.iflytek.skillhub.service.NamespacePortalQueryAppService; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; -import org.springframework.core.io.InputStreamResource; import org.springframework.data.domain.Pageable; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -50,20 +44,17 @@ public class NamespaceController extends BaseApiController { private final NamespacePortalCommandAppService namespacePortalCommandAppService; private final NamespaceMemberCandidateService namespaceMemberCandidateService; private final GovernanceWorkflowAppService governanceWorkflowAppService; - private final SkillDownloadService skillDownloadService; public NamespaceController(NamespacePortalQueryAppService namespacePortalQueryAppService, NamespacePortalCommandAppService namespacePortalCommandAppService, NamespaceMemberCandidateService namespaceMemberCandidateService, GovernanceWorkflowAppService governanceWorkflowAppService, - SkillDownloadService skillDownloadService, ApiResponseFactory responseFactory) { super(responseFactory); this.namespacePortalQueryAppService = namespacePortalQueryAppService; this.namespacePortalCommandAppService = namespacePortalCommandAppService; this.namespaceMemberCandidateService = namespaceMemberCandidateService; this.governanceWorkflowAppService = governanceWorkflowAppService; - this.skillDownloadService = skillDownloadService; } @GetMapping("/namespaces") @@ -178,27 +169,6 @@ public class NamespaceController extends BaseApiController { return ok("response.success.read", namespaceMemberCandidateService.searchCandidates(slug, search, userId, size)); } - @GetMapping("/namespaces/{slug}/skills/download") - @RateLimit(category = "download", authenticated = 30, anonymous = 10) - public ResponseEntity downloadNamespaceSkills( - @PathVariable String slug, - @RequestParam(name = "skill", required = false) List selectedSkills, - @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - - SkillDownloadService.DownloadResult result = skillDownloadService.downloadNamespaceBundle( - slug, - selectedSkills != null ? selectedSkills : List.of(), - userId, - userNsRoles != null ? userNsRoles : Map.of()); - - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") - .contentType(MediaType.parseMediaType(result.contentType())) - .contentLength(result.contentLength()) - .body(new InputStreamResource(result.openContent())); - } - @PostMapping("/namespaces/{slug}/members") public ApiResponse addMember( @PathVariable String slug, diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index cbe51214..be3e2ebe 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -143,10 +143,6 @@ error.skill.version.submit.notUploaded=Version ''{0}'' is not in UPLOADED status error.skill.version.confirm.notUploaded=Version ''{0}'' is not in UPLOADED status and cannot be confirmed error.skill.confirm.notPrivate=Only PRIVATE skills can use confirm-publish error.skill.version.notDownloadable=Version ''{0}'' is not available for download -error.namespace.skills.download.empty=No downloadable skills found in namespace ''{0}'' -error.namespace.skills.download.selectionRequired=Anonymous namespace bundle downloads require explicit skill selection -error.namespace.skills.download.tooMany=Namespace bundle download supports up to {0} skills at a time -error.namespace.skills.download.tooLarge=Namespace bundle download supports up to {0} bytes at a time # Profile update error.profile.displayName.length=Display name must be between 2 and 32 characters diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 55c35ce8..cef09563 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -143,10 +143,6 @@ error.skill.version.submit.notUploaded=版本"{0}"不在 UPLOADED 状态,无 error.skill.version.confirm.notUploaded=版本"{0}"不在 UPLOADED 状态,无法确认发布 error.skill.confirm.notPrivate=只有 PRIVATE 技能可以使用确认发布功能 error.skill.version.notDownloadable=版本"{0}"不可下载 -error.namespace.skills.download.empty=命名空间“{0}”下没有可下载的技能 -error.namespace.skills.download.selectionRequired=匿名命名空间批量下载需要显式选择技能 -error.namespace.skills.download.tooMany=命名空间批量下载一次最多支持 {0} 个技能 -error.namespace.skills.download.tooLarge=命名空间批量下载一次最多支持 {0} 字节 # 用户资料修改 error.profile.displayName.length=昵称长度需在 2-32 个字符之间 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java index 4804537c..8945d2a9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java @@ -16,7 +16,6 @@ import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimiter; import java.io.ByteArrayInputStream; -import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -200,28 +199,4 @@ class SkillControllerDownloadTest { 120, 60); } - - @Test - void downloadNamespaceBundle_streamsSelectedNamespaceSkills() throws Exception { - given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadNamespaceBundle("team-ai", List.of("alpha"), "test-user", java.util.Map.of())) - .willReturn(new SkillDownloadService.DownloadResult( - () -> new ByteArrayInputStream("zip".getBytes()), - "team-ai-skills.zip", - 3L, - "application/zip", - null, - false - )); - - mockMvc.perform(get("/api/web/namespaces/team-ai/skills/download") - .param("skill", "alpha") - .with(user("test-user")) - .requestAttr("userId", "test-user") - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(header().string("Content-Disposition", "attachment; filename=\"team-ai-skills.zip\"")); - - verify(skillDownloadService).downloadNamespaceBundle("team-ai", List.of("alpha"), "test-user", java.util.Map.of()); - } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 4747814f..5ac6c1d1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -52,7 +52,6 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/files"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/file"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/labels"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces/*/skills/download"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions"), @@ -68,7 +67,6 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/files"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/file"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/labels"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces/*/skills/download"), RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/id/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"), @@ -100,10 +98,8 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/skills/**"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills/**"), - ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*/skills/download"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*"), - ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*/skills/download"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resolve/**"), diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index d46d8b9d..8499ee4d 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -74,20 +74,20 @@ class RouteSecurityPolicyRegistryTest { } @Test - void authorizationPolicies_shouldKeepNamespaceDownloadRoutesAnonymous() { + void authorizationPolicies_shouldNotDeclareNamespaceBundleDownloadRoutes() { + String v1Route = "/api/v1/namespaces/*/skills/" + "download"; + String webRoute = "/api/web/namespaces/*/skills/" + "download"; boolean matchedV1 = registry.authorizationPolicies().stream() .anyMatch(policy -> policy.method() == HttpMethod.GET - && "/api/v1/namespaces/*/skills/download".equals(policy.pattern()) - && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL); + && v1Route.equals(policy.pattern())); boolean matchedWeb = registry.authorizationPolicies().stream() .anyMatch(policy -> policy.method() == HttpMethod.GET - && "/api/web/namespaces/*/skills/download".equals(policy.pattern()) - && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL); + && webRoute.equals(policy.pattern())); - assertTrue(matchedV1); - assertTrue(matchedWeb); - assertTrue(registry.authorizeApiToken("GET", "/api/v1/namespaces/global/skills/download", Set.of()).allowed()); - assertTrue(registry.authorizeApiToken("GET", "/api/web/namespaces/global/skills/download", Set.of()).allowed()); + assertFalse(matchedV1); + assertFalse(matchedWeb); + assertFalse(registry.authorizeApiToken("GET", "/api/v1/namespaces/global/skills/" + "download", Set.of()).allowed()); + assertFalse(registry.authorizeApiToken("GET", "/api/web/namespaces/global/skills/" + "download", Set.of()).allowed()); } @Test diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 27e9c376..b53a6c63 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -19,10 +19,8 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.time.Duration; import java.util.Comparator; -import java.util.HashSet; import java.util.Map; import java.util.List; -import java.util.Set; import java.util.function.Supplier; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -36,8 +34,6 @@ import java.util.zip.ZipOutputStream; @Service public class SkillDownloadService { private static final Logger log = LoggerFactory.getLogger(SkillDownloadService.class); - private static final int MAX_NAMESPACE_BUNDLE_SKILL_COUNT = 20; - private static final long MAX_NAMESPACE_BUNDLE_TOTAL_BYTES = 100L * 1024 * 1024; private final NamespaceRepository namespaceRepository; private final SkillRepository skillRepository; @@ -165,104 +161,6 @@ public class SkillDownloadService { return buildDownloadResult(skill, version); } - /** - * Builds one namespace-level archive containing each selected skill as its - * own versioned zip bundle. - */ - public DownloadResult downloadNamespaceBundle( - String namespaceSlug, - List selectedSkillSlugs, - String currentUserId, - Map userNsRoles) { - - Namespace namespace = findNamespace(namespaceSlug); - Set selected = selectedSkillSlugs == null - ? Set.of() - : new HashSet<>(selectedSkillSlugs.stream() - .filter(slug -> slug != null && !slug.isBlank()) - .map(slug -> slug.trim().replaceFirst("^@", "")) - .toList()); - - if (currentUserId == null && selected.isEmpty()) { - throw new DomainBadRequestException("error.namespace.skills.download.selectionRequired"); - } - - List candidates = skillRepository.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE) - .stream() - .filter(skill -> selected.isEmpty() || selected.contains(skill.getSlug())) - .sorted(Comparator.comparing(Skill::getSlug)) - .map(skill -> toNamespaceBundleCandidate(namespace, skill, currentUserId, userNsRoles)) - .flatMap(java.util.Optional::stream) - .toList(); - - if (candidates.isEmpty()) { - throw new DomainBadRequestException("error.namespace.skills.download.empty", namespaceSlug); - } - - if (candidates.size() > MAX_NAMESPACE_BUNDLE_SKILL_COUNT) { - throw new DomainBadRequestException( - "error.namespace.skills.download.tooMany", - MAX_NAMESPACE_BUNDLE_SKILL_COUNT - ); - } - - long estimatedBundleBytes = candidates.stream() - .mapToLong(this::estimateNamespaceBundleEntryBytes) - .sum(); - if (estimatedBundleBytes > MAX_NAMESPACE_BUNDLE_TOTAL_BYTES) { - throw new DomainBadRequestException( - "error.namespace.skills.download.tooLarge", - MAX_NAMESPACE_BUNDLE_TOTAL_BYTES - ); - } - - List entries = candidates.stream() - .map(candidate -> new NamespaceBundleEntry( - candidate.skill(), - candidate.version(), - buildDownloadResult(candidate.skill(), candidate.version()))) - .toList(); - - long totalBundleBytes = entries.stream() - .mapToLong(entry -> entry.downloadResult().contentLength()) - .sum(); - if (totalBundleBytes > MAX_NAMESPACE_BUNDLE_TOTAL_BYTES) { - throw new DomainBadRequestException( - "error.namespace.skills.download.tooLarge", - MAX_NAMESPACE_BUNDLE_TOTAL_BYTES - ); - } - - byte[] bundle = createNamespaceBundle(namespace.getSlug(), entries); - entries.forEach(entry -> recordPublishedDownload(entry.skill(), entry.version())); - - return new DownloadResult( - () -> new ByteArrayInputStream(bundle), - sanitizeFilename(namespace.getSlug()) + "-skills.zip", - bundle.length, - "application/zip", - null, - false - ); - } - - private long estimateNamespaceBundleEntryBytes(NamespaceBundleCandidate candidate) { - String storageKey = buildBundleStorageKey(candidate.skill(), candidate.version()); - if (objectStorageService.exists(storageKey)) { - return objectStorageService.getMetadata(storageKey).size(); - } - - List files = skillFileRepository.findByVersionId(candidate.version().getId()).stream() - .filter(file -> objectStorageService.exists(file.getStorageKey())) - .toList(); - if (files.isEmpty()) { - throw new DomainBadRequestException("error.skill.bundle.notFound"); - } - return files.stream() - .mapToLong(file -> file.getFileSize() != null ? file.getFileSize() : 0L) - .sum(); - } - private DownloadResult downloadVersion(Skill skill, SkillVersion version) { assertPublishedAccessible(skill); assertDownloadableVersion(skill, version); @@ -275,66 +173,12 @@ public class SkillDownloadService { return result; } - private java.util.Optional toNamespaceBundleCandidate( - Namespace namespace, - Skill skill, - String currentUserId, - Map userNsRoles) { - if (!canIncludeInNamespaceBundle(namespace, skill, currentUserId, userNsRoles)) { - return java.util.Optional.empty(); - } - if (skill.getLatestVersionId() == null) { - return java.util.Optional.empty(); - } - SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()) - .orElseThrow(() -> new DomainBadRequestException("error.skill.version.latest.notFound")); - if (version.getStatus() != SkillVersionStatus.PUBLISHED) { - return java.util.Optional.empty(); - } - return java.util.Optional.of(new NamespaceBundleCandidate(skill, version)); - } - - private boolean canIncludeInNamespaceBundle( - Namespace namespace, - Skill skill, - String currentUserId, - Map userNsRoles) { - if (currentUserId == null && !isAnonymousDownloadAllowed(skill)) { - return false; - } - return visibilityChecker.canAccess(skill, currentUserId, userNsRoles); - } - - private byte[] createNamespaceBundle(String namespaceSlug, List entries) { - try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { - for (NamespaceBundleEntry entry : entries) { - ZipEntry zipEntry = new ZipEntry(namespaceSlug + "/" + entry.skill().getSlug() + "-" + entry.version().getVersion() + ".zip"); - zipOutputStream.putNextEntry(zipEntry); - try (InputStream inputStream = entry.downloadResult().openContent()) { - inputStream.transferTo(zipOutputStream); - } - zipOutputStream.closeEntry(); - } - zipOutputStream.finish(); - return outputStream.toByteArray(); - } catch (Exception e) { - throw new IllegalStateException("Failed to build namespace skill bundle zip", e); - } - } - private void recordPublishedDownload(Skill skill, SkillVersion version) { skillRepository.incrementDownloadCount(skill.getId()); skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); } - private record NamespaceBundleEntry(Skill skill, SkillVersion version, DownloadResult downloadResult) { - } - - private record NamespaceBundleCandidate(Skill skill, SkillVersion version) { - } - private DownloadResult buildDownloadResult(Skill skill, SkillVersion version) { String storageKey = buildBundleStorageKey(skill, version); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 6defe623..4bc20d34 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -6,7 +6,6 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; -import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.storage.ObjectMetadata; import com.iflytek.skillhub.storage.ObjectStorageService; @@ -22,7 +21,6 @@ import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.lang.reflect.Field; import java.time.Instant; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -353,299 +351,6 @@ class SkillDownloadServiceTest { verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } - @Test - void testDownloadNamespaceBundle_PackagesVisiblePublishedSkills() throws Exception { - Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); - setId(namespace, 2L); - namespace.setType(NamespaceType.TEAM); - - Skill alpha = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); - setId(alpha, 11L); - alpha.setDisplayName("Alpha Skill"); - alpha.setStatus(SkillStatus.ACTIVE); - alpha.setLatestVersionId(101L); - - Skill beta = new Skill(2L, "beta", "owner-1", SkillVisibility.PUBLIC); - setId(beta, 12L); - beta.setDisplayName("Beta Skill"); - beta.setStatus(SkillStatus.ACTIVE); - beta.setLatestVersionId(102L); - - SkillVersion alphaVersion = new SkillVersion(11L, "1.0.0", "owner-1"); - setId(alphaVersion, 101L); - alphaVersion.setStatus(SkillVersionStatus.PUBLISHED); - SkillVersion betaVersion = new SkillVersion(12L, "2.0.0", "owner-1"); - setId(betaVersion, 102L); - betaVersion.setStatus(SkillVersionStatus.PUBLISHED); - - SkillFile alphaFile = new SkillFile(101L, "SKILL.md", 5L, "text/markdown", "hash-a", "skills/11/101/SKILL.md"); - SkillFile betaFile = new SkillFile(102L, "README.md", 4L, "text/markdown", "hash-b", "skills/12/102/README.md"); - - when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(alpha, beta)); - when(visibilityChecker.canAccess(alpha, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(visibilityChecker.canAccess(beta, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(alphaVersion)); - when(skillVersionRepository.findById(102L)).thenReturn(Optional.of(betaVersion)); - when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(false); - when(objectStorageService.exists("packages/12/102/bundle.zip")).thenReturn(false); - when(skillFileRepository.findByVersionId(101L)).thenReturn(List.of(alphaFile)); - when(skillFileRepository.findByVersionId(102L)).thenReturn(List.of(betaFile)); - when(objectStorageService.exists("skills/11/101/SKILL.md")).thenReturn(true); - when(objectStorageService.exists("skills/12/102/README.md")).thenReturn(true); - when(objectStorageService.getObject("skills/11/101/SKILL.md")).thenReturn(new ByteArrayInputStream("alpha".getBytes())); - when(objectStorageService.getObject("skills/12/102/README.md")).thenReturn(new ByteArrayInputStream("beta".getBytes())); - - SkillDownloadService.DownloadResult result = service.downloadNamespaceBundle( - "team-ai", - List.of(), - "user-1", - Map.of(2L, NamespaceRole.MEMBER)); - - assertEquals("team-ai-skills.zip", result.filename()); - assertEquals("application/zip", result.contentType()); - assertNull(result.presignedUrl()); - assertTrue(result.contentLength() > 0); - - try (ZipInputStream zipInputStream = new ZipInputStream(result.openContent())) { - var firstEntry = zipInputStream.getNextEntry(); - assertNotNull(firstEntry); - assertEquals("team-ai/alpha-1.0.0.zip", firstEntry.getName()); - var secondEntry = zipInputStream.getNextEntry(); - assertNotNull(secondEntry); - assertEquals("team-ai/beta-2.0.0.zip", secondEntry.getName()); - } - - verify(skillRepository).incrementDownloadCount(11L); - verify(skillRepository).incrementDownloadCount(12L); - verify(skillVersionStatsRepository).incrementDownloadCount(101L, 11L); - verify(skillVersionStatsRepository).incrementDownloadCount(102L, 12L); - verify(eventPublisher, times(2)).publishEvent(any(SkillDownloadedEvent.class)); - } - - @Test - void testDownloadNamespaceBundle_SkipsInvisibleAndUnpublishedSkills() throws Exception { - Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); - setId(namespace, 2L); - namespace.setType(NamespaceType.TEAM); - - Skill visible = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); - setId(visible, 11L); - visible.setDisplayName("Alpha Skill"); - visible.setStatus(SkillStatus.ACTIVE); - visible.setLatestVersionId(101L); - - Skill invisible = new Skill(2L, "beta-private", "owner-2", SkillVisibility.PRIVATE); - setId(invisible, 12L); - invisible.setDisplayName("Beta Private"); - invisible.setStatus(SkillStatus.ACTIVE); - invisible.setLatestVersionId(102L); - - Skill draftOnly = new Skill(2L, "gamma-draft", "owner-1", SkillVisibility.PUBLIC); - setId(draftOnly, 13L); - draftOnly.setDisplayName("Gamma Draft"); - draftOnly.setStatus(SkillStatus.ACTIVE); - draftOnly.setLatestVersionId(103L); - - SkillVersion visibleVersion = new SkillVersion(11L, "1.0.0", "owner-1"); - setId(visibleVersion, 101L); - visibleVersion.setStatus(SkillVersionStatus.PUBLISHED); - SkillVersion privateVersion = new SkillVersion(12L, "1.0.0", "owner-2"); - setId(privateVersion, 102L); - privateVersion.setStatus(SkillVersionStatus.PUBLISHED); - SkillVersion draftVersion = new SkillVersion(13L, "0.1.0", "owner-1"); - setId(draftVersion, 103L); - draftVersion.setStatus(SkillVersionStatus.DRAFT); - - SkillFile alphaFile = new SkillFile(101L, "SKILL.md", 5L, "text/markdown", "hash-a", "skills/11/101/SKILL.md"); - - Map roles = Map.of(2L, NamespaceRole.MEMBER); - when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(visible, invisible, draftOnly)); - when(visibilityChecker.canAccess(visible, "user-1", roles)).thenReturn(true); - when(visibilityChecker.canAccess(invisible, "user-1", roles)).thenReturn(false); - when(visibilityChecker.canAccess(draftOnly, "user-1", roles)).thenReturn(true); - when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(visibleVersion)); - when(skillVersionRepository.findById(103L)).thenReturn(Optional.of(draftVersion)); - when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(false); - when(skillFileRepository.findByVersionId(101L)).thenReturn(List.of(alphaFile)); - when(objectStorageService.exists("skills/11/101/SKILL.md")).thenReturn(true); - when(objectStorageService.getObject("skills/11/101/SKILL.md")).thenReturn(new ByteArrayInputStream("alpha".getBytes())); - - SkillDownloadService.DownloadResult result = service.downloadNamespaceBundle( - "team-ai", - List.of(), - "user-1", - roles); - - try (ZipInputStream zipInputStream = new ZipInputStream(result.openContent())) { - var firstEntry = zipInputStream.getNextEntry(); - assertNotNull(firstEntry); - assertEquals("team-ai/alpha-1.0.0.zip", firstEntry.getName()); - assertNull(zipInputStream.getNextEntry()); - } - - verify(skillVersionRepository, never()).findById(102L); - verify(skillRepository).incrementDownloadCount(11L); - verify(skillRepository, never()).incrementDownloadCount(12L); - verify(skillRepository, never()).incrementDownloadCount(13L); - verify(skillVersionStatsRepository).incrementDownloadCount(101L, 11L); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(102L, 12L); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(103L, 13L); - verify(eventPublisher, times(1)).publishEvent(any(SkillDownloadedEvent.class)); - } - - @Test - void testDownloadNamespaceBundle_RejectsAnonymousAllSkillsRequest() throws Exception { - Namespace namespace = new Namespace("global", "Global", "owner-1"); - setId(namespace, 1L); - namespace.setType(NamespaceType.GLOBAL); - - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); - - DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> - service.downloadNamespaceBundle("global", List.of(), null, Map.of())); - - assertEquals("error.namespace.skills.download.selectionRequired", ex.getMessage()); - verifyNoInteractions(objectStorageService); - verify(skillRepository, never()).incrementDownloadCount(anyLong()); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); - verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); - } - - @Test - void testDownloadNamespaceBundle_RejectsTooManyEligibleSkills() throws Exception { - Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); - setId(namespace, 2L); - namespace.setType(NamespaceType.TEAM); - - List skills = new ArrayList<>(); - for (int i = 1; i <= 21; i++) { - Skill skill = new Skill(2L, "skill-" + i, "owner-1", SkillVisibility.PUBLIC); - setId(skill, (long) i); - skill.setStatus(SkillStatus.ACTIVE); - skill.setLatestVersionId(100L + i); - skills.add(skill); - - SkillVersion version = new SkillVersion((long) i, "1.0.0", "owner-1"); - setId(version, 100L + i); - version.setStatus(SkillVersionStatus.PUBLISHED); - when(visibilityChecker.canAccess(skill, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(skillVersionRepository.findById(100L + i)).thenReturn(Optional.of(version)); - } - - when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(skills); - - DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> - service.downloadNamespaceBundle("team-ai", List.of(), "user-1", Map.of(2L, NamespaceRole.MEMBER))); - - assertEquals("error.namespace.skills.download.tooMany", ex.getMessage()); - verifyNoInteractions(objectStorageService); - verify(skillRepository, never()).incrementDownloadCount(anyLong()); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); - verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); - } - - @Test - void testDownloadNamespaceBundle_RejectsOversizedAggregateBundle() throws Exception { - Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); - setId(namespace, 2L); - namespace.setType(NamespaceType.TEAM); - - Skill alpha = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); - setId(alpha, 11L); - alpha.setDisplayName("Alpha Skill"); - alpha.setStatus(SkillStatus.ACTIVE); - alpha.setLatestVersionId(101L); - - Skill beta = new Skill(2L, "beta", "owner-1", SkillVisibility.PUBLIC); - setId(beta, 12L); - beta.setDisplayName("Beta Skill"); - beta.setStatus(SkillStatus.ACTIVE); - beta.setLatestVersionId(102L); - - SkillVersion alphaVersion = new SkillVersion(11L, "1.0.0", "owner-1"); - setId(alphaVersion, 101L); - alphaVersion.setStatus(SkillVersionStatus.PUBLISHED); - SkillVersion betaVersion = new SkillVersion(12L, "1.0.0", "owner-1"); - setId(betaVersion, 102L); - betaVersion.setStatus(SkillVersionStatus.PUBLISHED); - - when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(alpha, beta)); - when(visibilityChecker.canAccess(alpha, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(visibilityChecker.canAccess(beta, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(alphaVersion)); - when(skillVersionRepository.findById(102L)).thenReturn(Optional.of(betaVersion)); - when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(true); - when(objectStorageService.exists("packages/12/102/bundle.zip")).thenReturn(true); - when(objectStorageService.getMetadata("packages/11/101/bundle.zip")) - .thenReturn(new ObjectMetadata(60L * 1024 * 1024, "application/zip", Instant.now())); - when(objectStorageService.getMetadata("packages/12/102/bundle.zip")) - .thenReturn(new ObjectMetadata(60L * 1024 * 1024, "application/zip", Instant.now())); - - DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> - service.downloadNamespaceBundle("team-ai", List.of(), "user-1", Map.of(2L, NamespaceRole.MEMBER))); - - assertEquals("error.namespace.skills.download.tooLarge", ex.getMessage()); - verify(objectStorageService, never()).getObject(anyString()); - verify(skillRepository, never()).incrementDownloadCount(anyLong()); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); - verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); - } - - @Test - void testDownloadNamespaceBundle_RejectsOversizedFallbackBundleBeforeReadingFiles() throws Exception { - Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); - setId(namespace, 2L); - namespace.setType(NamespaceType.TEAM); - - Skill alpha = new Skill(2L, "alpha", "owner-1", SkillVisibility.PUBLIC); - setId(alpha, 11L); - alpha.setDisplayName("Alpha Skill"); - alpha.setStatus(SkillStatus.ACTIVE); - alpha.setLatestVersionId(101L); - - Skill beta = new Skill(2L, "beta", "owner-1", SkillVisibility.PUBLIC); - setId(beta, 12L); - beta.setDisplayName("Beta Skill"); - beta.setStatus(SkillStatus.ACTIVE); - beta.setLatestVersionId(102L); - - SkillVersion alphaVersion = new SkillVersion(11L, "1.0.0", "owner-1"); - setId(alphaVersion, 101L); - alphaVersion.setStatus(SkillVersionStatus.PUBLISHED); - SkillVersion betaVersion = new SkillVersion(12L, "1.0.0", "owner-1"); - setId(betaVersion, 102L); - betaVersion.setStatus(SkillVersionStatus.PUBLISHED); - - SkillFile alphaFile = new SkillFile(101L, "SKILL.md", 60L * 1024 * 1024, "text/markdown", "hash-a", "skills/11/101/SKILL.md"); - SkillFile betaFile = new SkillFile(102L, "README.md", 60L * 1024 * 1024, "text/markdown", "hash-b", "skills/12/102/README.md"); - - when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); - when(skillRepository.findByNamespaceIdAndStatus(2L, SkillStatus.ACTIVE)).thenReturn(List.of(alpha, beta)); - when(visibilityChecker.canAccess(alpha, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(visibilityChecker.canAccess(beta, "user-1", Map.of(2L, NamespaceRole.MEMBER))).thenReturn(true); - when(skillVersionRepository.findById(101L)).thenReturn(Optional.of(alphaVersion)); - when(skillVersionRepository.findById(102L)).thenReturn(Optional.of(betaVersion)); - when(objectStorageService.exists("packages/11/101/bundle.zip")).thenReturn(false); - when(objectStorageService.exists("packages/12/102/bundle.zip")).thenReturn(false); - when(skillFileRepository.findByVersionId(101L)).thenReturn(List.of(alphaFile)); - when(skillFileRepository.findByVersionId(102L)).thenReturn(List.of(betaFile)); - when(objectStorageService.exists("skills/11/101/SKILL.md")).thenReturn(true); - when(objectStorageService.exists("skills/12/102/README.md")).thenReturn(true); - - DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> - service.downloadNamespaceBundle("team-ai", List.of(), "user-1", Map.of(2L, NamespaceRole.MEMBER))); - - assertEquals("error.namespace.skills.download.tooLarge", ex.getMessage()); - verify(objectStorageService, never()).getObject(anyString()); - verify(skillRepository, never()).incrementDownloadCount(anyLong()); - verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); - verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); - } - private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); From 920e6889e701f1f1a3619e039d79c1351474780e Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 18:46:00 +0800 Subject: [PATCH 18/19] fix(web): remove namespace download residuals Signed-off-by: dongmucat <1127093059@qq.com> --- web/src/i18n/locales/en.json | 11 +-- web/src/i18n/locales/zh.json | 11 +-- web/src/pages/namespace.test.tsx | 7 +- web/src/pages/namespace.tsx | 117 +------------------------------ 4 files changed, 7 insertions(+), 139 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 6002e7a5..9696dc0c 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -772,16 +772,7 @@ "notFound": "Namespace not found", "skillList": "Skills", "emptyTitle": "No skills", - "emptyDescription": "No skills have been published in this namespace yet", - "downloadAll": "Download all", - "downloadSelected": "Download selected on this page", - "copyInstallManifest": "Copy current page install list", - "downloadConfirmTitle": "Confirm namespace download", - "downloadConfirmDescription_one": "This will request up to {{count}} skill package from @{{namespace}}.", - "downloadConfirmDescription_other": "This will request up to {{count}} skill packages from @{{namespace}}.", - "downloadConfirmLimitHint": "Unavailable skills may be skipped. Synchronous downloads are limited to {{maxSkills}} skills and {{maxSize}}.", - "downloadConfirmAction": "Download", - "selectSkill": "Select {{name}}" + "emptyDescription": "No skills have been published in this namespace yet" }, "skillDetail": { "back": "Back", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 2df46a20..c1f598ce 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -772,16 +772,7 @@ "notFound": "命名空间不存在", "skillList": "技能列表", "emptyTitle": "暂无技能", - "emptyDescription": "该命名空间下还没有发布任何技能", - "downloadAll": "下载全部", - "downloadSelected": "下载本页选中", - "copyInstallManifest": "复制本页安装清单", - "downloadConfirmTitle": "确认命名空间下载", - "downloadConfirmDescription_one": "将最多请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", - "downloadConfirmDescription_other": "将最多请求下载 @{{namespace}} 中的 {{count}} 个 skill 包。", - "downloadConfirmLimitHint": "不可用的 skill 可能会被跳过;同步下载一次最多支持 {{maxSkills}} 个 skill、{{maxSize}}。", - "downloadConfirmAction": "下载", - "selectSkill": "选择 {{name}}" + "emptyDescription": "该命名空间下还没有发布任何技能" }, "skillDetail": { "back": "返回上一页", diff --git a/web/src/pages/namespace.test.tsx b/web/src/pages/namespace.test.tsx index 6a920a85..c950fb55 100644 --- a/web/src/pages/namespace.test.tsx +++ b/web/src/pages/namespace.test.tsx @@ -99,11 +99,10 @@ describe('NamespacePage', () => { expect(html).toContain('namespace.notFound') }) - it('renders namespace distribution actions when skills are available', () => { + it('does not render namespace distribution controls when skills are available', () => { const html = renderToStaticMarkup() - expect(html).toContain('namespace.downloadAll') - expect(html).toContain('namespace.downloadSelected') - expect(html).toContain('namespace.copyInstallManifest') + expect(buttonRecords).toHaveLength(0) + expect(html).not.toContain('type="checkbox"') }) }) diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index 04a90772..275f7ba5 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -1,21 +1,15 @@ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect } from 'react' import { useNavigate, useParams } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' -import { ClipboardCopy, Download } from 'lucide-react' import { NamespaceHeader } from '@/features/namespace/namespace-header' import { SkillCard } from '@/features/skill/skill-card' -import { buildInstallTarget } from '@/features/skill/install-command' import { SkeletonList } from '@/shared/components/skeleton-loader' import { EmptyState } from '@/shared/components/empty-state' import { Pagination } from '@/shared/components/pagination' -import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { useSearchSkills } from '@/shared/hooks/use-skill-queries' import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' -import { Button } from '@/shared/ui/button' const PAGE_SIZE = 20 -const NAMESPACE_BUNDLE_MAX_SKILLS = 20 -const NAMESPACE_BUNDLE_MAX_SIZE = '100 MB' /** * Public namespace page showing namespace metadata and the skills currently discoverable inside it. @@ -25,20 +19,12 @@ export function NamespacePage() { const navigate = useNavigate() const { namespace } = useParams({ from: '/space/$namespace' }) const [page, setPage] = useState(0) - const [selectedSkillSlugs, setSelectedSkillSlugs] = useState([]) - const [pendingDownloadSlugs, setPendingDownloadSlugs] = useState(null) // Reset page when namespace changes useEffect(() => { setPage(0) - setSelectedSkillSlugs([]) - setPendingDownloadSlugs(null) }, [namespace]) - useEffect(() => { - setSelectedSkillSlugs([]) - }, [page]) - const { data: namespaceData, isLoading: isLoadingNamespace } = useNamespaceDetail(namespace) const { data: skillsData, isLoading: isLoadingSkills } = useSearchSkills({ namespace, @@ -47,56 +33,11 @@ export function NamespacePage() { }) const totalPages = skillsData ? Math.max(Math.ceil(skillsData.total / skillsData.size), 1) : 1 - const visibleSkills = skillsData?.items ?? [] - const selectedSlugSet = useMemo(() => new Set(selectedSkillSlugs), [selectedSkillSlugs]) - const hasSkills = visibleSkills.length > 0 - const selectedDownloadSlugs = selectedSkillSlugs.filter((slug) => visibleSkills.some((skill) => skill.slug === slug)) - const pendingDownloadCount = pendingDownloadSlugs - ? pendingDownloadSlugs.length || skillsData?.total || visibleSkills.length - : 0 const handleSkillClick = (slug: string) => { navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` }) } - const handleSkillSelectionChange = (slug: string, selected: boolean) => { - setSelectedSkillSlugs((current) => { - if (selected) { - return current.includes(slug) ? current : [...current, slug] - } - return current.filter((item) => item !== slug) - }) - } - - const buildNamespaceDownloadUrl = (slugs: string[]) => { - const params = new URLSearchParams() - slugs.forEach((slug) => params.append('skill', slug)) - const queryString = params.toString() - return `/api/web/namespaces/${encodeURIComponent(namespace)}/skills/download${queryString ? `?${queryString}` : ''}` - } - - const handleDownloadAll = () => { - setPendingDownloadSlugs([]) - } - - const handleDownloadSelected = () => { - setPendingDownloadSlugs(selectedDownloadSlugs) - } - - const confirmDownload = () => { - if (!pendingDownloadSlugs) { - return - } - window.location.assign(buildNamespaceDownloadUrl(pendingDownloadSlugs)) - } - - const handleCopyInstallManifest = async () => { - const manifest = visibleSkills - .map((skill) => `skillhub install ${buildInstallTarget(skill.namespace, skill.slug)}`) - .join('\n') - await navigator.clipboard?.writeText(manifest) - } - if (isLoadingNamespace) { return (
@@ -115,25 +56,7 @@ export function NamespacePage() {
-
-

{t('namespace.skillList')}

- {hasSkills ? ( -
- - - -
- ) : null} -
+

{t('namespace.skillList')}

{isLoadingSkills ? ( ) : skillsData && skillsData.items.length > 0 ? ( @@ -141,15 +64,6 @@ export function NamespacePage() {
{skillsData.items.map((skill, idx) => (
- handleSkillClick(skill.slug)} @@ -169,33 +83,6 @@ export function NamespacePage() { /> )}
- { - if (!open) { - setPendingDownloadSlugs(null) - } - }} - title={t('namespace.downloadConfirmTitle')} - description={( - - - {t('namespace.downloadConfirmDescription', { - count: pendingDownloadCount, - namespace, - })} - - - {t('namespace.downloadConfirmLimitHint', { - maxSkills: NAMESPACE_BUNDLE_MAX_SKILLS, - maxSize: NAMESPACE_BUNDLE_MAX_SIZE, - })} - - - )} - confirmText={t('namespace.downloadConfirmAction')} - onConfirm={confirmDownload} - />
) } From a88b09e51b52059b86e3bf5c19f9369e82efcaa0 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:34:13 +0800 Subject: [PATCH 19/19] fix(publish): accept case-insensitive SKILL.md uploads --- docs/07-skill-protocol.md | 2 +- .../support/MultipartPackageExtractor.java | 3 +- .../support/ZipPackageExtractor.java | 3 +- .../MultipartPackageExtractorTest.java | 35 ++++++++++++++ .../SkillPackageArchiveExtractorTest.java | 32 +++++++++++++ .../support/ZipPackageExtractorTest.java | 47 +++++++++++++++++++ .../skill/validation/SkillPackagePolicy.java | 14 +++++- .../validation/SkillPackageValidatorTest.java | 29 ++++++++++++ web/src/docs/skill.md | 3 +- 9 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index 6e9db3cc..4ee36a08 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -66,7 +66,7 @@ my-skill/ ``` 校验规则: -- 根目录必须包含 `SKILL.md` +- 根目录必须包含规范入口文件 `SKILL.md`;上传时服务端兼容 `skill.md`、`Skill.md` 等大小写变体,并在内部归一化为 `SKILL.md` - 文件类型白名单:`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.cjs`, `.mjs`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg` - 单文件大小限制:1MB(可配置) - 总包大小限制:10MB(可配置) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java index 0a9fc793..e1a0a57c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -110,7 +111,7 @@ public class MultipartPackageExtractor { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Unsafe package path: " + path); } - return path; + return SkillPackagePolicy.canonicalizeSkillMdPath(path); } private String determineContentType(String filename) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index 2beaec70..a23e8cc5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller.support; import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -112,7 +113,7 @@ public class ZipPackageExtractor { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Unsafe package path: " + path); } - return normalizedPath; + return SkillPackagePolicy.canonicalizeSkillMdPath(normalizedPath); } catch (InvalidPathException ex) { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Invalid package path: " + path); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java new file mode 100644 index 00000000..f3be9120 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.controller.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.SkillPublishProperties; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MultipartPackageExtractorTest { + + @Test + void extractCanonicalizesCaseInsensitiveSkillMd() throws Exception { + MultipartPackageExtractor extractor = new MultipartPackageExtractor( + new SkillPublishProperties(), + new ObjectMapper() + ); + MockMultipartFile skillMd = new MockMultipartFile( + "files", + "skill.md", + "text/markdown", + "---\nname: test\n---\n".getBytes() + ); + + MultipartPackageExtractor.ExtractedPackage extracted = extractor.extract( + new MockMultipartFile[] {skillMd}, + "{\"namespace\":\"global\",\"slug\":\"test\"}" + ); + + assertEquals(1, extracted.entries().size()); + assertTrue(extracted.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(extracted.entries().stream().noneMatch(e -> e.path().equals("skill.md"))); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java index ba5cc2f7..c849fc4c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java @@ -85,6 +85,21 @@ class SkillPackageArchiveExtractorTest { assertTrue(entries.stream().anyMatch(e -> e.path().equals("config.json"))); } + @Test + void canonicalizesCaseInsensitiveSkillMdAtRoot() throws Exception { + byte[] zipBytes = createZip(Map.of( + "skill.md", "---\nname: test\n---\n".getBytes(), + "README.md", "# readme".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file); + + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(result.entries().stream().noneMatch(e -> e.path().equals("skill.md"))); + assertTrue(result.warnings().isEmpty()); + } + @Test void doesNotStripWhenMultipleRootEntries() throws Exception { byte[] zipBytes = createZip(Map.of( @@ -144,6 +159,23 @@ class SkillPackageArchiveExtractorTest { assertTrue(result.warnings().stream().anyMatch(w -> w.contains("other.txt"))); } + @Test + void promotesCaseInsensitiveSkillMdFromSubdirectory() throws Exception { + byte[] zipBytes = createZip(Map.of( + "my-skill/skill.md", "---\nname: test\n---\n".getBytes(), + "my-skill/README.md", "# readme".getBytes(), + "other.txt", "stray file".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file); + + assertEquals(2, result.entries().size()); + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("README.md"))); + assertTrue(result.warnings().stream().anyMatch(w -> w.contains("other.txt"))); + } + @Test void rejectsAmbiguousMultipleSkillMdInSubdirectories() throws Exception { byte[] zipBytes = createZip(Map.of( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java new file mode 100644 index 00000000..02f26214 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java @@ -0,0 +1,47 @@ +package com.iflytek.skillhub.controller.support; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ZipPackageExtractorTest { + + @Test + void extractCanonicalizesCaseInsensitiveSkillMd() throws Exception { + ZipPackageExtractor extractor = new ZipPackageExtractor(new SkillPublishProperties()); + byte[] zipBytes = createZip(Map.of( + "skill.md", "---\nname: test\n---\n".getBytes(), + "README.md", "# readme".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + List entries = extractor.extract(file); + + assertEquals(2, entries.size()); + assertTrue(entries.stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(entries.stream().noneMatch(e -> e.path().equals("skill.md"))); + } + + private byte[] createZip(Map entries) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (Map.Entry e : entries.entrySet()) { + ZipEntry entry = new ZipEntry(e.getKey()); + zos.putNextEntry(entry); + zos.write(e.getValue()); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java index eab8d2e2..ee3880ae 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java @@ -64,7 +64,19 @@ public final class SkillPackagePolicy { throw new IllegalArgumentException("Package entry path must be normalized: " + rawPath); } - return canonical; + return canonicalizeSkillMdPath(canonical); + } + + public static String canonicalizeSkillMdPath(String normalizedPath) { + int slashIndex = normalizedPath.lastIndexOf('/'); + String fileName = slashIndex >= 0 ? normalizedPath.substring(slashIndex + 1) : normalizedPath; + if (!SKILL_MD_PATH.equalsIgnoreCase(fileName)) { + return normalizedPath; + } + if (slashIndex < 0) { + return SKILL_MD_PATH; + } + return normalizedPath.substring(0, slashIndex + 1) + SKILL_MD_PATH; } public static boolean hasAllowedExtension(String path) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java index d473a361..c2b6b63c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java @@ -40,6 +40,35 @@ class SkillPackageValidatorTest { assertTrue(result.errors().isEmpty()); } + @Test + void normalizesSkillMdFilenameCase() { + assertEquals("SKILL.md", SkillPackagePolicy.normalizeEntryPath("skill.md")); + assertEquals("SKILL.md", SkillPackagePolicy.normalizeEntryPath("Skill.MD")); + assertEquals("nested/SKILL.md", SkillPackagePolicy.normalizeEntryPath("nested/skill.md")); + } + + @Test + void acceptsSkillMdFilenameWithDifferentCase() { + String skillMdContent = """ + --- + name: test-skill + description: A test skill + version: 1.0.0 + --- + # Test Skill + """; + + List entries = List.of( + new PackageEntry("skill.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"), + new PackageEntry("README.md", "readme".getBytes(), 6, "text/markdown") + ); + + ValidationResult result = validator.validate(entries); + + assertTrue(result.passed()); + assertTrue(result.errors().isEmpty()); + } + @Test void testMissingSkillMd() { List entries = List.of( diff --git a/web/src/docs/skill.md b/web/src/docs/skill.md index 18ea1e88..b8ed8bcf 100644 --- a/web/src/docs/skill.md +++ b/web/src/docs/skill.md @@ -138,7 +138,8 @@ If a request fails with `403`, check: ## Skill Package Contract -SkillHub expects OpenSkills-style packages with `SKILL.md` as the entry point. +SkillHub expects OpenSkills-style packages with canonical `SKILL.md` as the entry point. Uploads +accept filename case variants such as `skill.md` and normalize them to `SKILL.md`. ## Publishing Guidance