Merge pull request #957 from iflytek/fix/pr950-metadata-consistency-20261015

feat(publish): support version-consistent skill display names
This commit is contained in:
XiaoSeS 2026-10-15 18:14:30 +08:00 • committed by GitHub
commit dad54e45b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 1521 additions and 113 deletions

View file

@ -40,6 +40,10 @@ 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`

View file

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

View file

@ -72,6 +72,32 @@ 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.
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

View file

@ -68,9 +68,32 @@ 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`)。
网页发布或更新时,可在上传技能包后编辑“显示名称”。默认读取包内 `metadata.display-name`,缺省使用 `name`;你修改的值优先,换包不会覆盖已填写的名称,留空则采用包内名称。名称最多 200 个字符,随本次版本审核,不单独修改线上标题;只改头像时此字段不可用。
“我的技能”会显示待审新版的名称和摘要,仅保留一个主状态,并说明本次版本与当前公开版本。点击后可预览新版;公众继续看到已发布版本。详情中始终显示可复制的 `@命名空间/slug`,改名不改变链接或安装标识。
网页填写的名称保存在平台版本元数据中,包内 `SKILL.md` 不会被改写;下载 ZIP 沿用现有重新打包逻辑,压缩后的字节可能不同。CLI 发布继续使用包内名称规则。审核通过、撤下新版回退或提升所选版本时,展示名称和摘要跟随对应版本。
2. **使用 CLI 发布(推荐)**
```bash

View file

@ -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 后执行真实浏览器和生命周期验收。

View file

@ -0,0 +1,14 @@
## Why
PR #957 支持包内展示名称,但网页不能填写,更新待审时“我的技能”仍显示旧标题并重复展示发布和审核状态,详情缺少完整技能标识。
## What Changes
- 网页新增/更新技能时可填写展示名称;上传后从包内 metadata.display-name(缺省 name)预填,用户输入优先。
- 表单名称作为版本元数据快照保存,不修改上传 ZIP 或 SKILL.md。CLI 继续采用包内字段。
- 管理列表展示待审更新的名称和摘要,使用一个主状态;公开页面保持已发布信息。
- 详情展示完整 @namespace/slug 和复制入口,所有者预览明确标识未公开。
## Scope
沿用现有版本审核、撤回、提升和权限。无独立改名流程、无新数据库迁移,不改变 slug 或下载包内容。

View file

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

View file

@ -0,0 +1,14 @@
## Implementation
- [x] Add safe authenticated package preview and version-bound optional display name.
- [x] Preserve snapshot compatibility and lifecycle metadata across approval/yank/promotion.
- [x] Implement web prefill with user-edit and stale-response protection.
- [x] Show one management status with pending metadata and full copyable detail coordinate.
- [x] 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.

View file

@ -9,7 +9,9 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.NegatedRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
/**
@ -43,11 +45,14 @@ public class ClawHubRegistrySecurityConfig {
HttpSecurity http,
ApiTokenAuthenticationFilter apiTokenAuthenticationFilter) throws Exception {
http
.securityMatcher(
"/api/v1/search",
"/api/v1/download",
"/api/v1/skills/*"
)
.securityMatcher(new AndRequestMatcher(
new OrRequestMatcher(
new AntPathRequestMatcher("/api/v1/search"),
new AntPathRequestMatcher("/api/v1/download"),
new AntPathRequestMatcher("/api/v1/skills/*")),
new NegatedRequestMatcher(new AntPathRequestMatcher(
"/api/v1/skills/package-preview", HttpMethod.POST.name()))
))
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.requestCache(cache -> cache.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

View file

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

View file

@ -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,34 @@ 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<SkillPackagePreviewResponse> 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.extractWithWarnings(file).entries()));
} 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 +82,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 +110,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(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -41,6 +41,14 @@ public class SkillPublishAppService {
public SkillPublishService.PublishResult publish(String namespace, List<PackageEntry> entries, String actor,
SkillVisibility visibility, Set<String> 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<PackageEntry> entries, String actor,
SkillVisibility visibility, Set<String> 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) {

View file

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

View file

@ -335,3 +335,8 @@ 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
error.skill.displayName.tooLong=Skill display name must not exceed {0} characters

View file

@ -278,3 +278,8 @@ 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} символов
error.skill.displayName.tooLong=Отображаемое имя навыка не должно превышать {0} символов

View file

@ -337,3 +337,8 @@ 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} 个字符
error.skill.displayName.tooLong=技能显示名称不能超过 {0} 个字符

View file

@ -39,6 +39,17 @@ class ClawHubCompatControllerSecurityTest {
@MockBean
private ClawHubCompatAppService clawHubCompatAppService;
@Test
void getPackagePreviewSlugStillUsesCompatibilityEndpoint() throws Exception {
when(clawHubCompatAppService.getSkill(eq("package-preview"), isNull(), isNull()))
.thenThrow(new DomainNotFoundException("error.skill.notFound", "package-preview"));
mockMvc.perform(get("/api/v1/skills/package-preview"))
.andExpect(status().isNotFound());
verify(clawHubCompatAppService).getSkill(eq("package-preview"), isNull(), isNull());
}
@Test
void getSkill_returnsNotFound_whenAnonymousCannotAccessPrivateSkill() throws Exception {
when(clawHubCompatAppService.getSkill(eq("priv"), isNull(), isNull()))

View file

@ -34,6 +34,7 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
@ -66,6 +67,9 @@ class PromotionApprovalFlowIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private UserAccountJpaRepository userAccountRepository;
@ -138,6 +142,8 @@ class PromotionApprovalFlowIntegrationTest {
assertThat(targetSkill.getNamespaceId()).isEqualTo(graph.globalNamespace().getId());
assertThat(targetSkill.getSlug()).isEqualTo(graph.sourceSkill().getSlug());
assertThat(targetSkill.getSourceSkillId()).isEqualTo(graph.sourceSkill().getId());
assertThat(targetSkill.getDisplayName()).isEqualTo("Selected version title");
assertThat(targetSkill.getSummary()).isEqualTo("Selected version summary.");
assertThat(targetSkill.getLatestVersionId()).isNotNull();
List<SkillVersion> targetVersions = skillVersionRepository.findBySkillId(targetSkill.getId());
@ -320,9 +326,17 @@ class PromotionApprovalFlowIntegrationTest {
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
sourceVersion.setPublishedAt(Instant.now());
sourceVersion.setRequestedVisibility(SkillVisibility.PUBLIC);
sourceVersion.setParsedMetadataJson("{\"name\":\"promotion-flow\"}");
String metadataJson = """
{"name":"promotion-flow","description":"Selected version summary.",
"frontmatter":{"metadata":{"display-name":"Selected version title"}}}
""";
sourceVersion.setParsedMetadataJson(metadataJson);
sourceVersion.setManifestJson("{\"version\":\"1.0.0\"}");
sourceVersion = skillVersionRepository.saveAndFlush(sourceVersion);
// H2's JSON mapping can quote a Java String as a JSON scalar. Seed an actual
// JSON object, matching the PostgreSQL snapshots produced by publishing.
jdbcTemplate.update("UPDATE skill_version SET parsed_metadata_json = ? FORMAT JSON WHERE id = ?",
metadataJson, sourceVersion.getId());
sourceSkill.setLatestVersionId(sourceVersion.getId());
sourceSkill.setUpdatedBy(submitterId);

View file

@ -0,0 +1,130 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.SkillPackagePreviewResponse;
import com.iflytek.skillhub.service.SkillPackagePreviewService;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** Exercises real ordered filter chains, scope parsing, and stored browser sessions.
* Token lifecycle verification is stubbed; these tests focus on routing and access control.
*/
@SpringBootTest(properties = "skillhub.ratelimit.enabled=false")
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SkillPackagePreviewSecurityIntegrationTest {
@Autowired MockMvc mockMvc;
@MockBean ApiTokenService tokens;
@Autowired UserAccountRepository users;
@MockBean SkillPackagePreviewService previews;
private String userId;
@BeforeEach
void setUp() {
userId = "preview-security-" + UUID.randomUUID();
users.save(new UserAccount(userId, "Preview user", userId + "@example.test", ""));
}
@ParameterizedTest
@ValueSource(strings = {"/api/v1", "/api/web"})
void browserSessionWithCsrfCanPreview(String prefix) throws Exception {
given(previews.preview(anyList())).willReturn(new SkillPackagePreviewResponse("foo", "中文标题", "Summary", "1.0.0"));
mockMvc.perform(multipart(prefix + "/skills/package-preview").file(archive())
.session(session()).with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.displayName").value("中文标题"));
verify(previews).preview(anyList());
}
@ParameterizedTest
@ValueSource(strings = {"/api/v1", "/api/web"})
void publishScopedTokenCanPreviewWithoutSessionOrCsrf(String prefix) throws Exception {
given(previews.preview(anyList())).willReturn(new SkillPackagePreviewResponse("foo", "中文标题", "Summary", "1.0.0"));
mockMvc.perform(multipart(prefix + "/skills/package-preview").file(archive())
.header("Authorization", "Bearer " + token("skill:publish")))
.andExpect(status().isOk());
verify(previews).preview(anyList());
}
@ParameterizedTest
@ValueSource(strings = {"/api/v1", "/api/web"})
void missingPublishScopeIsRejectedBeforeParsing(String prefix) throws Exception {
mockMvc.perform(multipart(prefix + "/skills/package-preview").file(archive())
.header("Authorization", "Bearer " + token("skill:read")))
.andExpect(status().isForbidden());
verifyNoInteractions(previews);
}
@ParameterizedTest
@ValueSource(strings = {"/api/v1", "/api/web"})
void anonymousIsRejectedBeforeParsing(String prefix) throws Exception {
mockMvc.perform(multipart(prefix + "/skills/package-preview").file(archive()).with(csrf()))
.andExpect(status().isUnauthorized());
verifyNoInteractions(previews);
}
@ParameterizedTest
@ValueSource(strings = {"/api/v1", "/api/web"})
void browserSessionWithoutCsrfIsRejectedBeforeParsing(String prefix) throws Exception {
mockMvc.perform(multipart(prefix + "/skills/package-preview").file(archive()).session(session()))
.andExpect(status().is4xxClientError());
verifyNoInteractions(previews);
}
private MockHttpSession session() {
var context = SecurityContextHolder.createEmptyContext();
var principal = new PlatformPrincipal(userId, "Preview user", userId + "@example.test", "", "local", Set.of());
context.setAuthentication(new UsernamePasswordAuthenticationToken(principal, null, List.of()));
var session = new MockHttpSession();
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
return session;
}
private String token(String scope) {
String rawToken = "preview-test-" + UUID.randomUUID();
var token = new com.iflytek.skillhub.auth.entity.ApiToken(userId, "preview", "preview", "test-hash",
"[\"" + scope + "\"]");
given(tokens.validateToken(rawToken)).willReturn(java.util.Optional.of(token));
return rawToken;
}
private MockMultipartFile archive() throws Exception {
try (var output = new ByteArrayOutputStream(); var zip = new ZipOutputStream(output)) {
zip.putNextEntry(new ZipEntry("SKILL.md"));
zip.write("---\nname: foo\ndescription: Summary\n---\nBody".getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
zip.finish();
return new MockMultipartFile("file", "skill.zip", "application/zip", output.toByteArray());
}
}
}

View file

@ -220,6 +220,83 @@ 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.<List<PackageEntry>>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 packagePreviewNormalizesNestedSkillAlongsideRootReadmeLikePublish() throws Exception {
byte[] archive;
try (var output = new ByteArrayOutputStream(); var zip = new ZipOutputStream(output)) {
zip.putNextEntry(new ZipEntry("README.md"));
zip.write("Repository overview".getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
zip.putNextEntry(new ZipEntry("foo/SKILL.md"));
zip.write("---\nname: foo\ndescription: Nested skill\nversion: 1.0.0\nmetadata:\n display-name: 嵌套技能\n---\nBody".getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
zip.finish();
archive = output.toByteArray();
}
var principal = new PlatformPrincipal("preview_nested", "publisher", "publisher@example.com", "", "local", Set.of());
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
for (String base : List.of("/api/v1", "/api/web")) {
mockMvc.perform(multipart(base + "/skills/package-preview")
.file(new MockMultipartFile("file", "skill.zip", "application/zip", archive))
.with(authentication(auth)).with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.name").value("foo"))
.andExpect(jsonPath("$.data.displayName").value("嵌套技能"))
.andExpect(jsonPath("$.data.description").value("Nested skill"));
}
org.mockito.Mockito.verifyNoInteractions(skillPublishService);
}
@Test
void packagePreviewRejectsAnonymousWithoutPublishing() throws Exception {
mockMvc.perform(multipart("/api/v1/skills/package-preview")
.file(new MockMultipartFile("file", "skill.zip", "application/zip", buildZipBytes()))
.with(csrf()))
.andExpect(status().isUnauthorized());
org.mockito.Mockito.verifyNoInteractions(skillPublishService);
}
private byte[] buildZipBytes() throws Exception {
try (ByteArrayOutputStream output = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {

View file

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

View file

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

View file

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

View file

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

View file

@ -61,6 +61,21 @@ class SkillSuiteBundlePackageAnalyzerTest {
});
}
@Test
void rejectsInvalidDisplayNamesInNewBundlePackages() throws Exception {
for (String invalidValue : new String[] {"123", "中".repeat(201)}) {
String skill = "---\nname: first\ndescription: Test\nversion: 1.0.0\nmetadata:\n display-name: "
+ invalidValue + "\n---\nBody";
SkillSuiteBundlePackageAnalyzer.BundleAnalysis result = analyzer.analyze(List.of(
entry("SUITE.yaml", manifest(packageMember("first"), "@global/first")),
entry("skills/first/SKILL.md", skill)
));
assertThat(result.packageMembers()).singleElement().satisfies(member ->
assertThat(member.validation().passed()).isFalse());
}
}
@Test
void reportsMissingRootSkillMdAndDoesNotPromoteNestedOne() throws Exception {
SkillSuiteBundlePackageAnalyzer.BundleAnalysis result = analyzer.analyze(List.of(

View file

@ -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"),
@ -176,6 +178,8 @@ public class RouteSecurityPolicyRegistry {
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/whoami"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/search"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/skills"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/package-preview", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/package-preview", "skill:publish"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/skills/**"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills/**"),

View file

@ -18,6 +18,16 @@ class RouteSecurityPolicyRegistryTest {
private final RouteSecurityPolicyRegistry registry = new RouteSecurityPolicyRegistry();
@Test
void packagePreviewIsAuthenticatedAndRequiresPublishScope() {
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")).allowed());
assertTrue(registry.authorizeApiToken("POST", prefix + "/skills/package-preview", Set.of("skill:publish")).allowed());
}
}
@Test
void accessLevel_respectsMethodSpecificPublicRoutesAndProtectedFallback() {
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL,
@ -54,7 +64,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 +149,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 +217,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 +240,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 +412,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 +452,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);
}
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.review;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.event.PromotionApprovedEvent;
import com.iflytek.skillhub.domain.event.PromotionRejectedEvent;
@ -17,6 +18,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.*;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
import jakarta.persistence.EntityManager;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
@ -51,6 +53,7 @@ public class PromotionService {
private final GovernanceNotificationService governanceNotificationService;
private final EntityManager entityManager;
private final Clock clock;
private final ObjectMapper objectMapper;
public PromotionService(PromotionRequestRepository promotionRequestRepository,
SkillRepository skillRepository,
@ -62,7 +65,8 @@ public class PromotionService {
ApplicationEventPublisher eventPublisher,
GovernanceNotificationService governanceNotificationService,
EntityManager entityManager,
Clock clock) {
Clock clock,
ObjectMapper objectMapper) {
this.promotionRequestRepository = promotionRequestRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
@ -74,6 +78,7 @@ public class PromotionService {
this.governanceNotificationService = governanceNotificationService;
this.entityManager = entityManager;
this.clock = clock;
this.objectMapper = objectMapper;
}
/**
@ -217,8 +222,7 @@ public class PromotionService {
// Serialize approval with ordinary uploads that lock target Skill before publishing.
entityManager.lock(targetSkill, jakarta.persistence.LockModeType.PESSIMISTIC_WRITE);
assertTargetVersionAvailable(targetSkill.getId(), sourceVersion.getVersion());
targetSkill.setDisplayName(sourceSkill.getDisplayName());
targetSkill.setSummary(sourceSkill.getSummary());
applySourceVersionMetadata(targetSkill, sourceSkill, sourceVersion);
} else {
if (promotionRequestRepository.findActiveInitialBySourceSkillId(sourceSkill.getId()).isPresent()) {
throw new DomainBadRequestException("promotion.already_promoted", sourceSkill.getId());
@ -226,8 +230,7 @@ public class PromotionService {
assertTargetSkillNotExists(approvedRequest, sourceSkill);
targetSkill = new Skill(approvedRequest.getTargetNamespaceId(), sourceSkill.getSlug(),
sourceSkill.getOwnerId(), SkillVisibility.PUBLIC);
targetSkill.setDisplayName(sourceSkill.getDisplayName());
targetSkill.setSummary(sourceSkill.getSummary());
applySourceVersionMetadata(targetSkill, sourceSkill, sourceVersion);
targetSkill.setSourceSkillId(sourceSkill.getId());
targetSkill.setCreatedBy(reviewerId);
targetSkill.setUpdatedBy(reviewerId);
@ -293,6 +296,22 @@ public class PromotionService {
return savedRequest;
}
private void applySourceVersionMetadata(Skill target, Skill source, SkillVersion sourceVersion) {
String metadataJson = sourceVersion.getParsedMetadataJson();
if (metadataJson == null || metadataJson.isBlank()) {
target.setDisplayName(source.getDisplayName());
target.setSummary(source.getSummary());
return;
}
try {
SkillMetadata metadata = objectMapper.readValue(metadataJson, SkillMetadata.class);
target.setDisplayName(metadata.displayName());
target.setSummary(metadata.description());
} catch (Exception e) {
throw new IllegalStateException("Failed to deserialize skill metadata", e);
}
}
private void assertTargetSkillNotExists(PromotionRequest approvedRequest, Skill sourceSkill) {
for (Skill existing : skillRepository.findByNamespaceIdAndSlug(
approvedRequest.getTargetNamespaceId(), sourceSkill.getSlug())) {

View file

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

View file

@ -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,5 +8,51 @@ public record SkillMetadata(
String description,
String version,
String body,
Map<String, Object> frontmatter
) {}
Map<String, Object> frontmatter,
String displayNameOverride
) {
public SkillMetadata(String name, String description, String version, String body, Map<String, Object> 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);
}
private static String resolveDisplayName(String name, Map<String, Object> 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)) {
return name;
}
String displayName = text.strip();
if (displayName.isEmpty()) {
return name;
}
if (displayName.length() > 200) {
return name;
}
return displayName;
}
}

View file

@ -59,9 +59,26 @@ public class SkillMetadataParser {
version = extractNestedOptionalField(frontmatter, "metadata", "version");
}
validateDisplayName(frontmatter);
return new SkillMetadata(name, description, version, body, frontmatter);
}
private void validateDisplayName(Map<String, Object> frontmatter) {
if (!(frontmatter.get("metadata") instanceof Map<?, ?> metadata)) {
return;
}
Object value = metadata.get("display-name");
if (value == null) {
return;
}
if (!(value instanceof String text)) {
throw new DomainBadRequestException("error.skill.metadata.displayName.invalid");
}
if (text.strip().length() > 200) {
throw new DomainBadRequestException("error.skill.metadata.displayName.tooLong", 200);
}
}
private Map<String, Object> parseFrontmatter(String yamlContent) {
try {
LoaderOptions loaderOptions = new LoaderOptions();

View file

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

View file

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

View file

@ -317,6 +317,15 @@ public class SkillPublishService {
return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, confirmWarnings, false, false);
}
@Transactional
public PublishResult publishFromEntries(
String namespaceSlug, List<PackageEntry> entries, String publisherId,
SkillVisibility visibility, Set<String> 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<PackageEntry> 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<PackageEntry> entries, String publisherId,
SkillVisibility visibility, Set<String> 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
@ -708,8 +740,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());
@ -977,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;
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.review;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
import com.iflytek.skillhub.domain.namespace.Namespace;
@ -16,6 +17,8 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
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.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ -64,7 +67,7 @@ class PromotionServiceTest {
promotionService = new PromotionService(
promotionRequestRepository, skillRepository, skillVersionRepository,
skillFileRepository, namespaceRepository, namespaceMemberRepository, permissionChecker,
eventPublisher, governanceNotificationService, entityManager, CLOCK);
eventPublisher, governanceNotificationService, entityManager, CLOCK, new ObjectMapper());
lenient().when(namespaceRepository.findById(5L)).thenReturn(Optional.of(createSourceNamespace()));
}
@ -100,6 +103,13 @@ class PromotionServiceTest {
return sv;
}
private String selectedVersionMetadata(boolean hasDisplayName) {
return """
{"name":"old-package-name","description":"Old version summary",
"frontmatter":{"metadata":{"display-name":"包内标题"}},"displayNameOverride":"%s"}
""".formatted(hasDisplayName ? " 旧版标题 " : " ");
}
private Namespace createGlobalNamespace() {
Namespace ns = new Namespace("global", "Global", "user-1");
setField(ns, "id", TARGET_NAMESPACE_ID);
@ -655,12 +665,15 @@ class PromotionServiceTest {
assertEquals("promotion.target_skill_conflict", ex.messageCode());
}
@Test
void shouldCopyDisplayNameAndSummaryToNewSkill() {
@ParameterizedTest
@ValueSource(booleans = {true, false})
void shouldCopySelectedVersionMetadataToNewSkill(boolean hasDisplayName) {
PromotionRequest pr = createPendingPromotion();
PromotionRequest approvedRequest = approvedPromotion(pr, "ok");
Skill sourceSkill = createSourceSkill();
SkillVersion sourceVersion = createPublishedVersion();
sourceSkill.setLatestVersionId(999L);
sourceVersion.setParsedMetadataJson(selectedVersionMetadata(hasDisplayName));
when(promotionRequestRepository.findById(PROMOTION_ID))
.thenReturn(Optional.of(pr), Optional.of(approvedRequest));
@ -686,8 +699,9 @@ class PromotionServiceTest {
ArgumentCaptor<Skill> skillCaptor = ArgumentCaptor.forClass(Skill.class);
verify(skillRepository, times(2)).save(skillCaptor.capture());
Skill newSkill = skillCaptor.getAllValues().get(0);
assertEquals("My Skill", newSkill.getDisplayName());
assertEquals("A test skill", newSkill.getSummary());
assertEquals(hasDisplayName ? "旧版标题" : "包内标题", newSkill.getDisplayName());
assertEquals("Old version summary", newSkill.getSummary());
assertEquals(NEW_VERSION_ID, newSkill.getLatestVersionId());
}
}
@ -784,8 +798,9 @@ class PromotionServiceTest {
assertEquals("2.0.0", state.targetCurrentVersion());
}
@Test
void approvesUpdateWithoutCreatingAnotherGlobalSkill() {
@ParameterizedTest
@ValueSource(booleans = {true, false})
void approvesUpdateUsingSelectedVersionMetadata(boolean hasDisplayName) {
PromotionRequest pending = createPendingPromotion();
pending.setRequestKind(PromotionRequestKind.UPDATE);
pending.setTargetSkillId(NEW_SKILL_ID);
@ -808,8 +823,12 @@ class PromotionServiceTest {
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "approved",
NEW_SKILL_ID, pending.getVersion()))
.thenReturn(1);
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill()));
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion()));
Skill source = createSourceSkill();
source.setLatestVersionId(998L);
SkillVersion sourceVersion = createPublishedVersion();
sourceVersion.setParsedMetadataJson(selectedVersionMetadata(hasDisplayName));
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(source));
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
when(promotionRequestRepository.findActiveInitialBySourceSkillId(SOURCE_SKILL_ID))
.thenReturn(Optional.of(initialLink()));
when(skillRepository.findById(NEW_SKILL_ID)).thenReturn(Optional.of(target));
@ -823,8 +842,8 @@ class PromotionServiceTest {
assertEquals(NEW_SKILL_ID, result.getTargetSkillId());
assertEquals(NEW_VERSION_ID, result.getTargetVersionId());
assertEquals(NEW_VERSION_ID, target.getLatestVersionId());
assertEquals("My Skill", target.getDisplayName());
assertEquals("A test skill", target.getSummary());
assertEquals(hasDisplayName ? "旧版标题" : "包内标题", target.getDisplayName());
assertEquals("Old version summary", target.getSummary());
verify(promotionRequestRepository).updateStatusWithVersion(
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "approved",
NEW_SKILL_ID, pending.getVersion());

View file

@ -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,9 @@ 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(),
hasDisplayName ? "已审核的中文标题" : null)
)));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
@ -262,7 +265,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));

View file

@ -11,6 +11,88 @@ 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("""
---
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 readsLegacyDisplayNameSnapshotsWithoutDiscardingRawValues() throws Exception {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
for (Object legacyValue : new Object[] {123, true, java.util.List.of("one", "two"),
Map.of("label", "test"), "中".repeat(201)}) {
String snapshot = mapper.writeValueAsString(Map.of(
"name", "old-skill", "description", "Historical summary",
"frontmatter", Map.of("metadata", Map.of("display-name", legacyValue))));
SkillMetadata metadata = mapper.readValue(snapshot, SkillMetadata.class);
assertEquals("old-skill", metadata.displayName());
assertEquals(legacyValue,
((Map<?, ?>) metadata.frontmatter().get("metadata")).get("display-name"));
assertEquals(mapper.readTree(snapshot).get("frontmatter"),
mapper.readTree(mapper.writeValueAsString(metadata)).get("frontmatter"));
}
}
@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 = """

View file

@ -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(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);
yanked.setStatus(SkillVersionStatus.PUBLISHED);
@ -261,10 +266,22 @@ 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",
"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));
case "tooLong" -> Map.of("metadata", Map.of("display-name", "中".repeat(201)));
default -> 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 +292,57 @@ class SkillGovernanceServiceTest {
service.yankVersion(22L, "admin", "127.0.0.1", "JUnit", "broken");
assertThat(skill.getLatestVersionId()).isEqualTo(11L);
assertThat(skill.getDisplayName()).isEqualTo(displayNameCase.equals("override") ? "网页旧版标题" : displayNameCase.equals("custom") ? "示例技能一" : "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);

View file

@ -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
@ -1931,6 +1933,69 @@ 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<Skill> 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 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<String, byte[]> 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);
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<PackageEntry> entries) {
}

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.search.event;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent;
import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.search.SearchIndexService;
import com.iflytek.skillhub.search.SearchRebuildService;
@ -32,6 +33,12 @@ public class SearchIndexEventListener {
searchRebuildService.rebuildBySkill(event.skillId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async("skillhubEventExecutor")
public void onSkillVersionYanked(SkillVersionYankedEvent event) {
searchRebuildService.rebuildBySkill(event.skillId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async("skillhubEventExecutor")
public void onSkillStatusChanged(SkillStatusChangedEvent event) {

View file

@ -1,14 +1,21 @@
package com.iflytek.skillhub.search.event;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent;
import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.search.SearchIndexService;
import com.iflytek.skillhub.search.SearchRebuildService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.transaction.event.TransactionalEventListenerFactory;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionSynchronizationUtils;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
class SearchIndexEventListenerTest {
@ -23,6 +30,41 @@ class SearchIndexEventListenerTest {
verify(searchRebuildService).rebuildBySkill(42L);
}
@Test
void yankedVersionShouldRebuildOnlyAfterCommit() {
assertYankTransactionOutcome(TransactionSynchronization.STATUS_COMMITTED);
}
@Test
void rolledBackYankShouldNotRebuildSearchDocument() {
assertYankTransactionOutcome(TransactionSynchronization.STATUS_ROLLED_BACK);
}
private void assertYankTransactionOutcome(int completionStatus) {
SearchRebuildService rebuildService = mock(SearchRebuildService.class);
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.registerBean(TransactionalEventListenerFactory.class);
context.registerBean(SearchIndexEventListener.class,
() -> new SearchIndexEventListener(rebuildService, mock(SearchIndexService.class)));
context.refresh();
TransactionSynchronizationManager.setActualTransactionActive(true);
TransactionSynchronizationManager.initSynchronization();
try {
context.publishEvent(new SkillVersionYankedEvent(42L, 100L, "owner-1", true));
verifyNoInteractions(rebuildService);
TransactionSynchronizationUtils.triggerAfterCompletion(completionStatus);
if (completionStatus == TransactionSynchronization.STATUS_COMMITTED) {
verify(rebuildService).rebuildBySkill(42L);
} else {
verifyNoInteractions(rebuildService);
}
} finally {
TransactionSynchronizationManager.clearSynchronization();
TransactionSynchronizationManager.setActualTransactionActive(false);
}
}
}
@Test
void archivedStatusShouldRemoveSearchDocument() {
SearchRebuildService searchRebuildService = mock(SearchRebuildService.class);

View file

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

View file

@ -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,48 @@ 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).toHaveURL(new RegExp(`/space/${skill.namespace}/${skill.slug}\\?returnTo=%2Fdashboard%2Fskills$`))
await expect(page.getByRole('heading', { name: displayName, exact: true })).toBeVisible()
await page.getByRole('heading', { name: displayName, exact: true }).click()
await expect(page).toHaveURL(url => url.pathname === `/space/${skill.namespace}/${skill.slug}`
&& url.searchParams.get('returnTo') === '/dashboard/skills'
&& url.searchParams.get('version') === skill.version)
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()
}

View file

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

View file

@ -307,6 +307,8 @@ export interface AdminLabelInput {
}
export interface SkillLifecycleVersion {
displayName?: string
summary?: string
id: number
version: string
status: string

View file

@ -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,13 @@
"copyright": "© 2026 SkillHub. Apache-2.0."
},
"publish": {
"retryPackagePreview": "Retry",
"packageName": "Package name",
"displayName": "Display name",
"displayNamePlaceholder": "For example: Meeting notes assistant",
"displayNameHelp": "Up to 200 characters. Defaults to the package title. Changes follow this version’s review and do not alter the identifier or package files. Leave blank to use the package title.",
"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": {

View file

@ -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,13 @@
"goToPage": "Перейти на страницу {{page}}"
},
"publish": {
"retryPackagePreview": "Повторить",
"packageName": "Имя в пакете",
"displayName": "Отображаемое имя",
"displayNamePlaceholder": "Например: Помощник для протоколов встреч",
"displayNameHelp": "До 200 символов. По умолчанию — имя из пакета. Изменения проходят проверку версии и не меняют идентификатор или файлы пакета. Пустое поле означает имя из пакета.",
"readingPackage": "Чтение метаданных пакета…",
"packagePreviewFailed": "Не удалось прочитать пакет. Проверьте SKILL.md и выберите пакет снова или повторите попытку.",
"title": "Опубликовать скилл",
"subtitle": "Загрузить пакет скилла в SkillHub",
"reviewNotice": {

View file

@ -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,13 @@
"copyright": "© 2026 SkillHub. Apache-2.0."
},
"publish": {
"retryPackagePreview": "重试",
"packageName": "包内名称",
"displayName": "显示名称",
"displayNamePlaceholder": "例如:会议纪要助手",
"displayNameHelp": "支持中文,最多 200 个字符。默认读取包内名称;修改后随本次版本审核,不改变技能标识或包内文件。留空则使用包内名称。",
"readingPackage": "正在读取技能包信息…",
"packagePreviewFailed": "无法读取技能包,请检查 SKILL.md 后重新选择,或重试。",
"title": "发布技能",
"subtitle": "上传技能包到 SkillHub",
"reviewNotice": {

View file

@ -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', () => ({
@ -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[^>]*>示例技能<\/h3>/)
})
it('navigates to publish page with namespace and visibility when update is clicked', () => {
renderToStaticMarkup(createElement(MySkillsPage))
@ -271,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')
})

View file

@ -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 (
<Card
key={skill.id}
className={`p-5 group animate-fade-up delay-${Math.min(idx + 1, 6)} ${filter === 'HIDDEN' ? '' : 'cursor-pointer'}`}
onClick={filter === 'HIDDEN' ? undefined : () => handleSkillClick(skill.namespace, skill.slug)}
onClick={filter === 'HIDDEN' ? undefined : () => handleSkillClick(skill.namespace, skill.slug, preview?.version)}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<div className="mb-3 flex items-center gap-3">
<SkillAvatar name={skill.displayName} url={skill.avatarUrl} />
<SkillAvatar name={displayName} url={skill.avatarUrl} />
<h3 className="min-w-0 break-words font-heading text-lg font-semibold group-hover:text-primary transition-colors">
{skill.displayName}
{displayName}
</h3>
</div>
{skill.status === 'ACTIVE' && filter !== 'HIDDEN' && <SkillAvatarManager namespace={skill.namespace} slug={skill.slug} previewOnly />}
{skill.summary && (
<p className="text-sm text-muted-foreground mb-3 leading-relaxed">{skill.summary}</p>
{summary && (
<p className="text-sm text-muted-foreground mb-3 leading-relaxed">{summary}</p>
)}
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="handle-tag">@{skill.namespace}</span>
{headlineVersion ? (
{preview && publishedVersion ? (
<span className="text-xs">{t('mySkills.versionDifference', { version: preview.version, published: publishedVersion.version })}</span>
) : headlineVersion ? (
<span className="font-mono text-xs">v{headlineVersion.version}</span>
) : null}
<span className="flex items-center gap-1">
@ -404,26 +413,12 @@ export function MySkillsPage() {
</svg>
{formatCompactCount(skill.downloadCount)}
</span>
{skill.status || filter === 'HIDDEN' ? (
<span className={resolveStatusClassName(filter === 'HIDDEN' ? 'HIDDEN' : skill.status)}>
{resolveStatusLabel(filter === 'HIDDEN' ? 'HIDDEN' : skill.status)}
{mainStatus && (
<span className={resolveStatusClassName(mainStatus)}>
{hasPendingPreview && publishedVersion && mainStatus === 'PENDING_REVIEW'
? t('mySkills.statusUpdatePendingReview') : resolveStatusLabel(mainStatus)}
</span>
) : null}
{headlineVersion?.status ? (
<span className={resolveStatusClassName(headlineVersion.status)}>
{resolveStatusLabel(headlineVersion.status)}
</span>
) : null}
{hasPendingPreview && ownerPreviewVersion?.version !== headlineVersion?.version ? (
<span className={resolveStatusClassName(ownerPreviewVersion?.status)}>
{resolveStatusLabel(ownerPreviewVersion?.status)}
</span>
) : null}
{!hasPendingPreview && ownerPreviewVersion?.status === 'REJECTED' && ownerPreviewVersion?.version !== headlineVersion?.version ? (
<span className={resolveStatusClassName('REJECTED')}>
{resolveStatusLabel('REJECTED')}
</span>
) : null}
)}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 sm:max-w-[45%] sm:justify-end">
@ -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')}

View file

@ -2,7 +2,9 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ publish: vi.fn(), navigate: vi.fn(), submit: vi.fn(), remove: vi.fn(), refresh: vi.fn(), search: { namespace: 'team', visibility: 'PUBLIC', updateSkill: 'foo', resubmitSkill: '' } }))
vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: mocks.refresh }), useQuery: () => ({ data: { avatarUrl: '/owner-active' }, isLoading: false, isError: false }) }))
vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: mocks.refresh }), useQuery: ({ queryKey, enabled }: { queryKey: unknown[]; enabled?: boolean }) => queryKey[0] === 'skill-package-preview'
? { data: enabled ? { name: 'foo', displayName: 'Foo', version: '1.1' } : undefined, isSuccess: !!enabled, isPending: false, isError: false }
: { data: { avatarUrl: '/owner-active' }, isLoading: false, isError: false } }))
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => mocks.navigate, useSearch: () => mocks.search }))
vi.mock('react-i18next', async () => ({ ...await vi.importActual<typeof import('react-i18next')>('react-i18next'), useTranslation: () => ({ t: (key: string) => key }) }))
vi.mock('@/shared/hooks/use-skill-queries', () => ({

View file

@ -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<typeof import('react-i18next')>('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 }) => <input aria-label="package" type="file" onChange={e => { 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(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
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<typeof metadata>) => 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: 'publish.retryPackagePreview' }))
await waitFor(() => expect((screen.getByLabelText('publish.displayName') as HTMLInputElement).value).toBe('重试后的名称'))
})
})

View file

@ -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<AvatarEdit>(KEEP_AVATAR)
const previewId = useId()
const [fileRevision, setFileRevision] = useState(0)
const [displayName, setDisplayName] = useState('')
const displayNameEdited = useRef(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [namespaceSlug, setNamespaceSlug] = useState<string>(prefill.namespace)
const [visibility, setVisibility] = useState<string>(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() {
)}
</div>
<div className="space-y-2">
<Label htmlFor="skill-display-name">{t('publish.displayName')}</Label>
<Input
id="skill-display-name"
value={displayName}
maxLength={200}
disabled={isBusy || !selectedFile}
placeholder={isUpdate ? search.updateName : t('publish.displayNamePlaceholder')}
aria-describedby="skill-display-name-help"
onChange={event => {
displayNameEdited.current = true
setDisplayName(event.target.value)
}}
/>
<p id="skill-display-name-help" className="text-sm text-muted-foreground">{t('publish.displayNameHelp')}</p>
{selectedFile && packagePreview.isPending && <p role="status" className="text-sm text-muted-foreground">{t('publish.readingPackage')}</p>}
{selectedFile && packagePreview.isError && <div role="alert" className="text-sm text-destructive">
<p>{t('publish.packagePreviewFailed')}</p>
<Button type="button" size="sm" variant="outline" onClick={() => packagePreview.refetch()}>{t('publish.retryPackagePreview')}</Button>
</div>}
{selectedFile && packagePreview.data && <p className="break-all text-xs text-muted-foreground">{t('publish.packageName')}: <code>{packagePreview.data.name}</code></p>}
</div>
<SkillAvatarEditor key={`${namespaceSlug}/${updateSlug}`} name={search.updateName ?? updateSlug} currentUrl={avatarTarget.data?.avatarUrl} canRemove={!!updateSlug} disabled={isBusy || avatarUnavailable} onChange={setAvatarEdit} />
{updateSlug && <SkillAvatarManager namespace={namespaceSlug} slug={updateSlug} disabled={isBusy} />}
{avatarTarget.isError && <p role="alert" className="text-sm text-destructive">{t('skillAvatar.failed')}</p>}

View file

@ -11,6 +11,8 @@ import { FileTree } from '@/features/skill/file-tree'
import { FilePreviewDialog } from '@/features/skill/file-preview-dialog'
import type { FileTreeNode } from '@/features/skill/file-tree-builder'
import type { SkillFile } from '@/api/types'
import { getVersionPresentation } from '@/shared/lib/version-presentation'
import { CopyButton } from '@/shared/components/copy-button'
import { InstallCommand } from '@/features/skill/install-command'
import { ShareButton } from '@/features/skill/share-button'
import { InstallForAgentButton } from '@/features/skill/install-for-agent-button'
@ -198,6 +200,15 @@ 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
: selectedVersion === headlineVersion?.version ? headlineVersion : null
const { data: selectedVersionDetail } = useSkillVersionDetail(qns, qslug, selectedVersion, skillReady && !selectedMetadata)
const historicalMetadata = getVersionPresentation(selectedVersionDetail?.parsedMetadataJson)
const selectedDisplayName = selectedMetadata?.displayName ?? historicalMetadata.displayName ?? skill?.displayName
const selectedSummary = selectedMetadata?.summary ?? historicalMetadata.summary ?? skill?.summary
const showingUnpublishedPreview = selectedVersionEntry != null && selectedVersionEntry.status !== 'PUBLISHED'
const { data: files } = useSkillFiles(qns, qslug, selectedVersion, skillReady)
const documentationPath = resolveDocumentationFilePath(files)
const { data: readme, error: readmeError } = useSkillReadme(qns, qslug, selectedVersion, documentationPath, skillReady)
@ -901,7 +912,11 @@ export function SkillDetailPage() {
</span>
)}
</div>
<div className="flex items-center gap-4"><SkillAvatar name={skill.displayName} url={skill.avatarUrl} size="h-16 w-16 text-2xl" /><h1 className="text-balance break-words text-4xl font-bold font-heading text-foreground [overflow-wrap:anywhere]">{skill.displayName}</h1></div>
<div className="flex items-center gap-4"><SkillAvatar name={selectedDisplayName ?? skill.displayName} url={skill.avatarUrl} size="h-16 w-16 text-2xl" /><h1 className="text-balance break-words text-4xl font-bold font-heading text-foreground [overflow-wrap:anywhere]">{selectedDisplayName}</h1></div>
<div className="flex min-w-0 items-center gap-2">
<code className="min-w-0 break-all text-sm text-muted-foreground">@{skill.namespace}/{skill.slug}</code>
<CopyButton text={`@${skill.namespace}/${skill.slug}`} ariaLabel={t('skillDetail.copyCoordinate')} className="shrink-0" />
</div>
{skill.avatarUrl && user?.userId !== skill.ownerId && (namespace === 'global'
? isPromotionAdmin
: myNamespaces?.some((item) => item.slug === namespace && (item.currentUserRole === 'OWNER' || item.currentUserRole === 'ADMIN')))
@ -916,8 +931,8 @@ export function SkillDetailPage() {
</div>
</div>
)}
{skill.summary && (
<p className="text-lg text-muted-foreground leading-relaxed">{skill.summary}</p>
{selectedSummary && (
<p className="text-lg text-muted-foreground leading-relaxed">{selectedSummary}</p>
)}
{(skill.labels?.length ?? 0) > 0 && (
<div className="flex flex-wrap gap-2">
@ -938,10 +953,10 @@ export function SkillDetailPage() {
))}
</div>
)}
{isPendingPreview && (
{showingUnpublishedPreview && (
<Card className="border-amber-500/30 bg-amber-500/5 p-4 text-sm text-muted-foreground">
<div className="font-medium text-foreground">{t('skillDetail.pendingPreviewTitle')}</div>
<p className="mt-1">{t('skillDetail.pendingPreviewDescription')}</p>
<div className="font-medium text-foreground">{t('skillDetail.unpublishedPreviewTitle')}</div>
<p className="mt-1">{t('skillDetail.unpublishedPreviewDescription')}</p>
</Card>
)}
{hasRejectedOwnerPreview && (
@ -1023,7 +1038,7 @@ export function SkillDetailPage() {
<div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{t('skillDetail.summaryLabel')}
</div>
<p className="mt-2 text-sm leading-6 text-foreground">{skill.summary}</p>
<p className="mt-2 text-sm leading-6 text-foreground">{selectedSummary}</p>
</div>
) : null}
<div className="text-sm text-muted-foreground">
@ -1186,7 +1201,7 @@ export function SkillDetailPage() {
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">{t('skillDetail.version')}</div>
<div className="max-w-[11rem] break-all text-right font-mono font-semibold leading-snug text-foreground">
{headlineVersion ? `v${headlineVersion.version}` : '—'}
{selectedVersion ? `v${selectedVersion}` : '—'}
</div>
</div>

View file

@ -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<PublishResult> {
async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean; avatarAction?: 'KEEP' | 'REPLACE' | 'REMOVE'; avatarUploadId?: string; expectedSlug?: string; displayName?: string }): Promise<PublishResult> {
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<PublishResult>(`${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<import('@/api/generated/schema').components['schemas']['SkillPackagePreviewResponse']> {
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],

View file

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { getVersionPresentation } from './version-presentation'
describe('historical version presentation', () => {
it('uses the explicit version override before original package metadata', () => {
expect(getVersionPresentation(JSON.stringify({ name: 'slug', description: '旧版摘要', displayNameOverride: ' 网页名 ', frontmatter: { metadata: { 'display-name': '包内名' } } })))
.toEqual({ displayName: '网页名', summary: '旧版摘要' })
})
it('keeps old package-only snapshots readable and falls back for unsupported metadata', () => {
expect(getVersionPresentation(JSON.stringify({ name: 'slug', frontmatter: { metadata: { 'display-name': '包内名' } } })).displayName).toBe('包内名')
for (const value of [123, '长'.repeat(201), null, ' ']) {
expect(getVersionPresentation(JSON.stringify({ name: 'slug', frontmatter: { metadata: { 'display-name': value } } })).displayName).toBe('slug')
}
})
it('tolerates absent, malformed and non-object snapshots', () => {
for (const value of [undefined, '', '{', 'null', '[]', 'true']) expect(getVersionPresentation(value)).toEqual({})
})
})

View file

@ -0,0 +1,22 @@
/** Read legacy version snapshots without rewriting the package or trusting arbitrary JSON. */
export function getVersionPresentation(snapshot?: string): { displayName?: string; summary?: string } {
if (!snapshot) return {}
try {
const value: unknown = JSON.parse(snapshot)
if (!isObject(value)) return {}
const frontmatter = isObject(value.frontmatter) ? value.frontmatter : {}
const metadata = isObject(frontmatter.metadata) ? frontmatter.metadata : {}
const displayName = validName(value.displayNameOverride) ?? validName(metadata['display-name'])
?? (typeof value.name === 'string' ? value.name : undefined)
return { displayName, summary: typeof value.description === 'string' ? value.description : undefined }
} catch {
return {}
}
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function validName(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 && value.trim().length <= 200 ? value.trim() : undefined
}