Merge branch 'main' of github.com:iflytek/skillhub into feature/ui

This commit is contained in:
dongmucat 2026-03-17 18:53:49 +08:00
commit 9bb7a01882
10 changed files with 85 additions and 65 deletions

View file

@ -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) {

View file

@ -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);

View file

@ -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() + "%");
}

View file

@ -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);

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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()
);

View file

@ -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();
}
}

View file

@ -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

View file

@ -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] })