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