From 793a519bdb628688b1f81f6ab7bf44d54b0fd69a Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:10:42 +0800 Subject: [PATCH] fix(publish): preserve package preview and version presentation compatibility Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../portal/SkillPublishController.java | 3 ++- .../portal/SkillPublishControllerTest.java | 27 +++++++++++++++++++ web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/ru.json | 1 + web/src/i18n/locales/zh.json | 1 + web/src/pages/dashboard/publish.tsx | 2 +- web/src/pages/skill-detail.tsx | 14 ++++++---- .../shared/lib/version-presentation.test.ts | 18 +++++++++++++ web/src/shared/lib/version-presentation.ts | 22 +++++++++++++++ 9 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 web/src/shared/lib/version-presentation.test.ts create mode 100644 web/src/shared/lib/version-presentation.ts diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index f03be1e6..7e214131 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -49,7 +49,8 @@ public class SkillPublishController extends BaseApiController { throw new DomainForbiddenException("error.auth.required"); } try { - return ok("response.success.read", packagePreview.preview(skillPackageArchiveExtractor.extract(file))); + return ok("response.success.read", packagePreview.preview( + skillPackageArchiveExtractor.extractWithWarnings(file).entries())); } catch (IllegalArgumentException exception) { throw new DomainBadRequestException("error.skill.publish.package.invalid", exception.getMessage()); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java index d5b5f737..57701679 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java @@ -261,6 +261,33 @@ class SkillPublishControllerTest { org.mockito.Mockito.verifyNoInteractions(skillPublishService); } + @Test + void packagePreviewNormalizesNestedSkillAlongsideRootReadmeLikePublish() throws Exception { + byte[] archive; + try (var output = new ByteArrayOutputStream(); var zip = new ZipOutputStream(output)) { + zip.putNextEntry(new ZipEntry("README.md")); + zip.write("Repository overview".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("foo/SKILL.md")); + zip.write("---\nname: foo\ndescription: Nested skill\nversion: 1.0.0\nmetadata:\n display-name: 嵌套技能\n---\nBody".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.finish(); + archive = output.toByteArray(); + } + var principal = new PlatformPrincipal("preview_nested", "publisher", "publisher@example.com", "", "local", Set.of()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); + for (String base : List.of("/api/v1", "/api/web")) { + mockMvc.perform(multipart(base + "/skills/package-preview") + .file(new MockMultipartFile("file", "skill.zip", "application/zip", archive)) + .with(authentication(auth)).with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.name").value("foo")) + .andExpect(jsonPath("$.data.displayName").value("嵌套技能")) + .andExpect(jsonPath("$.data.description").value("Nested skill")); + } + org.mockito.Mockito.verifyNoInteractions(skillPublishService); + } + @Test void packagePreviewRejectsAnonymousWithoutPublishing() throws Exception { mockMvc.perform(multipart("/api/v1/skills/package-preview") diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5ba6be87..8fec1ae6 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1831,6 +1831,7 @@ "copyright": "© 2026 SkillHub. Apache-2.0." }, "publish": { + "packageName": "Package name", "displayName": "Display name", "displayNamePlaceholder": "For example: Meeting notes assistant", "displayNameHelp": "Up to 200 characters. Defaults to the package title. Changes follow this version’s review and do not alter the identifier or package files. Leave blank to use the package title.", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index dff63a08..ef4b6248 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1822,6 +1822,7 @@ "goToPage": "Перейти на страницу {{page}}" }, "publish": { + "packageName": "Имя в пакете", "displayName": "Отображаемое имя", "displayNamePlaceholder": "Например: Помощник для протоколов встреч", "displayNameHelp": "До 200 символов. По умолчанию — имя из пакета. Изменения проходят проверку версии и не меняют идентификатор или файлы пакета. Пустое поле означает имя из пакета.", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index d165fe9e..dc45fd32 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1830,6 +1830,7 @@ "copyright": "© 2026 SkillHub. Apache-2.0." }, "publish": { + "packageName": "包内名称", "displayName": "显示名称", "displayNamePlaceholder": "例如:会议纪要助手", "displayNameHelp": "支持中文,最多 200 个字符。默认读取包内名称;修改后随本次版本审核,不改变技能标识或包内文件。留空则使用包内名称。", diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 1c8f7d1b..85cbd695 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -330,7 +330,7 @@ export function PublishPage() {

{t('publish.packagePreviewFailed')}

} - {selectedFile && packagePreview.data &&

@{namespaceSlug || '…'}/{packagePreview.data.name}

} + {selectedFile && packagePreview.data &&

{t('publish.packageName')}: {packagePreview.data.name}

} diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index aff71165..aa6e1535 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -11,6 +11,7 @@ import { FileTree } from '@/features/skill/file-tree' import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' import type { FileTreeNode } from '@/features/skill/file-tree-builder' import type { SkillFile } from '@/api/types' +import { getVersionPresentation } from '@/shared/lib/version-presentation' import { CopyButton } from '@/shared/components/copy-button' import { InstallCommand } from '@/features/skill/install-command' import { ShareButton } from '@/features/skill/share-button' @@ -200,10 +201,13 @@ export function SkillDetailPage() { : headlineVersion?.version ?? versions?.[0]?.version const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion) ?? versions?.[0] const selectedMetadata = selectedVersion === ownerPreviewVersion?.version ? ownerPreviewVersion - : selectedVersion === publishedVersion?.version ? publishedVersion : headlineVersion - const selectedDisplayName = selectedMetadata?.displayName ?? skill?.displayName - const selectedSummary = selectedMetadata?.summary ?? skill?.summary - const showingUnpublishedPreview = selectedMetadata != null && selectedMetadata.status !== 'PUBLISHED' + : selectedVersion === publishedVersion?.version ? publishedVersion + : selectedVersion === headlineVersion?.version ? headlineVersion : null + const { data: selectedVersionDetail } = useSkillVersionDetail(qns, qslug, selectedVersion, skillReady && !selectedMetadata) + const historicalMetadata = getVersionPresentation(selectedVersionDetail?.parsedMetadataJson) + const selectedDisplayName = selectedMetadata?.displayName ?? historicalMetadata.displayName ?? skill?.displayName + const selectedSummary = selectedMetadata?.summary ?? historicalMetadata.summary ?? skill?.summary + const showingUnpublishedPreview = selectedVersionEntry != null && selectedVersionEntry.status !== 'PUBLISHED' const { data: files } = useSkillFiles(qns, qslug, selectedVersion, skillReady) const documentationPath = resolveDocumentationFilePath(files) @@ -1197,7 +1201,7 @@ export function SkillDetailPage() {
{t('skillDetail.version')}
- {headlineVersion ? `v${headlineVersion.version}` : '—'} + {selectedVersion ? `v${selectedVersion}` : '—'}
diff --git a/web/src/shared/lib/version-presentation.test.ts b/web/src/shared/lib/version-presentation.test.ts new file mode 100644 index 00000000..03723fd3 --- /dev/null +++ b/web/src/shared/lib/version-presentation.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { getVersionPresentation } from './version-presentation' + +describe('historical version presentation', () => { + it('uses the explicit version override before original package metadata', () => { + expect(getVersionPresentation(JSON.stringify({ name: 'slug', description: '旧版摘要', displayNameOverride: ' 网页名 ', frontmatter: { metadata: { 'display-name': '包内名' } } }))) + .toEqual({ displayName: '网页名', summary: '旧版摘要' }) + }) + it('keeps old package-only snapshots readable and falls back for unsupported metadata', () => { + expect(getVersionPresentation(JSON.stringify({ name: 'slug', frontmatter: { metadata: { 'display-name': '包内名' } } })).displayName).toBe('包内名') + for (const value of [123, '长'.repeat(201), null, ' ']) { + expect(getVersionPresentation(JSON.stringify({ name: 'slug', frontmatter: { metadata: { 'display-name': value } } })).displayName).toBe('slug') + } + }) + it('tolerates absent, malformed and non-object snapshots', () => { + for (const value of [undefined, '', '{', 'null', '[]', 'true']) expect(getVersionPresentation(value)).toEqual({}) + }) +}) diff --git a/web/src/shared/lib/version-presentation.ts b/web/src/shared/lib/version-presentation.ts new file mode 100644 index 00000000..1b636f33 --- /dev/null +++ b/web/src/shared/lib/version-presentation.ts @@ -0,0 +1,22 @@ +/** Read legacy version snapshots without rewriting the package or trusting arbitrary JSON. */ +export function getVersionPresentation(snapshot?: string): { displayName?: string; summary?: string } { + if (!snapshot) return {} + try { + const value: unknown = JSON.parse(snapshot) + if (!isObject(value)) return {} + const frontmatter = isObject(value.frontmatter) ? value.frontmatter : {} + const metadata = isObject(frontmatter.metadata) ? frontmatter.metadata : {} + const displayName = validName(value.displayNameOverride) ?? validName(metadata['display-name']) + ?? (typeof value.name === 'string' ? value.name : undefined) + return { displayName, summary: typeof value.description === 'string' ? value.description : undefined } + } catch { + return {} + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} +function validName(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 && value.trim().length <= 200 ? value.trim() : undefined +}