From 2bdb83bdb6a07a9d83f2ddc590c25fa99410f0c4 Mon Sep 17 00:00:00 2001 From: Raymond <21071287+raymondtc@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:51:13 +0800 Subject: [PATCH] feat(publish): support metadata display names independent of skill slugs Use optional metadata.display-name for skill titles while preserving stable slugs and URLs. Keep published titles unchanged until review approval, and cover localized title display and search with generic example fixtures. Restore the fallback version title and summary when yanking the latest published version, with regression coverage for the lifecycle boundaries. Document the metadata field and retain existing behavior when it is absent. Signed-off-by: Raymond <21071287+raymondtc@users.noreply.github.com> --- docs/07-skill-protocol.md | 1 + docs/14-skill-lifecycle.md | 2 +- docs/skillhub/en/guide/skill-publish.md | 20 ++++++ docs/skillhub/guide/skill-publish.md | 17 +++++ .../src/main/resources/messages.properties | 3 + .../src/main/resources/messages_ru.properties | 3 + .../src/main/resources/messages_zh.properties | 3 + .../service/SkillYankLockingTest.java | 2 + .../skillhub/domain/review/ReviewService.java | 2 +- .../domain/skill/metadata/SkillMetadata.java | 33 +++++++++- .../skill/service/SkillGovernanceService.java | 32 +++++++-- .../skill/service/SkillPublishService.java | 7 +- .../domain/review/ReviewServiceTest.java | 11 ++-- .../metadata/SkillMetadataParserTest.java | 50 ++++++++++++++ .../service/SkillGovernanceServiceTest.java | 65 ++++++++++++++++++- .../service/SkillPublishServiceTest.java | 39 +++++++++++ web/e2e/helpers/test-data-builder.ts | 3 +- web/e2e/my-skills-navigation.spec.ts | 32 +++++++-- web/src/pages/dashboard/my-skills.test.ts | 23 +++++++ 19 files changed, 328 insertions(+), 20 deletions(-) diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index 835e5421..c870bf68 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -40,6 +40,7 @@ description: When to use # 必需,1-2 句话 解析规则: - `name` 和 `description` 为必需字段,缺失则校验失败 - `name` 映射为 `skill.slug`(首次发布时),后续版本不可变更 +- 可选的 `metadata.display-name` 映射为 `skill.display_name`,不参与 slug 生成。必须是字符串,去除首尾空白后最多 200 个字符;缺失、null 或空白时回退到 `name`。展示名沿用版本发布/审核流程,待审核更新不覆盖已发布标题;撤下最新版本并回退到旧版本时,标题和摘要同步恢复到该旧版本。 - `description` 映射为 `skill.summary` - frontmatter 完整解析结果存入 `skill_version.parsed_metadata_json` diff --git a/docs/14-skill-lifecycle.md b/docs/14-skill-lifecycle.md index 2237a368..1b78c824 100644 --- a/docs/14-skill-lifecycle.md +++ b/docs/14-skill-lifecycle.md @@ -123,7 +123,7 @@ Status: current code-aligned reference ### 4.8 Yank 后指针修正 -- yank 已发布版本时,若命中当前 `latestVersionId`,必须重算 latest published pointer +- yank 已发布版本时,若命中当前 `latestVersionId`,必须重算 latest published pointer,并从回退版本的 metadata 恢复展示名和摘要 - 若仍有其他 `PUBLISHED` 版本,则指向最新一个 - 若已无任何 `PUBLISHED` 版本,则 `latestVersionId = null` diff --git a/docs/skillhub/en/guide/skill-publish.md b/docs/skillhub/en/guide/skill-publish.md index be9e3902..ca3a49fb 100644 --- a/docs/skillhub/en/guide/skill-publish.md +++ b/docs/skillhub/en/guide/skill-publish.md @@ -72,6 +72,26 @@ When packaging an existing skill, keep its required scripts, references, `agents/` metadata, and license files together. Pass that skill directory (`./my-skill` in this example) to the publish command below. +To show a human-readable or localized title in lists, search results, and detail pages, +add the optional `metadata.display-name` field: + +```yaml +--- +name: example-skill +description: An example skill for demonstration. +metadata: + display-name: 示例技能 + version: "1.0.0" +--- +``` + +`name` still determines the slug, installation coordinate, and links. The display name +must be a string of at most 200 characters after trimming surrounding whitespace. +Missing, null, or blank display names fall back to `name`. Titles follow the version's +review lifecycle: an update awaiting review does not replace an existing published title. +No database migration is needed; existing skills can keep their `name` and publish a new +version with this optional metadata. + 2. **Publish via CLI (Recommended)** ```bash diff --git a/docs/skillhub/guide/skill-publish.md b/docs/skillhub/guide/skill-publish.md index 9aafcea7..78fb9cb4 100644 --- a/docs/skillhub/guide/skill-publish.md +++ b/docs/skillhub/guide/skill-publish.md @@ -68,6 +68,23 @@ version: 1.0.0 Summarize the supplied text as numbered steps. ``` +如需在列表、搜索结果和详情页显示中文或其他易读名称,可添加可选的 `metadata.display-name`: + +```yaml +--- +name: example-skill +description: 用于演示的示例技能。 +metadata: + display-name: 示例技能 + version: "1.0.0" +--- +``` + +`name` 仍用于生成 slug、安装坐标和链接;`metadata.display-name` 仅用于展示。 +展示名必须是字符串,去除首尾空白后最多 200 个字符;未填写、为 null 或空白时回退到 `name`。 +展示名随技能版本发布:已有公开版本的标题在更新审核通过后才改变。 +无需数据库迁移;已有技能可保持原 `name`,增加展示名后发布新版本。 + 打包已有技能时,应一并保留其所需的脚本、参考文件、`agents/` 元数据和许可证文件。 下一步发布命令的路径应指向该技能目录(本例为 `./my-skill`)。 diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 7bcf1b9e..d5ee5847 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -335,3 +335,6 @@ error.authoring.stream.tooManySubscribers=Too many validation event subscribers. error.skill.avatar.uploadExpired=The uploaded avatar expired. Please upload it again. error.skill.avatar.invalidUpload=Select a valid uploaded avatar before submitting. error.skill.avatar.skillMismatch=The uploaded package does not match the skill being updated. + +error.skill.metadata.displayName.invalid=metadata.display-name must be a string +error.skill.metadata.displayName.tooLong=metadata.display-name must not exceed {0} characters diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 69ebe259..353bed53 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -278,3 +278,6 @@ error.authoring.stream.tooManySubscribers=Достигнут предел под error.skill.avatar.uploadExpired=Срок действия загрузки аватара истёк. Загрузите изображение снова. error.skill.avatar.invalidUpload=Перед отправкой выберите успешно загруженный аватар. error.skill.avatar.skillMismatch=Загруженный пакет не соответствует обновляемому навыку. + +error.skill.metadata.displayName.invalid=metadata.display-name должно быть строкой +error.skill.metadata.displayName.tooLong=metadata.display-name не должно превышать {0} символов diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 66c93f16..6866a34e 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -337,3 +337,6 @@ error.authoring.stream.tooManySubscribers=校验事件连接数量已达上限 error.skill.avatar.uploadExpired=头像上传引用已过期,请重新上传。 error.skill.avatar.invalidUpload=请选择已上传成功的头像后再提交。 error.skill.avatar.skillMismatch=上传的技能包与正在更新的技能不一致。 + +error.skill.metadata.displayName.invalid=metadata.display-name 必须是字符串 +error.skill.metadata.displayName.tooLong=metadata.display-name 不能超过 {0} 个字符 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillYankLockingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillYankLockingTest.java index 5bbac28e..1a4d7925 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillYankLockingTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillYankLockingTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; @@ -130,6 +131,7 @@ class SkillYankLockingTest { eventPublisher, mock(SecurityScanService.class), mock(SkillStorageDeletionCompensationService.class), + new ObjectMapper(), Clock.systemUTC()); CountDownLatch firstAudited = new CountDownLatch(1); CountDownLatch secondReady = new CountDownLatch(1); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index 02e7f275..63149ed0 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -413,7 +413,7 @@ public class ReviewService { try { SkillMetadata metadata = objectMapper.readValue(metadataJson, SkillMetadata.class); - skill.setDisplayName(metadata.name()); + skill.setDisplayName(metadata.displayName()); skill.setSummary(metadata.description()); } catch (Exception e) { throw new IllegalStateException("Failed to deserialize skill metadata", e); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadata.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadata.java index ae9c2c74..45bfffe7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadata.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadata.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.skill.metadata; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import java.util.Map; public record SkillMetadata( @@ -8,4 +9,34 @@ public record SkillMetadata( String version, String body, Map frontmatter -) {} +) { + public SkillMetadata { + resolveDisplayName(name, frontmatter); + } + + /** Human-readable label; the package name remains the source of the skill slug. */ + public String displayName() { + return resolveDisplayName(name, frontmatter); + } + + private static String resolveDisplayName(String name, Map frontmatter) { + if (frontmatter == null || !(frontmatter.get("metadata") instanceof Map metadata)) { + return name; + } + Object value = metadata.get("display-name"); + if (value == null) { + return name; + } + if (!(value instanceof String text)) { + throw new DomainBadRequestException("error.skill.metadata.displayName.invalid"); + } + String displayName = text.strip(); + if (displayName.isEmpty()) { + return name; + } + if (displayName.length() > 200) { + throw new DomainBadRequestException("error.skill.metadata.displayName.tooLong", 200); + } + return displayName; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 72b32eab..32303e4b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.skill.service; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditDetail; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; @@ -17,12 +18,14 @@ import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import com.iflytek.skillhub.storage.ObjectStorageService; import java.time.Clock; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; @@ -49,6 +52,7 @@ public class SkillGovernanceService { private final ApplicationEventPublisher eventPublisher; private final SecurityScanService securityScanService; private final SkillStorageDeletionCompensationService compensationService; + private final ObjectMapper objectMapper; private final Clock clock; public SkillGovernanceService(SkillRepository skillRepository, @@ -60,6 +64,7 @@ public class SkillGovernanceService { ApplicationEventPublisher eventPublisher, SecurityScanService securityScanService, SkillStorageDeletionCompensationService compensationService, + ObjectMapper objectMapper, Clock clock) { this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -70,6 +75,7 @@ public class SkillGovernanceService { this.eventPublisher = eventPublisher; this.securityScanService = securityScanService; this.compensationService = compensationService; + this.objectMapper = objectMapper; this.clock = clock; } @@ -305,7 +311,9 @@ public class SkillGovernanceService { SkillVersion saved = skillVersionRepository.save(version); skillRepository.findById(version.getSkillId()).ifPresent(skill -> { if (versionId.equals(skill.getLatestVersionId())) { - skill.setLatestVersionId(findLatestPublishedVersionId(skill.getId())); + Optional fallback = findLatestPublishedVersion(skill.getId()); + skill.setLatestVersionId(fallback.map(SkillVersion::getId).orElse(null)); + fallback.ifPresent(published -> applyPublishedMetadata(skill, published)); skill.setUpdatedBy(actorUserId); skillRepository.save(skill); } @@ -317,13 +325,29 @@ public class SkillGovernanceService { } private Long findLatestPublishedVersionId(Long skillId) { + return findLatestPublishedVersion(skillId).map(SkillVersion::getId).orElse(null); + } + + private Optional findLatestPublishedVersion(Long skillId) { return skillVersionRepository.findBySkillIdAndStatus(skillId, SkillVersionStatus.PUBLISHED).stream() .max(java.util.Comparator .comparing(SkillVersion::getPublishedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())) .thenComparing(SkillVersion::getCreatedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())) - .thenComparing(SkillVersion::getId, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))) - .map(SkillVersion::getId) - .orElse(null); + .thenComparing(SkillVersion::getId, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))); + } + + private void applyPublishedMetadata(Skill skill, SkillVersion version) { + String metadataJson = version.getParsedMetadataJson(); + if (metadataJson == null || metadataJson.isBlank()) { + return; + } + try { + SkillMetadata metadata = objectMapper.readValue(metadataJson, SkillMetadata.class); + skill.setDisplayName(metadata.displayName()); + skill.setSummary(metadata.description()); + } catch (Exception e) { + throw new IllegalStateException("Failed to deserialize skill metadata", e); + } } private void assertCanManageLifecycle(Skill skill, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 1e65d12e..3b86448a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -708,8 +708,11 @@ public class SkillPublishService { } // 12. Update skill metadata and move the published pointer for auto-publish flows - skill.setDisplayName(metadata.name()); - skill.setSummary(metadata.description()); + // A pending update must not replace the currently published title or summary. + if (autoPublish || visibility == SkillVisibility.PRIVATE || skill.getLatestVersionId() == null) { + skill.setDisplayName(metadata.displayName()); + skill.setSummary(metadata.description()); + } if (autoPublish || visibility == SkillVisibility.PRIVATE) { // Update latestVersionId for autoPublish or PRIVATE skill (UPLOADED status) skill.setLatestVersionId(version.getId()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index f4fde339..10ec8fdb 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -225,8 +225,9 @@ class ReviewServiceTest { @Nested class ApproveReview { - @Test - void shouldApproveReviewSuccessfully() { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void shouldApproveReviewSuccessfully(boolean hasDisplayName) { ReviewTask task = createPendingReviewTask(); Namespace ns = createTeamNamespace(); SkillVersion sv = createPendingReviewSkillVersion(); @@ -235,7 +236,8 @@ class ReviewServiceTest { skill.setSummary("Published Summary"); skill.setUpdatedBy("previous-reviewer"); assertDoesNotThrow(() -> sv.setParsedMetadataJson(objectMapper.writeValueAsString( - new SkillMetadata("Approved Name", "Approved Summary", "1.0.0", "Body", Map.of()) + new SkillMetadata("Approved Name", "Approved Summary", "1.0.0", "Body", + hasDisplayName ? Map.of("metadata", Map.of("display-name", "已审核的中文标题")) : Map.of()) ))); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); @@ -262,7 +264,8 @@ class ReviewServiceTest { assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus()); assertEquals(Instant.now(CLOCK), sv.getPublishedAt()); assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId()); - assertEquals("Approved Name", skill.getDisplayName()); + assertEquals(hasDisplayName ? "已审核的中文标题" : "Approved Name", skill.getDisplayName()); + assertEquals("my-skill", skill.getSlug()); assertEquals("Approved Summary", skill.getSummary()); assertEquals(REVIEWER_ID, skill.getUpdatedBy()); verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java index ba449ee1..01ae3280 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java @@ -11,6 +11,56 @@ class SkillMetadataParserTest { private final SkillMetadataParser parser = new SkillMetadataParser(); + @Test + void parsesDisplayNameWithoutChangingPackageName() { + SkillMetadata metadata = parser.parse(""" + --- + name: example-skill + description: An example skill for demonstration + metadata: + version: "1.0.0" + display-name: " 示例技能 " + --- + Instructions + """); + + assertEquals("示例技能", metadata.displayName()); + assertEquals("example-skill", metadata.name()); + assertEquals("1.0.0", metadata.version()); + assertEquals("示例技能", new com.fasterxml.jackson.databind.ObjectMapper() + .convertValue(metadata, SkillMetadata.class).displayName()); + } + + @Test + void fallsBackToPackageNameForMissingOrBlankDisplayName() { + for (String extra : new String[] {"", "metadata: {}", "metadata:\n display-name: null", + "metadata:\n display-name: ' '"}) { + SkillMetadata metadata = parser.parse("---\nname: my-skill\ndescription: Test\n" + + extra + "\n---\nBody"); + assertEquals("my-skill", metadata.displayName()); + } + assertEquals("old-skill", new SkillMetadata("old-skill", "Test", "1.0.0", "Body", null).displayName()); + } + + @Test + void rejectsNonStringDisplayNames() { + for (String value : new String[] {"123", "true", "[one, two]", "{label: test}"}) { + DomainBadRequestException error = assertThrows(DomainBadRequestException.class, + () -> parser.parse("---\nname: my-skill\ndescription: Test\nmetadata:\n display-name: " + + value + "\n---\nBody")); + assertEquals("error.skill.metadata.displayName.invalid", error.messageCode()); + } + } + + @Test + void validatesDisplayNameLengthBeforePublishing() { + String template = "---\nname: my-skill\ndescription: Test\nmetadata:\n display-name: %s\n---\nBody"; + assertEquals("中".repeat(200), parser.parse(template.formatted("中".repeat(200))).displayName()); + DomainBadRequestException error = assertThrows(DomainBadRequestException.class, + () -> parser.parse(template.formatted("中".repeat(201)))); + assertEquals("error.skill.metadata.displayName.tooLong", error.messageCode()); + } + @Test void testParseStandardFrontmatterAndBody() { String content = """ diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index 2a149024..88429ae7 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -11,6 +11,7 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -36,6 +37,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -81,6 +84,7 @@ class SkillGovernanceServiceTest { eventPublisher, securityScanService, compensationService, + new ObjectMapper(), CLOCK ); } @@ -250,8 +254,9 @@ class SkillGovernanceServiceTest { verify(objectStorageService, never()).deleteObject(any()); } - @Test - void yankVersion_recomputesLatestPublishedPointer() { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void yankVersion_restoresLatestPublishedMetadata(boolean hasDisplayName) throws Exception { SkillVersion yanked = new SkillVersion(2L, "2.0.0", "owner"); setField(yanked, "id", 22L); yanked.setStatus(SkillVersionStatus.PUBLISHED); @@ -261,10 +266,18 @@ class SkillGovernanceServiceTest { setField(fallback, "id", 11L); fallback.setStatus(SkillVersionStatus.PUBLISHED); fallback.setPublishedAt(Instant.parse("2026-03-17T10:00:00Z")); + fallback.setParsedMetadataJson(new ObjectMapper().writeValueAsString(Map.of( + "name", "example-skill", + "description", "Published summary from v1", + "frontmatter", hasDisplayName + ? Map.of("metadata", Map.of("display-name", "示例技能一")) + : Map.of()))); Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); setField(skill, "id", 2L); skill.setLatestVersionId(22L); + skill.setDisplayName("示例技能二"); + skill.setSummary("Published summary from v2"); given(skillVersionRepository.findByIdForUpdate(22L)).willReturn(Optional.of(yanked)); given(skillVersionRepository.save(yanked)).willReturn(yanked); @@ -275,9 +288,57 @@ class SkillGovernanceServiceTest { service.yankVersion(22L, "admin", "127.0.0.1", "JUnit", "broken"); assertThat(skill.getLatestVersionId()).isEqualTo(11L); + assertThat(skill.getDisplayName()).isEqualTo(hasDisplayName ? "示例技能一" : "example-skill"); + assertThat(skill.getSummary()).isEqualTo("Published summary from v1"); verify(skillRepository).save(skill); } + @Test + void yankVersion_preservesMetadataWhenYankingAnOlderVersion() { + SkillVersion older = new SkillVersion(2L, "1.0.0", "owner"); + setField(older, "id", 11L); + older.setStatus(SkillVersionStatus.PUBLISHED); + Skill skill = new Skill(1L, "example-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 2L); + skill.setLatestVersionId(22L); + skill.setDisplayName("示例技能二"); + skill.setSummary("Published summary from v2"); + given(skillVersionRepository.findByIdForUpdate(11L)).willReturn(Optional.of(older)); + given(skillVersionRepository.save(older)).willReturn(older); + given(skillRepository.findById(2L)).willReturn(Optional.of(skill)); + + service.yankVersion(11L, "admin", "127.0.0.1", "JUnit", "broken"); + + assertThat(skill.getLatestVersionId()).isEqualTo(22L); + assertThat(skill.getDisplayName()).isEqualTo("示例技能二"); + assertThat(skill.getSummary()).isEqualTo("Published summary from v2"); + verify(skillRepository, never()).save(any()); + } + + @Test + void yankVersion_keepsHistoricalMetadataWhenNoPublishedVersionRemains() { + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + setField(version, "id", 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + Skill skill = new Skill(1L, "example-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 2L); + skill.setLatestVersionId(11L); + skill.setDisplayName("示例技能"); + skill.setSummary("Historical summary"); + given(skillVersionRepository.findByIdForUpdate(11L)).willReturn(Optional.of(version)); + given(skillVersionRepository.save(version)).willReturn(version); + given(skillRepository.findById(2L)).willReturn(Optional.of(skill)); + given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)) + .willReturn(java.util.List.of()); + given(skillRepository.save(skill)).willReturn(skill); + + service.yankVersion(11L, "admin", "127.0.0.1", "JUnit", "broken"); + + assertThat(skill.getLatestVersionId()).isNull(); + assertThat(skill.getDisplayName()).isEqualTo("示例技能"); + assertThat(skill.getSummary()).isEqualTo("Historical summary"); + } + @Test void deleteVersion_removesDraftFilesAndBundle() { Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index c3c5eacd..9029b678 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -1931,6 +1931,45 @@ class SkillPublishServiceTest { verify(skillVersionRepository, never()).save(any()); } + @Test + void publishesDisplayNameWithoutChangingSlugOrVersionMetadataName() throws Exception { + PublishFixture fixture = stubValidPublishInputs("test-ns", "user-100", "test-skill", "test-skill", "1.0.0", false); + when(skillMetadataParser.parse(anyString())).thenReturn(new SkillMetadata( + "test-skill", "Test", "1.0.0", "Body", + Map.of("metadata", Map.of("display-name", "示例技能")))); + + SkillPublishService.PublishResult result = service.publishFromEntries( + "test-ns", fixture.entries(), "user-100", SkillVisibility.PUBLIC, Set.of("SUPER_ADMIN")); + + assertEquals("test-skill", result.slug()); + ArgumentCaptor saved = ArgumentCaptor.forClass(Skill.class); + verify(skillRepository).save(saved.capture()); + assertEquals("示例技能", saved.getValue().getDisplayName()); + JsonNode snapshot = objectMapper.readTree(result.version().getParsedMetadataJson()); + assertEquals("test-skill", snapshot.path("name").asText()); + assertEquals("示例技能", snapshot.path("frontmatter").path("metadata").path("display-name").asText()); + } + + @Test + void pendingUpdateKeepsPublishedDisplayNameAndSummary() throws Exception { + PublishFixture fixture = stubValidPublishInputs("test-ns", "user-100", "test-skill", "test-skill", "2.0.0", true); + Skill skill = skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "test-skill", "user-100").orElseThrow(); + skill.setLatestVersionId(9L); + skill.setDisplayName("已审核的标题"); + skill.setSummary("Published summary"); + when(skillMetadataParser.parse(anyString())).thenReturn(new SkillMetadata( + "test-skill", "Pending summary", "2.0.0", "Body", + Map.of("metadata", Map.of("display-name", "待审核的标题")))); + + SkillPublishService.PublishResult result = service.publishFromEntries( + "test-ns", fixture.entries(), "user-100", SkillVisibility.PUBLIC, Set.of()); + + assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + assertEquals("已审核的标题", skill.getDisplayName()); + assertEquals("Published summary", skill.getSummary()); + assertEquals(9L, skill.getLatestVersionId()); + } + private record PublishFixture(List entries) { } diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 71cd9ebd..47a35ee7 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -58,6 +58,7 @@ interface ApiFailure extends Error { const cleanupTimeoutMs = process.env.CI ? 8_000 : 5_000 export interface SeedSkillOptions { + displayName?: string name?: string description?: string version?: string @@ -109,7 +110,7 @@ function buildSkillPackageContent(suffix: string, options?: SeedSkillOptions) { name: ${skillName} description: ${description} version: ${version} ---- +${options?.displayName === undefined ? '' : `metadata:\n display-name: ${JSON.stringify(options.displayName)}\n`}--- # ${readmeHeading} diff --git a/web/e2e/my-skills-navigation.spec.ts b/web/e2e/my-skills-navigation.spec.ts index 0bba27b8..dcb37b38 100644 --- a/web/e2e/my-skills-navigation.spec.ts +++ b/web/e2e/my-skills-navigation.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -import { registerSession } from './helpers/session' +import { loginWithCredentials, registerSession } from './helpers/session' import { E2eTestDataBuilder } from './helpers/test-data-builder' test.describe('My Skills Navigation (Real API)', () => { @@ -9,22 +9,46 @@ test.describe('My Skills Navigation (Real API)', () => { await registerSession(page, testInfo) }) - test('opens seeded skill detail from dashboard list and returns back', async ({ page }, testInfo) => { + test('shows a localized title while navigating with the original skill slug', async ({ page, browser }, testInfo) => { const builder = new E2eTestDataBuilder(page, testInfo) await builder.init() try { const namespace = await builder.ensureWritableNamespace() - const skill = await builder.publishSkill(namespace.slug) + const displayName = '示例技能' + const skill = await builder.publishSkill(namespace.slug, { displayName }) await page.goto('/dashboard/skills') await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible() - await page.getByText(`@${skill.namespace}`).first().click() + await expect(page.getByRole('heading', { name: displayName, exact: true })).toBeVisible() + await page.getByRole('heading', { name: displayName, exact: true }).click() await expect(page).toHaveURL(new RegExp(`/space/${skill.namespace}/${skill.slug}\\?returnTo=%2Fdashboard%2Fskills$`)) + await expect(page.getByRole('heading', { name: displayName, exact: true })).toBeVisible() + await page.getByRole('button', { name: 'Back' }).click() await expect(page).toHaveURL('/dashboard/skills') + + const adminContext = await browser.newContext() + try { + const adminPage = await adminContext.newPage() + await loginWithCredentials(adminPage, { + username: process.env.E2E_ADMIN_USERNAME ?? process.env.BOOTSTRAP_ADMIN_USERNAME ?? 'admin', + password: process.env.E2E_ADMIN_PASSWORD ?? process.env.BOOTSTRAP_ADMIN_PASSWORD ?? 'ChangeMe!2026', + }, testInfo) + const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo) + await adminBuilder.init() + const reviewId = await adminBuilder.waitForPendingReview(skill.namespace, skill.slug, skill.version) + await adminBuilder.approveReview(reviewId) + } finally { + await adminContext.close() + } + + // The Chinese query appears only in display-name, not the slug or description. + await builder.waitForSearchResult(displayName, skill.slug) + await page.goto(`/search?q=${encodeURIComponent(displayName)}`) + await expect(page.getByRole('heading', { name: displayName, exact: true })).toBeVisible() } finally { await builder.cleanup() } diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index f620de16..f9ed0378 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -151,6 +151,29 @@ describe('MySkillsPage', () => { }) }) + it('renders a localized display name independently of the package slug', () => { + useMySkillsMock.mockReturnValue({ + data: { + items: [{ + id: 1, + displayName: '示例技能', + namespace: 'team-ai', + slug: 'example-skill', + downloadCount: 0, + status: 'ACTIVE', + visibility: 'PUBLIC', + }], + total: 1, + page: 0, + size: 10, + }, + isLoading: false, + }) + + const markup = renderToStaticMarkup(createElement(MySkillsPage)) + expect(markup).toMatch(/]*>示例技能<\/h3>/) + }) + it('navigates to publish page with namespace and visibility when update is clicked', () => { renderToStaticMarkup(createElement(MySkillsPage))