mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
Merge branch 'main' of github.com:iflytek/skillhub into feature/ui
This commit is contained in:
commit
9bb7a01882
10 changed files with 85 additions and 65 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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() + "%");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,5 +11,5 @@ public interface ObjectStorageService {
|
|||
void deleteObjects(List<String> keys);
|
||||
boolean exists(String key);
|
||||
ObjectMetadata getMetadata(String key);
|
||||
String generatePresignedUrl(String key, Duration expiry);
|
||||
String generatePresignedUrl(String key, Duration expiry, String downloadFilename);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<User | null> {
|
||||
|
|
@ -441,27 +430,6 @@ export const accountApi = {
|
|||
},
|
||||
}
|
||||
|
||||
export const skillDownloadApi = {
|
||||
async downloadVersion(namespace: string, slug: string, version: string): Promise<DownloadedFile> {
|
||||
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<void> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
|
|
|
|||
|
|
@ -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] })
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue