diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index f9a2cc30..ef2a17f9 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -41,6 +41,8 @@ description: When to use # 必需,1-2 句话 - `name` 和 `description` 为必需字段,缺失则校验失败 - `name` 映射为 `skill.slug`(首次发布时),后续版本不可变更 - 可选的 `metadata.display-name` 映射为 `skill.display_name`,不参与 slug 生成。必须是字符串,去除首尾空白后最多 200 个字符;缺失、null 或空白时回退到 `name`。展示名沿用版本发布/审核流程,待审核更新不覆盖已发布标题;撤下最新版本并回退到旧版本时,标题和摘要同步恢复到该旧版本。 +- 网页发布 multipart 可选字段 `displayName` 在 trim 后非空时优先于包内展示名,最长 200 字符;空白回退包内字段。该覆盖值保存在版本元数据快照,原始 frontmatter 和文件内容不改写;下载 ZIP 沿用现有重新打包机制,压缩包二进制可能不同。CLI 不填写此字段时继续使用包内规则。 +- 网页上传后通过鉴权只读 `POST /api/web/skills/package-preview`(也提供 `/api/v1/skills/package-preview`)读取 `name/displayName/description/version`;此步骤不保存包、不创建技能或启动扫描,正式提交仍执行完整校验。 - 上述类型和长度限制在新上传解析时检查。读取历史版本快照时,非字符串或超长的旧 `display-name` 回退到该版本的 `name`,原始 frontmatter 保留,避免阻塞审核、提升或版本撤回。 - `description` 映射为 `skill.summary` - frontmatter 完整解析结果存入 `skill_version.parsed_metadata_json` diff --git a/docs/skillhub/en/guide/skill-publish.md b/docs/skillhub/en/guide/skill-publish.md index ca3a49fb..70322b4a 100644 --- a/docs/skillhub/en/guide/skill-publish.md +++ b/docs/skillhub/en/guide/skill-publish.md @@ -92,6 +92,12 @@ review lifecycle: an update awaiting review does not replace an existing publish No database migration is needed; existing skills can keep their `name` and publish a new version with this optional metadata. +On the web publish/update form, upload a package and edit **Display name**. The package's `metadata.display-name` (or `name`) pre-fills the field. Your edits take precedence and survive selecting another package; leave it blank to use the package title. The name is limited to 200 characters and follows this version's review. Avatar-only updates cannot rename a skill. + +**My Skills** shows the pending update's title and summary with one main status and a plain-text comparison to the published version. Open the card to preview that update; public readers still see the published version. Details always show the copyable `@namespace/slug`; renaming does not change links or install identifiers. + +Web names are stored in platform version metadata without rewriting `SKILL.md` or other package files. Downloads use the existing ZIP repackaging process, so compressed archive bytes may differ. CLI publishing keeps using package metadata. Approval, rollback after yanking, and promotion use the corresponding version's name and summary. + 2. **Publish via CLI (Recommended)** ```bash diff --git a/docs/skillhub/guide/skill-publish.md b/docs/skillhub/guide/skill-publish.md index 78fb9cb4..e5e5de2d 100644 --- a/docs/skillhub/guide/skill-publish.md +++ b/docs/skillhub/guide/skill-publish.md @@ -88,6 +88,12 @@ metadata: 打包已有技能时,应一并保留其所需的脚本、参考文件、`agents/` 元数据和许可证文件。 下一步发布命令的路径应指向该技能目录(本例为 `./my-skill`)。 +网页发布或更新时,可在上传技能包后编辑“显示名称”。默认读取包内 `metadata.display-name`,缺省使用 `name`;你修改的值优先,换包不会覆盖已填写的名称,留空则采用包内名称。名称最多 200 个字符,随本次版本审核,不单独修改线上标题;只改头像时此字段不可用。 + +“我的技能”会显示待审新版的名称和摘要,仅保留一个主状态,并说明本次版本与当前公开版本。点击后可预览新版;公众继续看到已发布版本。详情中始终显示可复制的 `@命名空间/slug`,改名不改变链接或安装标识。 + +网页填写的名称保存在平台版本元数据中,包内 `SKILL.md` 不会被改写;下载 ZIP 沿用现有重新打包逻辑,压缩后的字节可能不同。CLI 发布继续使用包内名称规则。审核通过、撤下新版回退或提升所选版本时,展示名称和摘要跟随对应版本。 + 2. **使用 CLI 发布(推荐)** ```bash diff --git a/openspec/changes/refine-display-name-publishing/design.md b/openspec/changes/refine-display-name-publishing/design.md new file mode 100644 index 00000000..fadbeda6 --- /dev/null +++ b/openspec/changes/refine-display-name-publishing/design.md @@ -0,0 +1,8 @@ +## Decisions + +1. 发布 multipart 增加可选 displayName,trim 后非空值优先,最多 200 个字符;空值回退包内展示名称。原包校验保持严格。 +2. 在现有 parsedMetadataJson 中增加可选 displayNameOverride;原始 frontmatter 不变,旧快照兼容。审核、撤下回退和所选版本提升从相应版本快照读取名称。 +3. 鉴权只读 package-preview 复用包解压安全策略和元数据解析器,仅返回 name/displayName/description/version,不存储文件、不创建版本、不启动扫描。 +4. 前端选包后读取建议名称;每次选择独立请求,过期结果不能覆盖当前文件,用户已编辑的名称不被新预填覆盖。读取失败可重新选包重试;头像单独更新不提交名称。 +5. 生命周期版本投影提供可选 displayName/summary;现有权限决定 ownerPreview 是否可见。管理列表只保留一个主状态,治理状态优先于更新状态,待审更新优先于已发布状态;版本差异使用普通说明。 +6. 保留现有单套预览数据;关键阶段独立 Review 后执行真实浏览器和生命周期验收。 diff --git a/openspec/changes/refine-display-name-publishing/proposal.md b/openspec/changes/refine-display-name-publishing/proposal.md new file mode 100644 index 00000000..818cde6c --- /dev/null +++ b/openspec/changes/refine-display-name-publishing/proposal.md @@ -0,0 +1,14 @@ +## Why + +PR #957 支持包内展示名称,但网页不能填写,更新待审时“我的技能”仍显示旧标题并重复展示发布和审核状态,详情缺少完整技能标识。 + +## What Changes + +- 网页新增/更新技能时可填写展示名称;上传后从包内 metadata.display-name(缺省 name)预填,用户输入优先。 +- 表单名称作为版本元数据快照保存,不修改上传 ZIP 或 SKILL.md。CLI 继续采用包内字段。 +- 管理列表展示待审更新的名称和摘要,使用一个主状态;公开页面保持已发布信息。 +- 详情展示完整 @namespace/slug 和复制入口,所有者预览明确标识未公开。 + +## Scope + +沿用现有版本审核、撤回、提升和权限。无独立改名流程、无新数据库迁移,不改变 slug 或下载包内容。 diff --git a/openspec/changes/refine-display-name-publishing/specs/skill-display-name/spec.md b/openspec/changes/refine-display-name-publishing/specs/skill-display-name/spec.md new file mode 100644 index 00000000..65a1f44f --- /dev/null +++ b/openspec/changes/refine-display-name-publishing/specs/skill-display-name/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Web version display name +The system SHALL accept an optional display name on version publish without changing the uploaded package or stable slug. + +#### Scenario: Explicit web name overrides package suggestion +- **WHEN** the package declares a display name and the user enters another nonblank name of at most 200 characters +- **THEN** the version snapshot uses the trimmed web name, while package file contents and frontmatter retain the original values (download ZIP compression bytes may differ) + +#### Scenario: Package defaults and CLI compatibility +- **WHEN** no nonblank web name is submitted +- **THEN** metadata.display-name is used, falling back to name; invalid new package metadata remains rejected + +#### Scenario: Safe prefill +- **WHEN** the user changes packages during parsing or edits the suggested name +- **THEN** an older response cannot populate the current package and prefill cannot overwrite user input + +### Requirement: Version lifecycle and preview isolation +The system SHALL keep display names and summaries associated with their version through approval, rollback and promotion. + +#### Scenario: Pending update +- **WHEN** an existing skill has a pending updated version +- **THEN** its management card displays that version's title and summary with one main status, and public readers continue to see the published title and summary + +#### Scenario: Approval, rollback and promotion +- **WHEN** a version is approved, the latest version is yanked, or a chosen source version is promoted +- **THEN** title and summary match the relevant approved, remaining or chosen version snapshot, including a web override + +### Requirement: Stable coordinate and clear private preview +The detail page SHALL show the full copyable @namespace/slug coordinate and label unpublished owner previews explicitly. + +#### Scenario: Copy coordinate +- **WHEN** the user copies the skill identifier +- **THEN** the clipboard contains the full stable coordinate regardless of display name diff --git a/openspec/changes/refine-display-name-publishing/tasks.md b/openspec/changes/refine-display-name-publishing/tasks.md new file mode 100644 index 00000000..8fe4fb8f --- /dev/null +++ b/openspec/changes/refine-display-name-publishing/tasks.md @@ -0,0 +1,14 @@ +## Implementation + +- [ ] Add safe authenticated package preview and version-bound optional display name. +- [ ] Preserve snapshot compatibility and lifecycle metadata across approval/yank/promotion. +- [ ] Implement web prefill with user-edit and stale-response protection. +- [ ] Show one management status with pending metadata and full copyable detail coordinate. +- [ ] Update generated API types and user guides. + +## Verification + +- [ ] Complete one independent exact-diff Review and freeze code/base. +- [ ] Review focused test design and run source checks. +- [ ] Refresh the existing preview and verify web publish, owner/public isolation, approval, rollback, promotion and package byte preservation. +- [ ] Update PR #957 and the canonical validation report; retain preview for user acceptance. diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index d81e1637..8ee8e85f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -496,7 +496,8 @@ public class SkillController extends BaseApiController { if (projection == null) { return null; } - return new SkillLifecycleVersionResponse(projection.id(), projection.version(), projection.status()); + return new SkillLifecycleVersionResponse(projection.id(), projection.version(), projection.status(), + projection.displayName(), projection.summary()); } private SkillVersionCompareResponse toCompareResponse(SkillQueryService.SkillVersionCompareDTO compare) { 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 9b9235e5..f03be1e6 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 @@ -1,5 +1,8 @@ package com.iflytek.skillhub.controller.portal; +import com.iflytek.skillhub.service.SkillPackagePreviewService; +import com.iflytek.skillhub.dto.SkillPackagePreviewResponse; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; @@ -35,15 +38,33 @@ public class SkillPublishController extends BaseApiController { private final SkillPublishAppService skillPublishService; private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; private final SkillHubMetrics skillHubMetrics; + private final SkillPackagePreviewService packagePreview; + + @PostMapping(value = "/package-preview", consumes = "multipart/form-data") + @RateLimit(category = "publish", authenticated = 10, anonymous = 0) + public ApiResponse previewPackage( + @RequestParam("file") MultipartFile file, + @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { + if (principal == null) { + throw new DomainForbiddenException("error.auth.required"); + } + try { + return ok("response.success.read", packagePreview.preview(skillPackageArchiveExtractor.extract(file))); + } catch (IllegalArgumentException exception) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", exception.getMessage()); + } + } public SkillPublishController(SkillPublishAppService skillPublishService, SkillPackageArchiveExtractor skillPackageArchiveExtractor, ApiResponseFactory responseFactory, - SkillHubMetrics skillHubMetrics) { + SkillHubMetrics skillHubMetrics, + SkillPackagePreviewService packagePreview) { super(responseFactory); this.skillPublishService = skillPublishService; this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; this.skillHubMetrics = skillHubMetrics; + this.packagePreview = packagePreview; } /** @@ -60,6 +81,7 @@ public class SkillPublishController extends BaseApiController { @RequestParam(value = "avatarAction", defaultValue = "KEEP") SkillPublishAppService.AvatarAction avatarAction, @RequestParam(value = "avatarUploadId", required = false) String avatarUploadId, @RequestParam(value = "expectedSlug", required = false) String expectedSlug, + @RequestParam(value = "displayName", required = false) String displayName, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); @@ -87,7 +109,7 @@ public class SkillPublishController extends BaseApiController { principal.userId(), skillVisibility, principal.platformRoles(), - confirmWarnings, avatarAction, avatarUploadId, expectedSlug, AuditRequestContext.from(request) + confirmWarnings, avatarAction, avatarUploadId, expectedSlug, displayName, AuditRequestContext.from(request) ); PublishResponse response = new PublishResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleVersionResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleVersionResponse.java index aea959be..5d526080 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleVersionResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleVersionResponse.java @@ -3,5 +3,11 @@ package com.iflytek.skillhub.dto; public record SkillLifecycleVersionResponse( Long id, String version, - String status -) {} + String status, + String displayName, + String summary +) { + public SkillLifecycleVersionResponse(Long id, String version, String status) { + this(id, version, status, null, null); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillPackagePreviewResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillPackagePreviewResponse.java new file mode 100644 index 00000000..18609b7b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillPackagePreviewResponse.java @@ -0,0 +1,4 @@ +package com.iflytek.skillhub.dto; + +/** Package defaults only: preview never creates a skill or changes its files. */ +public record SkillPackagePreviewResponse(String name, String displayName, String description, String version) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java index 7ad9e013..ce65d0b7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java @@ -173,6 +173,7 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository { if (projection == null) { return null; } - return new SkillLifecycleVersionResponse(projection.id(), projection.version(), projection.status()); + return new SkillLifecycleVersionResponse(projection.id(), projection.version(), projection.status(), + projection.displayName(), projection.summary()); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java index f4a57d0f..92bf1d6c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java @@ -1,5 +1,9 @@ package com.iflytek.skillhub.service; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import com.iflytek.skillhub.domain.review.ReviewSubjectType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; @@ -42,6 +46,7 @@ public class ReviewSkillDetailAppService { private final SkillQueryService skillQueryService; private final SkillDownloadService skillDownloadService; private final ComplianceSnapshotProjectionService complianceSnapshotProjectionService; + private final ObjectReader metadataReader; private SkillRepository skills; private SkillAvatarCandidateJpaRepository candidates; @@ -59,6 +64,21 @@ public class ReviewSkillDetailAppService { SkillQueryService skillQueryService, SkillDownloadService skillDownloadService, ComplianceSnapshotProjectionService complianceSnapshotProjectionService) { + this(reviewTaskRepository, namespaceRepository, reviewService, rbacService, skillQueryService, + skillDownloadService, complianceSnapshotProjectionService, new ObjectMapper()); + } + + @Autowired + public ReviewSkillDetailAppService(ReviewTaskRepository reviewTaskRepository, + NamespaceRepository namespaceRepository, + ReviewService reviewService, + RbacService rbacService, + SkillQueryService skillQueryService, + SkillDownloadService skillDownloadService, + ComplianceSnapshotProjectionService complianceSnapshotProjectionService, + ObjectMapper objectMapper) { + this.metadataReader = objectMapper.readerFor(SkillMetadata.class) + .without(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); this.reviewTaskRepository = reviewTaskRepository; this.namespaceRepository = namespaceRepository; this.reviewService = reviewService; @@ -78,13 +98,14 @@ public class ReviewSkillDetailAppService { SkillQueryService.ReviewSkillSnapshotDTO snapshot = skillQueryService.getReviewSkillSnapshot(context.task().getSkillVersionId()); + SkillLifecycleVersionResponse activeVersion = toLifecycleVersion(snapshot.activeVersion()); SkillDetailResponse skill = new SkillDetailResponse( snapshot.skill().getId(), snapshot.skill().getSlug(), - snapshot.skill().getDisplayName(), + activeVersion.displayName() != null ? activeVersion.displayName() : snapshot.skill().getDisplayName(), snapshot.skill().getOwnerId(), snapshot.ownerDisplayName(), - snapshot.skill().getSummary(), + activeVersion.summary() != null ? activeVersion.summary() : snapshot.skill().getSummary(), snapshot.skill().getVisibility().name(), snapshot.skill().getStatus().name(), snapshot.skill().getDownloadCount(), @@ -229,7 +250,16 @@ public class ReviewSkillDetailAppService { } private SkillLifecycleVersionResponse toLifecycleVersion(SkillVersion version) { - return new SkillLifecycleVersionResponse(version.getId(), version.getVersion(), version.getStatus().name()); + SkillMetadata metadata = null; + if (version.getParsedMetadataJson() != null) { + try { + metadata = metadataReader.readValue(version.getParsedMetadataJson()); + } catch (java.io.IOException ignored) { + // Missing or malformed legacy snapshots retain the existing container fallback. + } + } + return new SkillLifecycleVersionResponse(version.getId(), version.getVersion(), version.getStatus().name(), + metadata == null ? null : metadata.displayName(), metadata == null ? null : metadata.description()); } private record ReviewAccessContext(ReviewTask task, Namespace namespace) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPackagePreviewService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPackagePreviewService.java new file mode 100644 index 00000000..4486f5e0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPackagePreviewService.java @@ -0,0 +1,34 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; +import com.iflytek.skillhub.domain.skill.validation.ValidationResult; +import com.iflytek.skillhub.dto.SkillPackagePreviewResponse; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +public class SkillPackagePreviewService { + private final SkillPackageValidator validator; + private final SkillMetadataParser parser; + + public SkillPackagePreviewService(SkillPackageValidator validator, SkillMetadataParser parser) { + this.validator = validator; + this.parser = parser; + } + + public SkillPackagePreviewResponse preview(List entries) { + ValidationResult validation = validator.validate(entries); + if (!validation.passed()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", String.join(", ", validation.errors())); + } + PackageEntry skillMd = entries.stream().filter(entry -> "SKILL.md".equals(entry.path())).findFirst() + .orElseThrow(() -> new DomainBadRequestException("error.skill.publish.skillMd.notFound")); + SkillMetadata metadata = parser.parse(new String(skillMd.content(), StandardCharsets.UTF_8)); + return new SkillPackagePreviewResponse(metadata.name(), metadata.displayName(), metadata.description(), metadata.version()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java index ff4061e9..ad49260f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java @@ -41,6 +41,14 @@ public class SkillPublishAppService { public SkillPublishService.PublishResult publish(String namespace, List entries, String actor, SkillVisibility visibility, Set roles, boolean confirmWarnings, AvatarAction action, String uploadId, String expectedSlug, AuditRequestContext context) { + return publish(namespace, entries, actor, visibility, roles, confirmWarnings, action, + uploadId, expectedSlug, null, context); + } + + @Transactional + public SkillPublishService.PublishResult publish(String namespace, List entries, String actor, + SkillVisibility visibility, Set roles, boolean confirmWarnings, AvatarAction action, + String uploadId, String expectedSlug, String displayName, AuditRequestContext context) { if (action == AvatarAction.REPLACE && (uploadId == null || uploadId.isBlank()) || action != AvatarAction.REPLACE && uploadId != null && !uploadId.isBlank()) { throw new DomainBadRequestException("error.skill.avatar.invalidUpload"); @@ -56,8 +64,9 @@ public class SkillPublishAppService { uploads.validateReference(namespace, targetSlug, actor, uploadId); } } - SkillPublishService.PublishResult result = publications.publishFromEntries( - namespace, entries, actor, visibility, roles, confirmWarnings); + SkillPublishService.PublishResult result = displayName == null || displayName.isBlank() + ? publications.publishFromEntries(namespace, entries, actor, visibility, roles, confirmWarnings) + : publications.publishFromEntries(namespace, entries, actor, visibility, roles, confirmWarnings, displayName); var review = reviews.findBySkillVersionIdAndStatus( result.version().getId(), ReviewTaskStatus.PENDING).orElse(null); if (action == AvatarAction.REPLACE) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 60bb9cdd..4935b66c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -290,7 +290,9 @@ public class SkillSearchAppService { return new com.iflytek.skillhub.dto.SkillLifecycleVersionResponse( projection.id(), projection.version(), - projection.status() + projection.status(), + projection.displayName(), + projection.summary() ); } diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index d5ee5847..197d4742 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -338,3 +338,5 @@ error.skill.avatar.skillMismatch=The uploaded package does not match the skill b 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 + +error.skill.displayName.tooLong=Skill 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 353bed53..60522825 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -281,3 +281,5 @@ error.skill.avatar.skillMismatch=Загруженный пакет не соот error.skill.metadata.displayName.invalid=metadata.display-name должно быть строкой error.skill.metadata.displayName.tooLong=metadata.display-name не должно превышать {0} символов + +error.skill.displayName.tooLong=Отображаемое имя навыка не должно превышать {0} символов diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 6866a34e..854abbf5 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -340,3 +340,5 @@ error.skill.avatar.skillMismatch=上传的技能包与正在更新的技能不 error.skill.metadata.displayName.invalid=metadata.display-name 必须是字符串 error.skill.metadata.displayName.tooLong=metadata.display-name 不能超过 {0} 个字符 + +error.skill.displayName.tooLong=技能显示名称不能超过 {0} 个字符 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 8b8c714a..d5b5f737 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 @@ -220,6 +220,56 @@ class SkillPublishControllerTest { .andExpect(jsonPath("$.code").value(0)); } + @Test + void publishPassesExplicitDisplayNameWithoutRewritingPackage() throws Exception { + SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + given(skillPublishService.publishFromEntries(eq("global"), ArgumentMatchers.>any(), + eq("usr_1"), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false), eq(" 网页标题 "))) + .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); + var principal = new PlatformPrincipal("usr_1", "publisher", "publisher@example.com", "", "local", Set.of("SUPER_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); + mockMvc.perform(multipart("/api/v1/skills/global/publish") + .file(new MockMultipartFile("file", "skill.zip", "application/zip", buildZipBytes())) + .param("visibility", "PUBLIC").param("displayName", " 网页标题 ") + .with(authentication(auth)).with(csrf())) + .andExpect(status().isOk()); + } + + @Test + void packagePreviewReturnsDefaultsWithoutPublishing() throws Exception { + byte[] archive; + try (var output = new ByteArrayOutputStream(); var zip = new ZipOutputStream(output)) { + zip.putNextEntry(new ZipEntry("SKILL.md")); + zip.write("---\nname: example-skill\ndescription: Package summary\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("usr_1", "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("example-skill")) + .andExpect(jsonPath("$.data.displayName").value("包内标题")) + .andExpect(jsonPath("$.data.description").value("Package summary")) + .andExpect(jsonPath("$.data.version").value("1.0.0")); + } + org.mockito.Mockito.verifyNoInteractions(skillPublishService); + } + + @Test + void packagePreviewRejectsAnonymousWithoutPublishing() throws Exception { + mockMvc.perform(multipart("/api/v1/skills/package-preview") + .file(new MockMultipartFile("file", "skill.zip", "application/zip", buildZipBytes())) + .with(csrf())) + .andExpect(status().isTooManyRequests()); + org.mockito.Mockito.verifyNoInteractions(skillPublishService); + } + private byte[] buildZipBytes() throws Exception { try (ByteArrayOutputStream output = new ByteArrayOutputStream(); ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepositoryTest.java index f8ac810d..a1d27b2a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepositoryTest.java @@ -64,6 +64,7 @@ class JpaMySkillQueryRepositoryTest { SkillVersion rejectedVersion = new SkillVersion(2L, "1.3.0", "user-1"); rejectedVersion.setStatus(SkillVersionStatus.REJECTED); + rejectedVersion.setParsedMetadataJson("{\"name\":\"team-skill\",\"description\":\"待审摘要\",\"displayNameOverride\":\"网页新标题\",\"frontmatter\":{}}"); ReflectionTestUtils.setField(rejectedVersion, "id", 23L); ReflectionTestUtils.setField(rejectedVersion, "createdAt", Instant.parse("2026-03-15T11:30:00Z")); @@ -82,6 +83,8 @@ class JpaMySkillQueryRepositoryTest { assertThat(responses.get(0).publishedVersion()).isNotNull(); assertThat(responses.get(0).ownerPreviewVersion()).isNotNull(); assertThat(responses.get(0).ownerPreviewVersion().status()).isEqualTo("REJECTED"); + assertThat(responses.get(0).ownerPreviewVersion().displayName()).isEqualTo("网页新标题"); + assertThat(responses.get(0).ownerPreviewVersion().summary()).isEqualTo("待审摘要"); assertThat(responses.get(0).canSubmitPromotion()).isTrue(); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewSkillDetailAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewSkillDetailAppServiceTest.java index 1fc192b1..d1438d89 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewSkillDetailAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewSkillDetailAppServiceTest.java @@ -75,7 +75,7 @@ class ReviewSkillDetailAppServiceTest { Skill skill = createSkill(101L, 20L, "skill-a"); SkillVersion pending = createVersion(101L, 101L, "1.2.0", SkillVersionStatus.PENDING_REVIEW); pending.setParsedMetadataJson(""" - {"complianceSnapshot":{"schemaVersion":"1.0","items":[{"standard":"mitre-attack","version":"v19.1","controlId":"T1059","evidence":[]}],"digest":"sha256:demo"}} + {"name":"skill-a","description":"待审摘要","displayNameOverride":"网页新标题","complianceSnapshot":{"schemaVersion":"1.0","items":[{"standard":"mitre-attack","version":"v19.1","controlId":"T1059","evidence":[]}],"digest":"sha256:demo"}} """); SkillVersion published = createVersion(100L, 101L, "1.1.0", SkillVersionStatus.PUBLISHED); SkillFile readme = createFile(1L, 101L, "README.md"); @@ -100,6 +100,9 @@ class ReviewSkillDetailAppServiceTest { ReviewSkillDetailResponse response = service.getReviewSkillDetail(42L, "admin", Map.of()); assertThat(response.activeVersion()).isEqualTo("1.2.0"); + assertThat(response.skill().displayName()).isEqualTo("网页新标题"); + assertThat(response.skill().summary()).isEqualTo("待审摘要"); + assertThat(response.skill().ownerPreviewVersion().displayName()).isEqualTo("网页新标题"); assertThat(response.downloadUrl()).isEqualTo("/api/v1/reviews/42/download"); assertThat(response.documentationPath()).isEqualTo("README.md"); assertThat(response.documentationContent()).isEqualTo("# demo"); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPackagePreviewServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPackagePreviewServiceTest.java new file mode 100644 index 00000000..95c531d6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPackagePreviewServiceTest.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SkillPackagePreviewServiceTest { + private final SkillMetadataParser parser = new SkillMetadataParser(); + private final SkillPackagePreviewService service = new SkillPackagePreviewService(new SkillPackageValidator(parser), parser); + + @Test + void missingDisplayNameUsesPackageNameAndDoesNotInventVersion() { + var response = service.preview(entries("")); + assertThat(response.name()).isEqualTo("example-skill"); + assertThat(response.displayName()).isEqualTo("example-skill"); + assertThat(response.version()).isNull(); + } + + @Test + void previewUsesSameStrictMetadataRulesAsPublish() { + for (String displayName : List.of("123", "中".repeat(201))) { + assertThatThrownBy(() -> service.preview(entries("metadata:\n display-name: " + displayName + "\n"))) + .isInstanceOf(DomainBadRequestException.class); + } + } + + @Test + void missingSkillMdCannotBePreviewed() { + assertThatThrownBy(() -> service.preview(List.of())) + .isInstanceOf(DomainBadRequestException.class); + } + + private List entries(String extra) { + byte[] bytes = ("---\nname: example-skill\ndescription: Summary\n" + extra + "---\nBody").getBytes(StandardCharsets.UTF_8); + return List.of(new PackageEntry("SKILL.md", bytes, bytes.length, "text/markdown")); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 25412f32..23536a47 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -56,6 +56,7 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/reviews/me"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills"), RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/skills/avatar-uploads"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/skills/package-preview"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/avatar-uploads/*/image"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/avatar"), @@ -74,6 +75,7 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/labels"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills"), RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/skills/avatar-uploads"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/skills/package-preview"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/avatar-uploads/*/image"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/avatar"), @@ -300,7 +302,9 @@ public class RouteSecurityPolicyRegistry { "ANY /api/v1/auth/local/**", "ANY /api/v1/admin/**", "POST /api/v1/skills/avatar-uploads", + "POST /api/v1/skills/package-preview", "POST /api/web/skills/avatar-uploads", + "POST /api/web/skills/package-preview", "POST /api/v1/suites/reviews/*/approve", "POST /api/v1/suites/reviews/*/reject", "POST /api/web/suites/reviews/*/approve", diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 8914db47..bf3d7c3a 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -18,6 +18,15 @@ class RouteSecurityPolicyRegistryTest { private final RouteSecurityPolicyRegistry registry = new RouteSecurityPolicyRegistry(); + @Test + void packagePreviewIsAuthenticatedAndSessionOnly() { + for (String prefix : java.util.List.of("/api/v1", "/api/web")) { + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("POST", prefix + "/skills/package-preview")); + assertFalse(registry.authorizeApiToken("POST", prefix + "/skills/package-preview", Set.of("skill:read", "skill:publish", "skill:delete", "token:manage")).allowed()); + } + } + @Test void accessLevel_respectsMethodSpecificPublicRoutesAndProtectedFallback() { assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL, @@ -54,7 +63,7 @@ class RouteSecurityPolicyRegistryTest { String upload = prefix + "/skills/avatar-uploads"; assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, registry.accessLevel("POST", upload)); assertTrue(registry.sessionOnlyRoutes().contains("POST " + upload)); - assertFalse(registry.authorizeApiToken("POST", upload, ALL_SCOPES).allowed()); + assertFalse(registry.authorizeApiToken("POST", upload, Set.of("skill:read", "skill:publish", "skill:delete", "token:manage")).allowed()); assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, registry.accessLevel("GET", upload + "/upload-id/image")); } @@ -139,7 +148,7 @@ class RouteSecurityPolicyRegistryTest { assertFalse(deniedSubmit.allowed()); assertEquals("skill:publish", deniedSubmit.requiredScope()); assertFalse(registry.authorizeApiToken( - "POST", "/api/v1/suites/reviews/7/approve", ALL_SCOPES).allowed()); + "POST", "/api/v1/suites/reviews/7/approve", Set.of("skill:read", "skill:publish", "skill:delete", "token:manage")).allowed()); } @Test @@ -207,7 +216,7 @@ class RouteSecurityPolicyRegistryTest { @Test void authorizeApiToken_keepsAdminYankSessionOnly() { - assertFalse(registry.authorizeApiToken("POST", "/api/v1/admin/skills/versions/42/yank", ALL_SCOPES).allowed()); + assertFalse(registry.authorizeApiToken("POST", "/api/v1/admin/skills/versions/42/yank", Set.of("skill:read", "skill:publish", "skill:delete", "token:manage")).allowed()); } @Test @@ -230,8 +239,8 @@ class RouteSecurityPolicyRegistryTest { @Test void authorizeApiToken_keepsWholeSkillWebDeleteSessionOnlyWhileAllowingVersionDelete() { - assertFalse(registry.authorizeApiToken("DELETE", "/api/web/skills/global/demo-skill", ALL_SCOPES).allowed()); - assertTrue(registry.authorizeApiToken("DELETE", "/api/web/skills/global/demo-skill/versions/1.2.3", ALL_SCOPES).allowed()); + assertFalse(registry.authorizeApiToken("DELETE", "/api/web/skills/global/demo-skill", Set.of("skill:read", "skill:publish", "skill:delete", "token:manage")).allowed()); + assertTrue(registry.authorizeApiToken("DELETE", "/api/web/skills/global/demo-skill/versions/1.2.3", Set.of("skill:read", "skill:publish", "skill:delete", "token:manage")).allowed()); } @Test @@ -402,7 +411,7 @@ class RouteSecurityPolicyRegistryTest { String method = policy.method() == null ? "GET" : policy.method().name(); String path = samplePath(policy.pattern()); - if (!registry.authorizeApiToken(method, path, ALL_SCOPES).allowed()) { + if (!registry.authorizeApiToken(method, path, Set.of("skill:read", "skill:publish", "skill:delete", "token:manage")).allowed()) { gaps.add(key); } } @@ -442,7 +451,7 @@ class RouteSecurityPolicyRegistryTest { for (String route : registry.sessionOnlyRoutes()) { String[] parts = route.split(" ", 2); String method = "ANY".equals(parts[0]) ? "POST" : parts[0]; - assertFalse(registry.authorizeApiToken(method, samplePath(parts[1]), ALL_SCOPES).allowed(), + assertFalse(registry.authorizeApiToken(method, samplePath(parts[1]), Set.of("skill:read", "skill:publish", "skill:delete", "token:manage")).allowed(), "session-only route must stay closed to API tokens: " + route); } } 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 8fb58402..1c1a65e4 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( @@ -7,10 +8,30 @@ public record SkillMetadata( String description, String version, String body, - Map frontmatter + Map frontmatter, + String displayNameOverride ) { + public SkillMetadata(String name, String description, String version, String body, Map frontmatter) { + this(name, description, version, body, frontmatter, null); + } + + /** Explicit portal input is validated before it becomes part of the version snapshot. */ + public SkillMetadata withDisplayNameOverride(String value) { + String override = value == null ? null : value.strip(); + if (override != null && override.length() > 200) { + throw new DomainBadRequestException( + "error.skill.displayName.tooLong", 200); + } + return new SkillMetadata(name, description, version, body, frontmatter, + override == null || override.isEmpty() ? null : override); + } + /** Human-readable label; unsupported legacy snapshot values fall back to the package name. */ public String displayName() { + if (displayNameOverride != null && !displayNameOverride.isBlank() + && displayNameOverride.strip().length() <= 200) { + return displayNameOverride.strip(); + } return resolveDisplayName(name, frontmatter); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 4bb3bfa5..14d44bfc 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -1,5 +1,10 @@ package com.iflytek.skillhub.domain.skill.service; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; +import org.springframework.beans.factory.annotation.Autowired; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillInstallability; @@ -31,10 +36,15 @@ public class SkillLifecycleProjectionService { Long id, String version, String status, - String parsedMetadataJson + String parsedMetadataJson, + String displayName, + String summary ) { + public VersionProjection(Long id, String version, String status, String parsedMetadataJson) { + this(id, version, status, parsedMetadataJson, null, null); + } public VersionProjection(Long id, String version, String status) { - this(id, version, status, null); + this(id, version, status, null, null, null); } } @@ -50,9 +60,18 @@ public class SkillLifecycleProjectionService { .thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder())); private final SkillVersionRepository skillVersionRepository; + private final ObjectReader metadataReader; public SkillLifecycleProjectionService(SkillVersionRepository skillVersionRepository) { + this(skillVersionRepository, new ObjectMapper()); + } + + @Autowired + public SkillLifecycleProjectionService(SkillVersionRepository skillVersionRepository, + ObjectMapper objectMapper) { this.skillVersionRepository = skillVersionRepository; + this.metadataReader = objectMapper.readerFor(SkillMetadata.class) + .without(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); } public Projection projectForViewer(Skill skill, String currentUserId, Map userNsRoles) { @@ -158,11 +177,21 @@ public class SkillLifecycleProjectionService { if (version == null) { return null; } + SkillMetadata metadata = null; + if (version.getParsedMetadataJson() != null) { + try { + metadata = metadataReader.readValue(version.getParsedMetadataJson()); + } catch (java.io.IOException ignored) { + // Legacy versions can have no usable snapshot; keep the existing container fallback. + } + } return new VersionProjection( version.getId(), version.getVersion(), version.getStatus().name(), - version.getParsedMetadataJson() + version.getParsedMetadataJson(), + metadata == null ? null : metadata.displayName(), + metadata == null ? null : metadata.description() ); } } 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 3b86448a..0e489331 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 @@ -317,6 +317,15 @@ public class SkillPublishService { return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, confirmWarnings, false, false); } + @Transactional + public PublishResult publishFromEntries( + String namespaceSlug, List entries, String publisherId, + SkillVisibility visibility, Set platformRoles, boolean confirmWarnings, + String displayName) { + return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, + confirmWarnings, false, false, null, displayName); + } + /** * Publishes one package that was explicitly bound by a confirmed Suite Bundle plan. * @@ -387,10 +396,24 @@ public class SkillPublishService { Set.of(), confirmWarnings, // confirmWarnings: honour caller's choice for rerelease false, // forceAutoPublish=false: respect visibility rules - true + true, + null, + displayNameOverrideForRerelease(publishedVersion) ); } + private String displayNameOverrideForRerelease(SkillVersion source) { + if (source.getParsedMetadataJson() == null) { + return null; + } + try { + String override = objectMapper.readTree(source.getParsedMetadataJson()).path("displayNameOverride").textValue(); + return override != null && override.strip().length() <= 200 ? override : null; + } catch (java.io.IOException ignored) { + return null; + } + } + private PublishResult publishFromEntriesInternal( String namespaceSlug, List entries, @@ -415,6 +438,15 @@ public class SkillPublishService { boolean forceAutoPublish, boolean bypassMembershipCheck, BundlePublicationTarget bundleTarget) { + return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, + confirmWarnings, forceAutoPublish, bypassMembershipCheck, bundleTarget, null); + } + + private PublishResult publishFromEntriesInternal( + String namespaceSlug, List entries, String publisherId, + SkillVisibility visibility, Set platformRoles, boolean confirmWarnings, + boolean forceAutoPublish, boolean bypassMembershipCheck, + BundlePublicationTarget bundleTarget, String displayName) { // 1. Find namespace by slug Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) @@ -444,10 +476,10 @@ public class SkillPublishService { .orElseThrow(() -> new DomainBadRequestException("error.skill.publish.skillMd.notFound")); String skillMdContent = new String(skillMd.content()); - SkillMetadata metadata = skillMetadataParser.parse(skillMdContent); + SkillMetadata metadata = skillMetadataParser.parse(skillMdContent).withDisplayNameOverride(displayName); if (metadata.version() == null || metadata.version().isBlank()) { String autoVersion = AUTO_VERSION_FORMATTER.format(currentTime()); - metadata = new SkillMetadata(metadata.name(), metadata.description(), autoVersion, metadata.body(), metadata.frontmatter()); + metadata = new SkillMetadata(metadata.name(), metadata.description(), autoVersion, metadata.body(), metadata.frontmatter(), metadata.displayNameOverride()); } String skillSlug = SlugValidator.slugify(metadata.name()); if (bundleTarget != null @@ -980,6 +1012,7 @@ public class SkillPublishService { parsedMetadata.put("version", metadata.version()); parsedMetadata.put("body", metadata.body()); parsedMetadata.put("frontmatter", metadata.frontmatter()); + parsedMetadata.put("displayNameOverride", metadata.displayNameOverride()); parsedMetadata.put(ComplianceMetadataService.SNAPSHOT_FIELD_NAME, complianceSnapshot); return parsedMetadata; } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index ad7575fa..a971aa7b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -106,7 +106,7 @@ class PromotionServiceTest { private String selectedVersionMetadata(boolean hasDisplayName) { return """ {"name":"old-package-name","description":"Old version summary", - "frontmatter":{"metadata":{"display-name":"%s"}}} + "frontmatter":{"metadata":{"display-name":"包内标题"}},"displayNameOverride":"%s"} """.formatted(hasDisplayName ? " 旧版标题 " : " "); } @@ -699,7 +699,7 @@ class PromotionServiceTest { ArgumentCaptor skillCaptor = ArgumentCaptor.forClass(Skill.class); verify(skillRepository, times(2)).save(skillCaptor.capture()); Skill newSkill = skillCaptor.getAllValues().get(0); - assertEquals(hasDisplayName ? "旧版标题" : "old-package-name", newSkill.getDisplayName()); + assertEquals(hasDisplayName ? "旧版标题" : "包内标题", newSkill.getDisplayName()); assertEquals("Old version summary", newSkill.getSummary()); assertEquals(NEW_VERSION_ID, newSkill.getLatestVersionId()); } @@ -842,7 +842,7 @@ class PromotionServiceTest { assertEquals(NEW_SKILL_ID, result.getTargetSkillId()); assertEquals(NEW_VERSION_ID, result.getTargetVersionId()); assertEquals(NEW_VERSION_ID, target.getLatestVersionId()); - assertEquals(hasDisplayName ? "旧版标题" : "old-package-name", target.getDisplayName()); + assertEquals(hasDisplayName ? "旧版标题" : "包内标题", target.getDisplayName()); assertEquals("Old version summary", target.getSummary()); verify(promotionRequestRepository).updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "approved", 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 10ec8fdb..afecab79 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 @@ -237,7 +237,8 @@ class ReviewServiceTest { skill.setUpdatedBy("previous-reviewer"); assertDoesNotThrow(() -> sv.setParsedMetadataJson(objectMapper.writeValueAsString( new SkillMetadata("Approved Name", "Approved Summary", "1.0.0", "Body", - hasDisplayName ? Map.of("metadata", Map.of("display-name", "已审核的中文标题")) : Map.of()) + hasDisplayName ? Map.of("metadata", Map.of("display-name", "包内标题")) : Map.of(), + hasDisplayName ? "已审核的中文标题" : null) ))); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); 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 56117482..5b03de32 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,19 @@ class SkillMetadataParserTest { private final SkillMetadataParser parser = new SkillMetadataParser(); + @Test + void platformOverrideIsVersionedWithoutChangingFrontmatter() throws Exception { + SkillMetadata source = parser.parse("---\nname: my-skill\ndescription: Test\nmetadata:\n display-name: 包内标题\n---\nBody"); + SkillMetadata result = source.withDisplayNameOverride(" 网页标题 "); + assertEquals("网页标题", result.displayName()); + assertEquals(source.frontmatter(), result.frontmatter()); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + assertEquals("网页标题", mapper.readValue(mapper.writeValueAsString(result), SkillMetadata.class).displayName()); + assertEquals("包内标题", source.withDisplayNameOverride(" ").displayName()); + assertThrows(DomainBadRequestException.class, () -> source.withDisplayNameOverride("中".repeat(201))); + assertEquals("my-skill", mapper.readValue("{\"name\":\"my-skill\",\"frontmatter\":{}}", SkillMetadata.class).displayName()); + } + @Test void parsesDisplayNameWithoutChangingPackageName() { SkillMetadata metadata = parser.parse(""" 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 7c7a6f03..4d5e24b6 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 @@ -255,7 +255,7 @@ class SkillGovernanceServiceTest { } @ParameterizedTest - @ValueSource(strings = {"missing", "custom", "number", "tooLong"}) + @ValueSource(strings = {"missing", "custom", "number", "tooLong", "override"}) void yankVersion_restoresLatestPublishedMetadata(String displayNameCase) throws Exception { SkillVersion yanked = new SkillVersion(2L, "2.0.0", "owner"); setField(yanked, "id", 22L); @@ -269,6 +269,7 @@ class SkillGovernanceServiceTest { fallback.setParsedMetadataJson(new ObjectMapper().writeValueAsString(Map.of( "name", "example-skill", "description", "Published summary from v1", + "displayNameOverride", displayNameCase.equals("override") ? "网页旧版标题" : "", "frontmatter", switch (displayNameCase) { case "custom" -> Map.of("metadata", Map.of("display-name", "示例技能一")); case "number" -> Map.of("metadata", Map.of("display-name", 123)); @@ -291,7 +292,7 @@ class SkillGovernanceServiceTest { service.yankVersion(22L, "admin", "127.0.0.1", "JUnit", "broken"); assertThat(skill.getLatestVersionId()).isEqualTo(11L); - assertThat(skill.getDisplayName()).isEqualTo(displayNameCase.equals("custom") ? "示例技能一" : "example-skill"); + assertThat(skill.getDisplayName()).isEqualTo(displayNameCase.equals("override") ? "网页旧版标题" : displayNameCase.equals("custom") ? "示例技能一" : "example-skill"); assertThat(skill.getSummary()).isEqualTo("Published summary from v1"); verify(skillRepository).save(skill); } 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 9029b678..85d4561d 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 @@ -1032,6 +1032,7 @@ class SkillPublishServiceTest { SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId); setId(sourceVersion, 21L); sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + sourceVersion.setParsedMetadataJson("{\"displayNameOverride\":\"原网页标题\"}"); sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z")); String sourceSkillMd = """ @@ -1084,6 +1085,7 @@ class SkillPublishServiceTest { ); assertEquals("1.2.4", result.version().getVersion()); + assertEquals("原网页标题", objectMapper.readTree(result.version().getParsedMetadataJson()).path("displayNameOverride").asText()); // Rerelease for PUBLIC skill should go to PENDING_REVIEW (respecting visibility rules) assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); // Review task should be created for PUBLIC skill @@ -1950,6 +1952,30 @@ class SkillPublishServiceTest { assertEquals("示例技能", snapshot.path("frontmatter").path("metadata").path("display-name").asText()); } + @Test + void portalOverrideLivesInVersionAndNeverRewritesStoredSkillMd() 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", "包内标题")))); + java.util.Map stored = new java.util.HashMap<>(); + doAnswer(invocation -> { + stored.put(invocation.getArgument(0), ((java.io.InputStream) invocation.getArgument(1)).readAllBytes()); + return null; + }).when(objectStorageService).putObject(anyString(), any(java.io.InputStream.class), anyLong(), anyString()); + + var result = service.publishFromEntries("test-ns", fixture.entries(), "user-100", + SkillVisibility.PUBLIC, Set.of("SUPER_ADMIN"), false, " 网页标题 "); + SkillMetadata snapshot = objectMapper.readerFor(SkillMetadata.class) + .without(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .readValue(result.version().getParsedMetadataJson()); + assertEquals("网页标题", snapshot.displayName()); + assertEquals("test-skill", result.slug()); + assertEquals("包内标题", ((Map) snapshot.frontmatter().get("metadata")).get("display-name")); + byte[] original = fixture.entries().getFirst().content(); + assertArrayEquals(original, stored.entrySet().stream().filter(entry -> entry.getKey().endsWith("/SKILL.md")) + .findFirst().orElseThrow().getValue()); + } + @Test void pendingUpdateKeepsPublishedDisplayNameAndSummary() throws Exception { PublishFixture fixture = stubValidPublishInputs("test-ns", "user-100", "test-skill", "test-skill", "2.0.0", true); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a52c6278..ac3b807d 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1602,6 +1602,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/skills/package-preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["previewPackage"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/skills/package-preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["previewPackage_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/skills/avatar-uploads": { parameters: { query?: never; @@ -6761,6 +6793,21 @@ export interface components { /** Format: int64 */ totalSize?: number; }; + ApiResponseSkillPackagePreviewResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillPackagePreviewResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillPackagePreviewResponse: { + name?: string; + displayName?: string; + description?: string; + version?: string; + }; ApiResponseSkillAvatarUploadResponse: { /** Format: int32 */ code?: number; @@ -7647,6 +7694,8 @@ export interface components { id?: number; version?: string; status?: string; + displayName?: string; + summary?: string; }; SkillSummaryResponse: { /** Format: int64 */ @@ -12585,6 +12634,7 @@ export interface operations { avatarAction?: "KEEP" | "REPLACE" | "REMOVE"; avatarUploadId?: string; expectedSlug?: string; + displayName?: string; }; header?: never; path: { @@ -12620,6 +12670,7 @@ export interface operations { avatarAction?: "KEEP" | "REPLACE" | "REMOVE"; avatarUploadId?: string; expectedSlug?: string; + displayName?: string; }; header?: never; path: { @@ -12647,6 +12698,60 @@ export interface operations { }; }; }; + previewPackage: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillPackagePreviewResponse"]; + }; + }; + }; + }; + previewPackage_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillPackagePreviewResponse"]; + }; + }; + }; + }; upload: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d4ea200b..59644466 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -307,6 +307,8 @@ export interface AdminLabelInput { } export interface SkillLifecycleVersion { + displayName?: string + summary?: string id: number version: string status: string diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a8bb5ffa..5ba6be87 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -518,6 +518,8 @@ "securityDesc": "Change password and account security options" }, "mySkills": { + "versionDifference": "This update v{{version}} · Public v{{published}}", + "statusUpdatePendingReview": "Update awaiting review", "title": "My Skills", "subtitle": "Manage your published skills", "searchPlaceholder": "Search by name, slug, or description", @@ -1042,6 +1044,9 @@ "emptyDescription": "No skills have been published in this namespace yet" }, "skillDetail": { + "copyCoordinate": "Copy full skill identifier", + "unpublishedPreviewTitle": "Unpublished version preview", + "unpublishedPreviewDescription": "You are viewing an unpublished version under the existing preview permissions. Public readers continue to see the published name, summary and content.", "back": "Back", "notFound": "Skill not found", "notFoundDesc": "This skill may have been deleted or never existed", @@ -1826,6 +1831,11 @@ "copyright": "© 2026 SkillHub. Apache-2.0." }, "publish": { + "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.", + "readingPackage": "Reading package metadata…", + "packagePreviewFailed": "Unable to read the package. Check SKILL.md and select the package again, or retry.", "title": "Publish Skill", "subtitle": "Upload skill package to SkillHub", "reviewNotice": { diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 7a5063ef..dff63a08 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -518,6 +518,8 @@ "securityDesc": "Изменение пароля и параметров безопасности" }, "mySkills": { + "versionDifference": "Обновление v{{version}} · Публичная v{{published}}", + "statusUpdatePendingReview": "Обновление на проверке", "title": "Мои скиллы", "subtitle": "Управление опубликованными скиллами", "searchPlaceholder": "Поиск по имени, slug или описанию", @@ -1107,6 +1109,9 @@ "emptyDescription": "В этом пространстве имён ещё не опубликовано ни одного скилла" }, "skillDetail": { + "copyCoordinate": "Копировать полный идентификатор", + "unpublishedPreviewTitle": "Предпросмотр неопубликованной версии", + "unpublishedPreviewDescription": "Вы просматриваете неопубликованную версию в рамках существующих прав. Публичным пользователям доступны имя, описание и содержимое опубликованной версии.", "back": "Назад", "notFound": "Скилл не найден", "notFoundDesc": "Этот скилл мог быть удалён или никогда не существовал", @@ -1817,6 +1822,11 @@ "goToPage": "Перейти на страницу {{page}}" }, "publish": { + "displayName": "Отображаемое имя", + "displayNamePlaceholder": "Например: Помощник для протоколов встреч", + "displayNameHelp": "До 200 символов. По умолчанию — имя из пакета. Изменения проходят проверку версии и не меняют идентификатор или файлы пакета. Пустое поле означает имя из пакета.", + "readingPackage": "Чтение метаданных пакета…", + "packagePreviewFailed": "Не удалось прочитать пакет. Проверьте SKILL.md и выберите пакет снова или повторите попытку.", "title": "Опубликовать скилл", "subtitle": "Загрузить пакет скилла в SkillHub", "reviewNotice": { diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 1bbc03fd..d165fe9e 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -518,6 +518,8 @@ "securityDesc": "修改密码和账户安全选项" }, "mySkills": { + "versionDifference": "本次 v{{version}} · 当前公开 v{{published}}", + "statusUpdatePendingReview": "更新待审核", "title": "我的技能", "subtitle": "管理你发布的技能", "searchPlaceholder": "搜索技能名称、Slug 或描述", @@ -1042,6 +1044,9 @@ "emptyDescription": "该命名空间下还没有发布任何技能" }, "skillDetail": { + "copyCoordinate": "复制完整技能标识", + "unpublishedPreviewTitle": "未公开版本预览", + "unpublishedPreviewDescription": "你正在查看尚未发布的版本。名称、摘要和内容仅在现有预览权限范围内可见,公众仍看到已发布版本。", "back": "返回上一页", "notFound": "技能不存在", "notFoundDesc": "该技能可能已被删除或从未存在", @@ -1825,6 +1830,11 @@ "copyright": "© 2026 SkillHub. Apache-2.0." }, "publish": { + "displayName": "显示名称", + "displayNamePlaceholder": "例如:会议纪要助手", + "displayNameHelp": "支持中文,最多 200 个字符。默认读取包内名称;修改后随本次版本审核,不改变技能标识或包内文件。留空则使用包内名称。", + "readingPackage": "正在读取技能包信息…", + "packagePreviewFailed": "无法读取技能包,请检查 SKILL.md 后重新选择,或重试。", "title": "发布技能", "subtitle": "上传技能包到 SkillHub", "reviewNotice": { diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index f9ed0378..665ee189 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -88,8 +88,8 @@ vi.mock('@/features/admin/use-admin-skills', () => ({ vi.mock('@/shared/lib/skill-lifecycle', () => ({ getHeadlineVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }), getPublishedVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }), - getOwnerPreviewVersion: () => null, - hasPendingOwnerPreview: () => false, + getOwnerPreviewVersion: (skill: { ownerPreviewVersion?: unknown }) => skill.ownerPreviewVersion ?? null, + hasPendingOwnerPreview: (skill: { ownerPreviewVersion?: { status: string } }) => skill.ownerPreviewVersion?.status === 'PENDING_REVIEW', })) vi.mock('@/shared/lib/number-format', () => ({ @@ -294,6 +294,21 @@ describe('MySkillsPage', () => { expect(buttonRecords.some((button) => button.label === 'mySkills.restoreHidden')).toBe(false) }) + it('shows pending metadata with exactly one status instead of the published title', () => { + useMySkillsMock.mockReturnValue({ data: { items: [{ + id: 1, namespace: 'team-ai', slug: 'stable-slug', displayName: '旧名称', summary: '旧摘要', + status: 'ACTIVE', downloadCount: 0, + ownerPreviewVersion: { id: 12, version: '2.0.0', status: 'PENDING_REVIEW', displayName: '待审新名称', summary: '待审新摘要' }, + }], total: 1, page: 0, size: 10 }, isLoading: false }) + const html = renderToStaticMarkup(createElement(MySkillsPage)) + expect(html).toContain('待审新名称') + expect(html).toContain('待审新摘要') + expect(html).not.toContain('旧名称') + expect(html.match(/class="status-pill[^"]*"/g)).toHaveLength(1) + expect(html).toContain('mySkills.statusUpdatePendingReview') + expect(html).not.toContain('mySkills.statusPublished') + }) + it('exports a named component function', () => { expect(typeof MySkillsPage).toBe('function') }) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 9daa6d9f..9525ec5b 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -99,10 +99,10 @@ export function MySkillsPage() { const submitPromotionMutation = useSubmitPromotion() const restoreHiddenMutation = useRestoreHiddenSkill() - const handleSkillClick = (namespace: string, slug: string) => { + const handleSkillClick = (namespace: string, slug: string, version?: string) => { navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}`, - search: { returnTo: buildReturnTo(location) }, + search: { returnTo: buildReturnTo(location), version }, }) } @@ -374,28 +374,37 @@ export function MySkillsPage() { const publishedVersion = getPublishedVersion(skill) const ownerPreviewVersion = getOwnerPreviewVersion(skill) const hasPendingPreview = hasPendingOwnerPreview(skill) + const preview = ownerPreviewVersion && ownerPreviewVersion.id !== publishedVersion?.id && ownerPreviewVersion.status !== 'YANKED' + ? ownerPreviewVersion : null + const displayName = preview?.displayName ?? skill.displayName + const summary = preview?.summary ?? skill.summary + const mainStatus = filter === 'HIDDEN' ? 'HIDDEN' : skill.status === 'ARCHIVED' || skill.status === 'HIDDEN' + ? skill.status : preview?.status ?? headlineVersion?.status ?? skill.status + return ( handleSkillClick(skill.namespace, skill.slug)} + onClick={filter === 'HIDDEN' ? undefined : () => handleSkillClick(skill.namespace, skill.slug, preview?.version)} >
- +

- {skill.displayName} + {displayName}

{skill.status === 'ACTIVE' && filter !== 'HIDDEN' && } - {skill.summary && ( -

{skill.summary}

+ {summary && ( +

{summary}

)}
@{skill.namespace} - {headlineVersion ? ( + {preview && publishedVersion ? ( + {t('mySkills.versionDifference', { version: preview.version, published: publishedVersion.version })} + ) : headlineVersion ? ( v{headlineVersion.version} ) : null} @@ -404,26 +413,12 @@ export function MySkillsPage() { {formatCompactCount(skill.downloadCount)} - {skill.status || filter === 'HIDDEN' ? ( - - {resolveStatusLabel(filter === 'HIDDEN' ? 'HIDDEN' : skill.status)} + {mainStatus && ( + + {hasPendingPreview && publishedVersion && mainStatus === 'PENDING_REVIEW' + ? t('mySkills.statusUpdatePendingReview') : resolveStatusLabel(mainStatus)} - ) : null} - {headlineVersion?.status ? ( - - {resolveStatusLabel(headlineVersion.status)} - - ) : null} - {hasPendingPreview && ownerPreviewVersion?.version !== headlineVersion?.version ? ( - - {resolveStatusLabel(ownerPreviewVersion?.status)} - - ) : null} - {!hasPendingPreview && ownerPreviewVersion?.status === 'REJECTED' && ownerPreviewVersion?.version !== headlineVersion?.version ? ( - - {resolveStatusLabel('REJECTED')} - - ) : null} + )}
@@ -444,7 +439,7 @@ export function MySkillsPage() { variant="outline" onClick={(event) => { event.stopPropagation() - handleUpdateSkill(skill.namespace, skill.slug, skill.displayName, skill.visibility ?? 'PUBLIC') + handleUpdateSkill(skill.namespace, skill.slug, displayName, skill.visibility ?? 'PUBLIC') }} > {t('mySkills.update')} diff --git a/web/src/pages/dashboard/publish-display-name.test.tsx b/web/src/pages/dashboard/publish-display-name.test.tsx new file mode 100644 index 00000000..6b450ead --- /dev/null +++ b/web/src/pages/dashboard/publish-display-name.test.tsx @@ -0,0 +1,73 @@ +/** @vitest-environment jsdom */ +import { fireEvent, render, screen, waitFor, cleanup } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const preview = vi.hoisted(() => vi.fn()) +const publish = vi.hoisted(() => vi.fn()) +vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), useSearch: () => ({ namespace: 'team-ai' }) })) +vi.mock('react-i18next', async () => ({ ...await vi.importActual('react-i18next'), useTranslation: () => ({ t: (key: string) => key }) })) +vi.mock('@/shared/hooks/use-skill-queries', () => ({ previewSkillPackage: preview, usePublishSkill: () => ({ mutateAsync: publish, isPending: false }) })) +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ useMyNamespaces: () => ({ data: [{ id: 1, slug: 'team-ai', displayName: 'Team' }], isLoading: false }) })) +vi.mock('@/features/publish/upload-zone', () => ({ UploadZone: ({ onFileSelect }: { onFileSelect: (file: File) => void }) => { if (e.target.files?.[0]) onFileSelect(e.target.files[0]) }} /> })) +vi.mock('@/features/skill/skill-avatar-editor', () => ({ KEEP_AVATAR: { action: 'KEEP', ready: true }, SkillAvatarEditor: () => null })) +vi.mock('@/features/skill/skill-avatar-manager', () => ({ SkillAvatarManager: () => null })) +vi.mock('@/shared/lib/navigate-after-overlays', () => ({ navigateAfterOverlays: (action: () => void) => action() })) +vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +import { PublishPage } from './publish' + +function mount() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + return client +} +function select(name: string) { + fireEvent.change(screen.getByLabelText('package'), { target: { files: [new File(['zip'], name)] } }) +} +const metadata = (name: string) => ({ name: 'stable-slug', displayName: name, version: '1.0.0', description: 'summary' }) + +afterEach(cleanup) +beforeEach(() => { preview.mockReset(); publish.mockReset(); publish.mockResolvedValue({ namespace: 'team-ai', slug: 'stable-slug', version: '1.0.0', status: 'PENDING_REVIEW' }) }) + +describe('web display name publishing', () => { + it('prefills from the package and sends the explicit edited name with the original file', async () => { + preview.mockResolvedValue(metadata('包内名称')) + mount(); select('one.zip') + await waitFor(() => expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('包内名称')) + fireEvent.change(screen.getByLabelText('publish.displayName'), { target: { value: '网页名称' } }) + fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' })) + await waitFor(() => expect(publish).toHaveBeenCalledWith(expect.objectContaining({ displayName: '网页名称', file: expect.objectContaining({ name: 'one.zip' }) }))) + }) + + it('keeps user input when a replacement package finishes parsing', async () => { + preview.mockResolvedValue(metadata('初始建议')) + mount(); select('one.zip') + await waitFor(() => expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('初始建议')) + fireEvent.change(screen.getByLabelText('publish.displayName'), { target: { value: '我填写的名称' } }) + preview.mockResolvedValue(metadata('另一个建议')); select('two.zip') + await waitFor(() => expect(preview).toHaveBeenCalledTimes(2)) + expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('我填写的名称') + }) + + it('ignores a stale package response and blocks submit until the current preview succeeds', async () => { + let finishOld!: (value: ReturnType) => void + preview.mockReturnValueOnce(new Promise(resolve => { finishOld = resolve })).mockResolvedValueOnce(metadata('当前包名称')) + mount(); select('old.zip') + expect(screen.getByRole('button', { name: 'publish.confirm' }).hasAttribute('disabled')).toBe(true) + await waitFor(() => expect(preview).toHaveBeenCalledTimes(1)) + select('new.zip') + await waitFor(() => expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('当前包名称')) + finishOld(metadata('过期包名称')) + await new Promise(resolve => setTimeout(resolve, 20)) + expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('当前包名称') + }) + + it('shows a retry and cannot publish a package with invalid metadata', async () => { + preview.mockRejectedValueOnce(new Error('invalid package')).mockResolvedValueOnce(metadata('重试后的名称')) + mount(); select('invalid.zip') + await screen.findByRole('alert') + expect(screen.getByRole('button', { name: 'publish.confirm' }).hasAttribute('disabled')).toBe(true) + fireEvent.click(screen.getByRole('button', { name: 'common.retry' })) + await waitFor(() => expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('重试后的名称')) + }) +}) diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index c02b5574..1c8f7d1b 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' @@ -21,11 +21,12 @@ import { SelectValue, normalizeSelectValue, } from '@/shared/ui/select' +import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { Card } from '@/shared/ui/card' import { SkillAvatarEditor, KEEP_AVATAR, type AvatarEdit } from '@/features/skill/skill-avatar-editor' import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager' -import { usePublishSkill } from '@/shared/hooks/use-skill-queries' +import { previewSkillPackage, usePublishSkill } from '@/shared/hooks/use-skill-queries' import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' @@ -46,6 +47,10 @@ export function PublishPage() { const submitting = useRef(false) const [isSavingAvatar, setIsSavingAvatar] = useState(false) const [avatarEdit, setAvatarEdit] = useState(KEEP_AVATAR) + const previewId = useId() + const [fileRevision, setFileRevision] = useState(0) + const [displayName, setDisplayName] = useState('') + const displayNameEdited = useRef(false) const [selectedFile, setSelectedFile] = useState(null) const [namespaceSlug, setNamespaceSlug] = useState(prefill.namespace) const [visibility, setVisibility] = useState(prefill.visibility) @@ -55,11 +60,23 @@ export function PublishPage() { const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const publishMutation = usePublishSkill() + const packagePreview = useQuery({ + queryKey: ['skill-package-preview', previewId, fileRevision], + queryFn: () => previewSkillPackage(selectedFile!), + enabled: selectedFile !== null, + retry: false, + gcTime: 0, + }) + useEffect(() => { + if (packagePreview.data && !displayNameEdited.current) { + setDisplayName(packagePreview.data.displayName ?? packagePreview.data.name ?? '') + } + }, [packagePreview.data]) const avatarTarget = useQuery({ queryKey: ['skill-avatar', namespaceSlug, updateSlug], queryFn: () => skillAvatarApi.status(namespaceSlug, updateSlug), enabled: !!namespaceSlug && !!updateSlug }) const avatarUnavailable = !!updateSlug && (avatarTarget.isLoading || avatarTarget.isError) const isBusy = publishMutation.isPending || isSavingAvatar const avatarOnly = isUpdate && !selectedFile - const canSubmit = !!namespaceSlug && avatarEdit.ready && !avatarUnavailable && !isBusy && (selectedFile !== null || (isUpdate && avatarEdit.action !== 'KEEP')) + const canSubmit = (!selectedFile || packagePreview.isSuccess) && displayName.trim().length <= 200 && !!namespaceSlug && avatarEdit.ready && !avatarUnavailable && !isBusy && (selectedFile !== null || (isUpdate && avatarEdit.action !== 'KEEP')) const selectedNamespace = namespaces?.find((ns) => ns.slug === namespaceSlug) const namespaceOnlyLabel = selectedNamespace?.type === 'GLOBAL' ? t('publish.visibilityOptions.loggedInUsersOnly') @@ -79,6 +96,7 @@ export function PublishPage() { const handleFileSelect = (file: File | null) => { setSelectedFile(file) + setFileRevision(value => value + 1) setPrecheckWarnings([]) setWarningDialogOpen(false) } @@ -125,6 +143,7 @@ export function PublishPage() { avatarAction: avatarEdit.action, avatarUploadId: avatarEdit.uploadId, expectedSlug: updateSlug || undefined, + displayName, }) setPrecheckWarnings([]) setWarningDialogOpen(false) @@ -291,6 +310,29 @@ export function PublishPage() { )}
+
+ + { + displayNameEdited.current = true + setDisplayName(event.target.value) + }} + /> +

{t('publish.displayNameHelp')}

+ {selectedFile && packagePreview.isPending &&

{t('publish.readingPackage')}

} + {selectedFile && packagePreview.isError &&
+

{t('publish.packagePreviewFailed')}

+ +
} + {selectedFile && packagePreview.data &&

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

} +
+ {updateSlug && } {avatarTarget.isError &&

{t('skillAvatar.failed')}

} diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 6047403c..aff71165 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 { CopyButton } from '@/shared/components/copy-button' import { InstallCommand } from '@/features/skill/install-command' import { ShareButton } from '@/features/skill/share-button' import { InstallForAgentButton } from '@/features/skill/install-for-agent-button' @@ -198,6 +199,12 @@ export function SkillDetailPage() { ? requestedVersion : 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' + const { data: files } = useSkillFiles(qns, qslug, selectedVersion, skillReady) const documentationPath = resolveDocumentationFilePath(files) const { data: readme, error: readmeError } = useSkillReadme(qns, qslug, selectedVersion, documentationPath, skillReady) @@ -901,7 +908,11 @@ export function SkillDetailPage() { )}
-

{skill.displayName}

+

{selectedDisplayName}

+
+ @{skill.namespace}/{skill.slug} + +
{skill.avatarUrl && user?.userId !== skill.ownerId && (namespace === 'global' ? isPromotionAdmin : myNamespaces?.some((item) => item.slug === namespace && (item.currentUserRole === 'OWNER' || item.currentUserRole === 'ADMIN'))) @@ -916,8 +927,8 @@ export function SkillDetailPage() { )} - {skill.summary && ( -

{skill.summary}

+ {selectedSummary && ( +

{selectedSummary}

)} {(skill.labels?.length ?? 0) > 0 && (
@@ -938,10 +949,10 @@ export function SkillDetailPage() { ))}
)} - {isPendingPreview && ( + {showingUnpublishedPreview && ( -
{t('skillDetail.pendingPreviewTitle')}
-

{t('skillDetail.pendingPreviewDescription')}

+
{t('skillDetail.unpublishedPreviewTitle')}
+

{t('skillDetail.unpublishedPreviewDescription')}

)} {hasRejectedOwnerPreview && ( @@ -1023,7 +1034,7 @@ export function SkillDetailPage() {
{t('skillDetail.summaryLabel')}
-

{skill.summary}

+

{selectedSummary}

) : null}
diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index cd4a0c8d..6183073e 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -44,7 +44,7 @@ async function getSkillDocumentation(namespace: string, slug: string, version: s return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/file?path=${encodeURIComponent(path)}`) } -async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean; avatarAction?: 'KEEP' | 'REPLACE' | 'REMOVE'; avatarUploadId?: string; expectedSlug?: string }): Promise { +async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean; avatarAction?: 'KEEP' | 'REPLACE' | 'REMOVE'; avatarUploadId?: string; expectedSlug?: string; displayName?: string }): Promise { const cleanNamespace = params.namespace.startsWith('@') ? params.namespace.slice(1) : params.namespace const formData = new FormData() formData.append('file', params.file) @@ -53,6 +53,7 @@ async function publishSkill(params: { namespace: string; file: File; visibility: formData.append('avatarAction', params.avatarAction ?? 'KEEP') if (params.avatarUploadId) formData.append('avatarUploadId', params.avatarUploadId) if (params.expectedSlug) formData.append('expectedSlug', params.expectedSlug) + if (params.displayName?.trim()) formData.append('displayName', params.displayName.trim()) return fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/publish`, { method: 'POST', @@ -62,6 +63,17 @@ async function publishSkill(params: { namespace: string; file: File; visibility: }) } +export async function previewSkillPackage(file: File): Promise { + const body = new FormData() + body.append('file', file) + return fetchJson(`${WEB_API_PREFIX}/skills/package-preview`, { + method: 'POST', + headers: getCsrfHeaders(), + body, + timeoutMs: PUBLISH_REQUEST_TIMEOUT_MS, + }) +} + export function useSearchSkills(params: SearchParams, enabled = true) { return useQuery({ queryKey: ['skills', 'search', params],