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 83c5dc8e..dbc03cbf 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 @@ -146,9 +146,10 @@ public class SkillDownloadService { DownloadResult result; if (objectStorageService.exists(storageKey)) { ObjectMetadata metadata = objectStorageService.getMetadata(storageKey); - String presignedUrl = objectStorageService.generatePresignedUrl(storageKey, Duration.ofMinutes(10)); + String filename = buildFilename(skill, version); + String presignedUrl = objectStorageService.generatePresignedUrl(storageKey, Duration.ofMinutes(10), filename); InputStream content = objectStorageService.getObject(storageKey); - result = new DownloadResult(content, buildFilename(skill, version), metadata.size(), metadata.contentType(), presignedUrl); + result = new DownloadResult(content, filename, metadata.size(), metadata.contentType(), presignedUrl); } else { result = buildBundleFromFiles(skill, version); } @@ -196,7 +197,19 @@ public class SkillDownloadService { } private String buildFilename(Skill skill, SkillVersion version) { - return String.format("%s-%s.zip", skill.getSlug(), version.getVersion()); + String baseName = skill.getDisplayName(); + if (baseName == null || baseName.isBlank()) { + baseName = skill.getSlug(); + } + return String.format("%s-%s.zip", sanitizeFilename(baseName), version.getVersion()); + } + + private String sanitizeFilename(String value) { + String sanitized = value + .replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "-") + .replaceAll("\\s+", " ") + .trim(); + return sanitized.isBlank() ? "skill" : sanitized; } private Namespace findNamespace(String slug) { 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 a2c11666..798ac383 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 @@ -80,6 +80,7 @@ class SkillDownloadServiceTest { setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setDisplayName("Test Skill"); skill.setStatus(SkillStatus.ACTIVE); skill.setLatestVersionId(10L); @@ -97,14 +98,14 @@ class SkillDownloadServiceTest { when(objectStorageService.exists(storageKey)).thenReturn(true); when(objectStorageService.getMetadata(storageKey)).thenReturn(metadata); when(objectStorageService.getObject(storageKey)).thenReturn(content); - when(objectStorageService.generatePresignedUrl(eq(storageKey), any())).thenReturn(null); + when(objectStorageService.generatePresignedUrl(eq(storageKey), any(), eq("Test Skill-1.0.0.zip"))).thenReturn(null); // Act SkillDownloadService.DownloadResult result = service.downloadLatest(namespaceSlug, skillSlug, userId, userNsRoles); // Assert assertNotNull(result); - assertEquals("test-skill-1.0.0.zip", result.filename()); + assertEquals("Test Skill-1.0.0.zip", result.filename()); assertEquals(1000L, result.contentLength()); assertNotNull(result.content()); verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); @@ -123,6 +124,7 @@ class SkillDownloadServiceTest { setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setDisplayName("Test Skill"); skill.setStatus(SkillStatus.ACTIVE); SkillTag tag = new SkillTag(1L, tagName, 10L, userId); SkillVersion version = new SkillVersion(1L, "1.0.0", userId); @@ -140,14 +142,14 @@ class SkillDownloadServiceTest { when(objectStorageService.exists(storageKey)).thenReturn(true); when(objectStorageService.getMetadata(storageKey)).thenReturn(metadata); when(objectStorageService.getObject(storageKey)).thenReturn(content); - when(objectStorageService.generatePresignedUrl(eq(storageKey), any())).thenReturn(null); + when(objectStorageService.generatePresignedUrl(eq(storageKey), any(), eq("Test Skill-1.0.0.zip"))).thenReturn(null); // Act SkillDownloadService.DownloadResult result = service.downloadByTag(namespaceSlug, skillSlug, tagName, userId, userNsRoles); // Assert assertNotNull(result); - assertEquals("test-skill-1.0.0.zip", result.filename()); + assertEquals("Test Skill-1.0.0.zip", result.filename()); assertNotNull(result.content()); verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } @@ -164,6 +166,7 @@ class SkillDownloadServiceTest { setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setDisplayName("Generate Commit Message"); skill.setStatus(SkillStatus.ACTIVE); SkillVersion version = new SkillVersion(1L, versionStr, userId); setId(version, 10L); @@ -179,7 +182,8 @@ class SkillDownloadServiceTest { when(objectStorageService.exists(storageKey)).thenReturn(true); when(objectStorageService.getMetadata(storageKey)).thenReturn(metadata); when(objectStorageService.getObject(storageKey)).thenReturn(content); - when(objectStorageService.generatePresignedUrl(eq(storageKey), any())).thenReturn("http://minio.local/presigned"); + when(objectStorageService.generatePresignedUrl(eq(storageKey), any(), eq("Generate Commit Message-1.0.0.zip"))) + .thenReturn("http://minio.local/presigned"); SkillDownloadService.DownloadResult result = service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles); @@ -225,6 +229,7 @@ class SkillDownloadServiceTest { setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setDisplayName("Generate Commit Message"); skill.setStatus(SkillStatus.ACTIVE); SkillVersion version = new SkillVersion(1L, versionStr, userId); setId(version, 10L); @@ -243,7 +248,7 @@ class SkillDownloadServiceTest { SkillDownloadService.DownloadResult result = service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles); assertNull(result.presignedUrl()); - assertEquals("test-skill-1.0.0.zip", result.filename()); + assertEquals("Generate Commit Message-1.0.0.zip", result.filename()); assertEquals("application/zip", result.contentType()); assertTrue(result.contentLength() > 0); diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index 92e3cf22..d6fe80b0 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -62,6 +62,7 @@ public class PostgresFullTextQueryService implements SearchQueryService { String tsQuery = buildPrefixTsQuery(normalizedKeyword); boolean hasKeyword = normalizedKeyword != null; boolean hasTsQuery = tsQuery != null; + boolean useRelevanceOrdering = "relevance".equals(query.sortBy()) && hasKeyword; boolean useShortPrefixTitleSearch = hasTsQuery && normalizedKeyword.length() <= SHORT_PREFIX_LENGTH; boolean useSemanticRerank = semanticEnabled && hasKeyword @@ -126,7 +127,7 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("ORDER BY (SELECT rating_avg FROM skill WHERE id = skill_id) DESC "); } else if ("newest".equals(query.sortBy())) { sql.append("ORDER BY (SELECT updated_at FROM skill WHERE id = skill_id) DESC "); - } else if ("relevance".equals(query.sortBy()) && hasKeyword) { + } else if (useRelevanceOrdering) { sql.append("ORDER BY CASE "); sql.append("WHEN ").append(TITLE_SQL).append(" = :titleExact THEN 4 "); sql.append("WHEN ").append(TITLE_SQL).append(" LIKE :titlePrefix THEN 3 "); @@ -163,8 +164,10 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (hasTsQuery) { nativeQuery.setParameter("tsQuery", tsQuery); } - nativeQuery.setParameter("titleExact", normalizedKeyword.toLowerCase()); - nativeQuery.setParameter("titlePrefix", normalizedKeyword.toLowerCase() + "%"); + if (useRelevanceOrdering) { + nativeQuery.setParameter("titleExact", normalizedKeyword.toLowerCase()); + nativeQuery.setParameter("titlePrefix", normalizedKeyword.toLowerCase() + "%"); + } nativeQuery.setParameter("titleLike", "%" + normalizedKeyword.toLowerCase() + "%"); } diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index 388a02f8..23bdc798 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -202,6 +202,35 @@ class PostgresFullTextQueryServiceTest { assertThat(sqlCaptor.getAllValues().getFirst()).contains("LOWER(title) LIKE :titleLike"); } + @Test + void downloadsSortShouldNotBindRelevanceOnlyParameters() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "51222222333", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "downloads", + 0, + 12 + )); + + verify(nativeQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("titleExact"), anyString()); + verify(nativeQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("titlePrefix"), anyString()); + verify(nativeQuery).setParameter("titleLike", "%51222222333%"); + } + @Test void semanticRerankShouldPromoteSemanticallyRelevantCandidate() { EntityManager entityManager = mock(EntityManager.class); diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java index bfa0ead2..e0e35e40 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java @@ -59,7 +59,7 @@ public class LocalFileStorageService implements ObjectStorageService { } @Override - public String generatePresignedUrl(String key, Duration expiry) { + public String generatePresignedUrl(String key, Duration expiry, String downloadFilename) { return null; } diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java index e154facf..5873f36b 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java @@ -11,5 +11,5 @@ public interface ObjectStorageService { void deleteObjects(List keys); boolean exists(String key); ObjectMetadata getMetadata(String key); - String generatePresignedUrl(String key, Duration expiry); + String generatePresignedUrl(String key, Duration expiry, String downloadFilename); } diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java index dbb198cb..8bb54971 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java @@ -17,6 +17,7 @@ import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequ import java.io.InputStream; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; @@ -95,14 +96,19 @@ public class S3StorageService implements ObjectStorageService { } @Override - public String generatePresignedUrl(String key, Duration expiry) { + public String generatePresignedUrl(String key, Duration expiry, String downloadFilename) { Duration signatureDuration = expiry != null ? expiry : properties.getPresignExpiry(); + String contentDisposition = downloadFilename == null || downloadFilename.isBlank() + ? "attachment" + : "attachment; filename*=UTF-8''" + java.net.URLEncoder.encode(downloadFilename, StandardCharsets.UTF_8) + .replace("+", "%20"); PresignedGetObjectRequest request = s3Presigner.presignGetObject( GetObjectPresignRequest.builder() .signatureDuration(signatureDuration) .getObjectRequest(GetObjectRequest.builder() .bucket(properties.getBucket()) .key(key) + .responseContentDisposition(contentDisposition) .build()) .build() ); diff --git a/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java index 1c29af9f..51e9aa5e 100644 --- a/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java +++ b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java @@ -105,6 +105,6 @@ class LocalFileStorageServiceTest { LocalFileStorageService service = new LocalFileStorageService(properties); - assertThat(service.generatePresignedUrl("packages/demo.zip", Duration.ofMinutes(10))).isNull(); + assertThat(service.generatePresignedUrl("packages/demo.zip", Duration.ofMinutes(10), "demo.zip")).isNull(); } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f9e70e89..25e5dc35 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -37,11 +37,6 @@ export { ApiError } export const WEB_API_PREFIX = '/api/web' -export type DownloadedFile = { - blob: Blob - fileName?: string -} - type RuntimeConfig = { apiBaseUrl?: string appBaseUrl?: string @@ -274,22 +269,16 @@ function withBaseUrl(input: RequestInfo | URL): RequestInfo | URL { return new URL(input, ensureTrailingSlash(baseUrl)) } -function ensureTrailingSlash(value: string): string { - return value.endsWith('/') ? value : `${value}/` +export function buildApiUrl(path: string): string { + const baseUrl = getApiBaseUrl() + if (!baseUrl) { + return path + } + return new URL(path, ensureTrailingSlash(baseUrl)).toString() } -function parseDownloadFileName(contentDisposition: string | null): string | undefined { - if (!contentDisposition) { - return undefined - } - - const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i) - if (utf8Match) { - return decodeURIComponent(utf8Match[1]) - } - - const basicMatch = contentDisposition.match(/filename="?([^";]+)"?/i) - return basicMatch?.[1] +function ensureTrailingSlash(value: string): string { + return value.endsWith('/') ? value : `${value}/` } export async function getCurrentUser(): Promise { @@ -441,27 +430,6 @@ export const accountApi = { }, } -export const skillDownloadApi = { - async downloadVersion(namespace: string, slug: string, version: string): Promise { - const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace - const response = await fetch( - withBaseUrl(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/download`), - { - headers: withRequestHeaders(), - }, - ) - - if (!response.ok) { - throw new ApiError(`HTTP ${response.status}`, response.status) - } - - return { - blob: await response.blob(), - fileName: parseDownloadFileName(response.headers.get('content-disposition')), - } - }, -} - export const skillLifecycleApi = { async archiveSkill(namespace: string, slug: string, reason?: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 09b1bb0e..e3914d3e 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -10,7 +10,7 @@ import { resolveSkillActionErrorTitle } from '@/features/skill/skill-action-erro import { RatingInput } from '@/features/social/rating-input' import { StarButton } from '@/features/social/star-button' import { useAuth } from '@/features/auth/use-auth' -import { adminApi, ApiError, skillDownloadApi } from '@/api/client' +import { adminApi, ApiError, buildApiUrl, WEB_API_PREFIX } from '@/api/client' import { useSubmitSkillReport } from '@/features/report/use-skill-reports' import { formatLocalDateTime } from '@/shared/lib/date-time' import { incrementSkillDownloadCount } from '@/shared/lib/skill-download-cache' @@ -141,15 +141,12 @@ export function SkillDetailPage() { const submitPromotionMutation = useSubmitPromotion() const reportMutation = useSubmitSkillReport(namespace, slug) - const triggerBrowserDownload = (blob: Blob, fileName: string) => { - const objectUrl = window.URL.createObjectURL(blob) + const triggerBrowserDownload = (url: string) => { const link = document.createElement('a') - link.href = objectUrl - link.download = fileName + link.href = url document.body.appendChild(link) link.click() link.remove() - window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 0) } const handleDownload = async () => { @@ -162,10 +159,9 @@ export function SkillDetailPage() { } try { - const downloadedFile = await skillDownloadApi.downloadVersion(namespace, slug, selectedVersionEntry.version) + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace triggerBrowserDownload( - downloadedFile.blob, - downloadedFile.fileName ?? `${slug}-${selectedVersionEntry.version}.zip`, + buildApiUrl(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${selectedVersionEntry.version}/download`), ) incrementSkillDownloadCount(queryClient, { namespace, slug }) queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] })