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/10] 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/10] 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/10] 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 6e094f4199f61b6e3b5cc886b58c6c81c9682aa3 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 8 Jun 2026 10:46:30 +0800 Subject: [PATCH 04/10] 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 b5edfb850eb8975921e0d21429afc8a0510b3677 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 9 Jun 2026 10:02:26 +0800 Subject: [PATCH 05/10] 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 06/10] 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 04348f5022ea4ed8e8973e5e469c602f0bf96605 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 18:07:15 +0800 Subject: [PATCH 07/10] =?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 08/10] 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 09/10] 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 10/10] 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} - />
) }