mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-16 04:28:39 +00:00
Merge remote-tracking branch 'origin/main' into docs/lifecycle-contract-alignment
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
commit
edeb4020bb
62 changed files with 1521 additions and 113 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ Status: contract reference with documented implementation exception
|
|||
|
||||
### 4.8 Yank 后指针修正
|
||||
|
||||
- yank 已发布版本时,若命中当前 `latestVersionId`,必须重算 latest published pointer
|
||||
- yank 已发布版本时,若命中当前 `latestVersionId`,必须重算 latest published pointer,并从回退版本的 metadata 恢复展示名和摘要
|
||||
- 若仍有其他 `PUBLISHED` 版本,则指向最新一个
|
||||
- 若已无任何 `PUBLISHED` 版本,则 `latestVersionId = null`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 后执行真实浏览器和生命周期验收。
|
||||
14
openspec/changes/refine-display-name-publishing/proposal.md
Normal file
14
openspec/changes/refine-display-name-publishing/proposal.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
## Why
|
||||
|
||||
PR #957 支持包内展示名称,但网页不能填写,更新待审时“我的技能”仍显示旧标题并重复展示发布和审核状态,详情缺少完整技能标识。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 网页新增/更新技能时可填写展示名称;上传后从包内 metadata.display-name(缺省 name)预填,用户输入优先。
|
||||
- 表单名称作为版本元数据快照保存,不修改上传 ZIP 或 SKILL.md。CLI 继续采用包内字段。
|
||||
- 管理列表展示待审更新的名称和摘要,使用一个主状态;公开页面保持已发布信息。
|
||||
- 详情展示完整 @namespace/slug 和复制入口,所有者预览明确标识未公开。
|
||||
|
||||
## Scope
|
||||
|
||||
沿用现有版本审核、撤回、提升和权限。无独立改名流程、无新数据库迁移,不改变 slug 或下载包内容。
|
||||
|
|
@ -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
|
||||
14
openspec/changes/refine-display-name-publishing/tasks.md
Normal file
14
openspec/changes/refine-display-name-publishing/tasks.md
Normal 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.
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} символов
|
||||
|
|
|
|||
|
|
@ -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} 个字符
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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/**"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 = """
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
105
web/src/api/generated/schema.d.ts
vendored
105
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -307,6 +307,8 @@ export interface AdminLabelInput {
|
|||
}
|
||||
|
||||
export interface SkillLifecycleVersion {
|
||||
displayName?: string
|
||||
summary?: string
|
||||
id: number
|
||||
version: string
|
||||
status: string
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
73
web/src/pages/dashboard/publish-display-name.test.tsx
Normal file
73
web/src/pages/dashboard/publish-display-name.test.tsx
Normal 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('重试后的名称'))
|
||||
})
|
||||
})
|
||||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
18
web/src/shared/lib/version-presentation.test.ts
Normal file
18
web/src/shared/lib/version-presentation.test.ts
Normal 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({})
|
||||
})
|
||||
})
|
||||
22
web/src/shared/lib/version-presentation.ts
Normal file
22
web/src/shared/lib/version-presentation.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue