From 7c2bd553e582a8070ea657bf085aeddd53d2d2db Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 19:08:29 +0800 Subject: [PATCH 1/9] test(token): harden duplicate name regression coverage --- .../controller/TokenControllerTest.java | 22 +++++++++++ .../auth/token/ApiTokenServiceTest.java | 37 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java index 86c9e8d3..a93aea16 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java @@ -93,6 +93,28 @@ class TokenControllerTest { .andExpect(jsonPath("$.msg").value("Token 名称最多 64 个字符")); } + @Test + void create_rejectsDuplicateActiveNames() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + given(apiTokenService.createToken(anyString(), anyString(), anyString(), org.mockito.ArgumentMatchers.nullable(String.class))) + .willThrow(new DomainBadRequestException("error.token.name.duplicate")); + + mockMvc.perform(post("/api/v1/tokens") + .with(authentication(auth)) + .with(csrf()) + .contentType("application/json") + .content(""" + {"name":"cli"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value("你已经有同名 Token")); + } + @Test void create_passesExpirationToService() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java index 6aa7c5ef..af137f20 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java @@ -1,12 +1,16 @@ package com.iflytek.skillhub.auth.token; import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.auth.entity.ApiToken; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; @@ -83,4 +87,37 @@ class ApiTokenServiceTest { verify(tokenRepo, never()).save(any()); } + + @Test + void createToken_rejectsBlankNamesAfterTrimming() { + assertThatThrownBy(() -> service.createToken("user-1", " ", "[]")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("validation.token.name.notBlank"); + + verify(tokenRepo, never()).save(any()); + } + + @Test + void createToken_allowsReusingNameWhenPreviousTokenIsRevoked() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "CLI")) + .thenReturn(false); + when(tokenRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var result = service.createToken("user-1", " CLI ", "[]"); + + assertThat(result.entity().getName()).isEqualTo("CLI"); + verify(tokenRepo).existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "CLI"); + verify(tokenRepo).save(any(ApiToken.class)); + } + + @Test + void createToken_translatesDatabaseConstraintViolationToDuplicateError() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "CLI")) + .thenReturn(false); + when(tokenRepo.save(any())).thenThrow(new DataIntegrityViolationException("duplicate key")); + + assertThatThrownBy(() -> service.createToken("user-1", "CLI", "[]")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("error.token.name.duplicate"); + } } From 29a08d3c540ae2c9ebf130196bafa1dd2b6247d9 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 19:23:20 +0800 Subject: [PATCH 2/9] feat(skill): allow withdrawing pending submissions --- .../portal/SkillLifecycleController.java | 33 ++++++++++ .../src/main/resources/messages.properties | 3 + .../src/main/resources/messages_zh.properties | 3 + .../portal/SkillLifecycleControllerTest.java | 34 +++++++++++ .../skillhub/domain/review/ReviewService.java | 11 +++- .../domain/skill/SkillRepository.java | 1 + .../skill/service/SkillGovernanceService.java | 33 ++++++++++ .../domain/review/ReviewServiceTest.java | 47 ++++++++++++++- .../infra/jpa/JpaSkillRepositoryAdapter.java | 5 ++ web/src/api/client.ts | 8 +++ web/src/i18n/locales/en.json | 12 ++++ web/src/i18n/locales/zh.json | 12 ++++ web/src/pages/dashboard/my-skills.tsx | 60 ++++++++++++++++++- web/src/pages/skill-detail.tsx | 44 ++++++++++++++ web/src/shared/hooks/use-skill-queries.ts | 15 +++++ 15 files changed, 313 insertions(+), 8 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java index 604119ff..d5f23324 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -1,9 +1,11 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.ReviewService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; @@ -32,17 +34,23 @@ public class SkillLifecycleController extends BaseApiController { private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final SkillGovernanceService skillGovernanceService; + private final ReviewService reviewService; + private final AuditLogService auditLogService; public SkillLifecycleController(NamespaceRepository namespaceRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, SkillGovernanceService skillGovernanceService, + ReviewService reviewService, + AuditLogService auditLogService, ApiResponseFactory responseFactory) { super(responseFactory); this.namespaceRepository = namespaceRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillGovernanceService = skillGovernanceService; + this.reviewService = reviewService; + this.auditLogService = auditLogService; } @PostMapping("/{namespace}/{slug}/archive") @@ -108,6 +116,31 @@ public class SkillLifecycleController extends BaseApiController { new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "DELETE_VERSION", version)); } + @PostMapping("/{namespace}/{slug}/versions/{version}/withdraw-review") + public ApiResponse withdrawReview(@PathVariable String namespace, + @PathVariable String slug, + @PathVariable String version, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Skill skill = findSkill(namespace, slug); + SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); + reviewService.withdrawReview(skillVersion.getId(), userId); + auditLogService.record( + userId, + "REVIEW_WITHDRAW", + "SKILL_VERSION", + skillVersion.getId(), + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + "{\"version\":\"" + version.replace("\"", "\\\"") + "\"}" + ); + + return ok("response.success.updated", + new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "WITHDRAW_REVIEW", "DELETED")); + } + private Skill findSkill(String namespaceSlug, String skillSlug) { String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug; Namespace namespace = namespaceRepository.findBySlug(cleanNamespace) diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 70e73e4d..91dbcd7b 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -79,6 +79,9 @@ error.skill.publish.package.invalid=Package validation failed: {0} error.skill.publish.skillMd.notFound=SKILL.md not found error.skill.publish.precheck.failed=Pre-publish validation failed: {0} error.skill.publish.archived=Archived skill must be restored before publishing: {0} +review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0} +review.withdraw.not_submitter=Only the submitter can withdraw this review +review_task.not_found_for_version=No pending review submission found for version: {0} error.skill.publish.summary.tooLong=Skill description must not exceed {0} characters error.skill.notFound=Skill not found: {0} error.skill.access.denied=Access denied to skill: {0} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 0353c3d2..2de14886 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -79,6 +79,9 @@ error.skill.publish.package.invalid=技能包校验失败:{0} error.skill.publish.skillMd.notFound=未找到 SKILL.md error.skill.publish.precheck.failed=预发布校验失败:{0} error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0} +review.withdraw.not_pending=只有待审核版本才能撤销审核:{0} +review.withdraw.not_submitter=只有提交人本人可以撤销此次审核 +review_task.not_found_for_version=未找到该版本对应的待审核记录:{0} error.skill.publish.summary.tooLong=技能描述长度不能超过 {0} 个字符 error.skill.notFound=未找到技能:{0} error.skill.access.denied=没有权限访问技能:{0} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java index 3f196de4..40acf537 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -13,10 +13,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import com.iflytek.skillhub.TestRedisConfig; import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.ReviewService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; @@ -55,6 +57,12 @@ class SkillLifecycleControllerTest { @MockBean private SkillGovernanceService skillGovernanceService; + @MockBean + private ReviewService reviewService; + + @MockBean + private AuditLogService auditLogService; + @MockBean private NamespaceMemberRepository namespaceMemberRepository; @@ -139,6 +147,32 @@ class SkillLifecycleControllerTest { .andExpect(jsonPath("$.data.status").value("1.0.0")); } + @Test + void withdrawReview_returnsUnifiedEnvelope() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner"); + setSkillVersionId(version, 2L); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill)); + given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version)); + + mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.0.0/withdraw-review") + .requestAttr("userId", "usr_1") + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(1)) + .andExpect(jsonPath("$.data.versionId").value(2)) + .andExpect(jsonPath("$.data.action").value("WITHDRAW_REVIEW")) + .andExpect(jsonPath("$.data.status").value("DELETED")); + } + private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) { skill.setStatus(status); return skill; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index 4d654eea..7749622d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -14,6 +14,7 @@ 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.domain.skill.service.SkillGovernanceService; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -34,6 +35,7 @@ public class ReviewService { private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; + private final SkillGovernanceService skillGovernanceService; public ReviewService(ReviewTaskRepository reviewTaskRepository, SkillVersionRepository skillVersionRepository, @@ -41,7 +43,8 @@ public class ReviewService { NamespaceRepository namespaceRepository, ReviewPermissionChecker permissionChecker, ApplicationEventPublisher eventPublisher, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + SkillGovernanceService skillGovernanceService) { this.reviewTaskRepository = reviewTaskRepository; this.skillVersionRepository = skillVersionRepository; this.skillRepository = skillRepository; @@ -49,6 +52,7 @@ public class ReviewService { this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; this.objectMapper = objectMapper; + this.skillGovernanceService = skillGovernanceService; } @Transactional @@ -202,8 +206,9 @@ public class ReviewService { SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId)); - skillVersion.setStatus(SkillVersionStatus.DRAFT); - skillVersionRepository.save(skillVersion); + Skill skill = skillRepository.findById(skillVersion.getSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + skillGovernanceService.withdrawPendingVersion(skill, skillVersion, userId); } public boolean canReviewNamespace(ReviewTask task, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java index 7f2195d4..fa34494c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java @@ -10,6 +10,7 @@ public interface SkillRepository { Optional findByNamespaceIdAndSlug(Long namespaceId, String slug); List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status); Skill save(Skill skill); + void delete(Skill skill); List findByOwnerId(String ownerId); void incrementDownloadCount(Long skillId); List findBySlug(String slug); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 3a17f5ef..2e486a11 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -142,6 +142,39 @@ public class SkillGovernanceService { ); } + @Transactional + public boolean withdrawPendingVersion(Skill skill, + SkillVersion version, + String actorUserId) { + if (version.getStatus() != SkillVersionStatus.PENDING_REVIEW) { + throw new DomainBadRequestException("review.withdraw.not_pending", version.getId()); + } + + List files = skillFileRepository.findByVersionId(version.getId()); + if (!files.isEmpty()) { + objectStorageService.deleteObjects(files.stream().map(SkillFile::getStorageKey).toList()); + } + objectStorageService.deleteObject(String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId())); + skillFileRepository.deleteByVersionId(version.getId()); + skillVersionRepository.delete(version); + + List remainingVersions = skillVersionRepository.findBySkillId(skill.getId()).stream() + .filter(existing -> !existing.getId().equals(version.getId())) + .toList(); + + if (remainingVersions.isEmpty()) { + skillRepository.delete(skill); + return true; + } + + if (version.getId().equals(skill.getLatestVersionId())) { + skill.setLatestVersionId(null); + } + skill.setUpdatedBy(actorUserId); + skillRepository.save(skill); + return false; + } + @Transactional public SkillVersion yankVersion(Long versionId, String actorUserId, String clientIp, String userAgent, String reason) { SkillVersion version = skillVersionRepository.findById(versionId) diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index 70650c7a..7525b4e8 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -14,6 +14,7 @@ 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.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -43,6 +44,7 @@ class ReviewServiceTest { @Mock private NamespaceRepository namespaceRepository; @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private SkillGovernanceService skillGovernanceService; private ReviewService reviewService; @@ -59,7 +61,7 @@ class ReviewServiceTest { objectMapper = new ObjectMapper(); reviewService = new ReviewService( reviewTaskRepository, skillVersionRepository, skillRepository, - namespaceRepository, permissionChecker, eventPublisher, objectMapper); + namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService); } private SkillVersion createDraftSkillVersion() { @@ -414,16 +416,18 @@ class ReviewServiceTest { void shouldWithdrawReviewSuccessfully() { ReviewTask task = createPendingReviewTask(); SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(task)); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(false); reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID); verify(reviewTaskRepository).delete(task); - assertEquals(SkillVersionStatus.DRAFT, sv.getStatus()); - verify(skillVersionRepository).save(sv); + verify(skillGovernanceService).withdrawPendingVersion(skill, sv, USER_ID); } @Test @@ -445,5 +449,42 @@ class ReviewServiceTest { assertThrows(DomainForbiddenException.class, () -> reviewService.withdrawReview(SKILL_VERSION_ID, otherUserId)); } + + @Test + void shouldDeleteEntireSkillWhenOnlyPendingVersionExists() { + ReviewTask task = createPendingReviewTask(); + SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); + + when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.of(task)); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(true); + + reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID); + + verify(reviewTaskRepository).delete(task); + verify(skillGovernanceService).withdrawPendingVersion(skill, sv, USER_ID); + } + + @Test + void shouldDeletePendingVersionAndKeepSkillWhenPublishedHistoryExists() { + ReviewTask task = createPendingReviewTask(); + SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); + setField(skill, "latestVersionId", 99L); + + when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.of(task)); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(false); + + reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID); + + verify(reviewTaskRepository).delete(task); + verify(skillGovernanceService).withdrawPendingVersion(skill, sv, USER_ID); + } } } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java index ceac7c53..cfe57ff3 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -52,6 +52,11 @@ public class JpaSkillRepositoryAdapter implements SkillRepository { return jpaDelegate.save(skill); } + @Override + public void delete(Skill skill) { + jpaDelegate.delete(skill); + } + @Override public List findByOwnerId(String ownerId) { return delegate.findByOwnerId(ownerId); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f0faf193..7fcb02bf 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -439,6 +439,14 @@ export const skillLifecycleApi = { headers: await ensureCsrfHeaders(), }) }, + + async withdrawReview(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${encodeURIComponent(version)}/withdraw-review`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, } export const tokenApi = { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e7a685ad..56283497 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -205,6 +205,12 @@ "unarchiveSuccessTitle": "Skill restored", "unarchiveSuccessDescription": "\"{{skill}}\" has been restored and can publish new versions again.", "unarchiveErrorTitle": "Failed to restore skill", + "withdrawReview": "Withdraw Review", + "withdrawConfirmTitle": "Withdraw upload", + "withdrawConfirmDescription": "After withdrawal, \"{{skill}}\" will no longer be reviewed and the pending version will be deleted.", + "withdrawSuccessTitle": "Upload withdrawn", + "withdrawSuccessDescription": "The pending version for \"{{skill}}\" has been withdrawn.", + "withdrawErrorTitle": "Failed to withdraw upload", "emptyTitle": "No skills yet", "emptyDescription": "Start publishing your first skill", "publishSkill": "Publish Skill" @@ -399,6 +405,7 @@ "governance": "Governance", "processing": "Processing...", "archiveSkill": "Archive Skill", + "withdrawReview": "Withdraw Review", "hideSkill": "Hide Skill", "unhideSkill": "Unhide Skill", "archiveConfirmTitle": "Archive skill", @@ -411,6 +418,11 @@ "unarchiveSuccessTitle": "Skill restored", "unarchiveSuccessDescription": "\"{{skill}}\" has been restored.", "unarchiveErrorTitle": "Failed to restore skill", + "withdrawReviewConfirmTitle": "Withdraw review", + "withdrawReviewConfirmDescription": "After withdrawal, version {{version}} will leave the review queue and be removed from this skill.", + "withdrawReviewSuccessTitle": "Review withdrawn", + "withdrawReviewSuccessDescription": "Version {{version}} has been withdrawn from review.", + "withdrawReviewErrorTitle": "Failed to withdraw review", "deleteVersion": "Delete Version", "deleteVersionConfirmTitle": "Delete version", "deleteVersionConfirmDescription": "Version {{version}} cannot be recovered after deletion. Continue?", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 04830c27..ab37f248 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -205,6 +205,12 @@ "unarchiveSuccessTitle": "技能已恢复", "unarchiveSuccessDescription": "“{{skill}}”已恢复,可继续发布新版本。", "unarchiveErrorTitle": "恢复技能失败", + "withdrawReview": "撤销审核", + "withdrawConfirmTitle": "确认撤销上传", + "withdrawConfirmDescription": "撤销后“{{skill}}”将不再进入审核流程,当前待审核版本会被删除。", + "withdrawSuccessTitle": "已撤销上传", + "withdrawSuccessDescription": "“{{skill}}”的待审核版本已撤销。", + "withdrawErrorTitle": "撤销上传失败", "emptyTitle": "还没有技能", "emptyDescription": "开始发布你的第一个技能吧", "publishSkill": "发布技能" @@ -399,6 +405,7 @@ "governance": "治理操作", "processing": "处理中...", "archiveSkill": "归档技能", + "withdrawReview": "撤销审核", "hideSkill": "隐藏技能", "unhideSkill": "恢复技能", "archiveConfirmTitle": "确认归档技能", @@ -411,6 +418,11 @@ "unarchiveSuccessTitle": "技能已恢复", "unarchiveSuccessDescription": "“{{skill}}”已恢复。", "unarchiveErrorTitle": "恢复技能失败", + "withdrawReviewConfirmTitle": "确认撤销审核", + "withdrawReviewConfirmDescription": "撤销后,版本 {{version}} 将不再进入审核流程,并从当前技能中移除。", + "withdrawReviewSuccessTitle": "已撤销审核", + "withdrawReviewSuccessDescription": "版本 {{version}} 已撤销审核。", + "withdrawReviewErrorTitle": "撤销审核失败", "deleteVersion": "删除版本", "deleteVersionConfirmTitle": "确认删除版本", "deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?", diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index c76261db..84ab687d 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -6,7 +6,7 @@ import { Card } from '@/shared/ui/card' import { EmptyState } from '@/shared/components/empty-state' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' -import { useArchiveSkill, useMySkills, useUnarchiveSkill } from '@/shared/hooks/use-skill-queries' +import { useArchiveSkill, useMySkills, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries' import { formatCompactCount } from '@/shared/lib/number-format' import { toast } from '@/shared/lib/toast' @@ -15,9 +15,11 @@ export function MySkillsPage() { const { t } = useTranslation() const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) + const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) const { data: skills, isLoading } = useMySkills() const archiveMutation = useArchiveSkill() const unarchiveMutation = useUnarchiveSkill() + const withdrawMutation = useWithdrawSkillReview() const handleSkillClick = (namespace: string, slug: string) => { navigate({ to: `/space/${namespace}/${slug}` }) @@ -89,6 +91,27 @@ export function MySkillsPage() { } } + const handleWithdrawSkill = async () => { + if (!withdrawTarget) { + return + } + try { + await withdrawMutation.mutateAsync({ + namespace: withdrawTarget.namespace, + slug: withdrawTarget.slug, + version: withdrawTarget.version, + }) + toast.success( + t('mySkills.withdrawSuccessTitle'), + t('mySkills.withdrawSuccessDescription', { skill: withdrawTarget.name }), + ) + setWithdrawTarget(null) + } catch (error) { + toast.error(t('mySkills.withdrawErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + if (isLoading) { return (
@@ -151,7 +174,27 @@ export function MySkillsPage() {
- {skill.status === 'ARCHIVED' ? ( + {skill.latestVersionStatus === 'PENDING_REVIEW' && skill.latestVersion ? ( + + ) : skill.status === 'ARCHIVED' ? (
) } diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 5dea9ee1..7fe06f77 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -30,6 +30,7 @@ import { useArchiveSkill, useDeleteSkillVersion, useUnarchiveSkill, + useWithdrawSkillReview, } from '@/shared/hooks/use-skill-queries' export function SkillDetailPage() { @@ -43,6 +44,7 @@ export function SkillDetailPage() { const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false) const [unarchiveConfirmOpen, setUnarchiveConfirmOpen] = useState(false) const [deleteVersionTarget, setDeleteVersionTarget] = useState(null) + const [withdrawVersionTarget, setWithdrawVersionTarget] = useState(null) const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' }) const { user, hasRole } = useAuth() @@ -76,6 +78,7 @@ export function SkillDetailPage() { const archiveMutation = useArchiveSkill() const unarchiveMutation = useUnarchiveSkill() const deleteVersionMutation = useDeleteSkillVersion() + const withdrawReviewMutation = useWithdrawSkillReview() const reportMutation = useSubmitSkillReport(namespace, slug) const handleDownload = () => { @@ -150,6 +153,7 @@ export function SkillDetailPage() { } const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED' + const canWithdrawVersion = (status?: string) => status === 'PENDING_REVIEW' const handleArchive = async () => { try { @@ -196,6 +200,24 @@ export function SkillDetailPage() { } } + const handleWithdrawVersion = async () => { + if (!withdrawVersionTarget) { + return + } + try { + await withdrawReviewMutation.mutateAsync({ namespace, slug, version: withdrawVersionTarget }) + toast.success( + t('skillDetail.withdrawReviewSuccessTitle'), + t('skillDetail.withdrawReviewSuccessDescription', { version: withdrawVersionTarget }), + ) + setWithdrawVersionTarget(null) + navigate({ to: '/dashboard/skills' }) + } catch (error) { + toast.error(t('skillDetail.withdrawReviewErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + if (isLoadingSkill) { return (
@@ -323,6 +345,15 @@ export function SkillDetailPage() { {t('skillDetail.deleteVersion')} )} + {skill.canManageLifecycle && canWithdrawVersion(version.status) && ( + + )}
{version.changelog && ( @@ -523,6 +554,19 @@ export function SkillDetailPage() { variant="destructive" onConfirm={handleDeleteVersion} /> + + { + if (!open) { + setWithdrawVersionTarget(null) + } + }} + title={t('skillDetail.withdrawReviewConfirmTitle')} + description={withdrawVersionTarget ? t('skillDetail.withdrawReviewConfirmDescription', { version: withdrawVersionTarget }) : ''} + confirmText={t('skillDetail.withdrawReview')} + onConfirm={handleWithdrawVersion} + /> ) } diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index a6b5b555..d16dc584 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -218,3 +218,18 @@ export function useDeleteSkillVersion() { }, }) } + +export function useWithdrawSkillReview() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ namespace, slug, version }: { namespace: string; slug: string; version: string }) => + skillLifecycleApi.withdrawReview(namespace, slug, version), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug, 'versions'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} From 3f82a88c4e3d8d6865022bb1f8481f417f082a48 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 19:42:44 +0800 Subject: [PATCH 3/9] feat(skill): support rereleasing published versions --- .../skill/service/SkillPublishService.java | 114 +++++++++++++++++- .../service/SkillPublishServiceTest.java | 102 ++++++++++++++++ 2 files changed, 214 insertions(+), 2 deletions(-) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index ca67907c..2238b7e0 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -5,10 +5,12 @@ import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.SlugValidator; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; @@ -17,18 +19,24 @@ import com.iflytek.skillhub.domain.skill.validation.PrePublishValidator; import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; import com.iflytek.skillhub.domain.skill.validation.ValidationResult; import com.iflytek.skillhub.storage.ObjectStorageService; +import org.yaml.snakeyaml.Yaml; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.InputStream; import java.security.MessageDigest; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -88,6 +96,50 @@ public class SkillPublishService { String publisherId, SkillVisibility visibility, java.util.Set platformRoles) { + return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, false, false); + } + + @Transactional + public PublishResult rereleasePublishedVersion( + Long skillId, + String sourceVersion, + String targetVersion, + String publisherId, + Map userNamespaceRoles) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); + assertCanManageLifecycle(skill, publisherId, userNamespaceRoles); + + SkillVersion publishedVersion = skillVersionRepository.findBySkillIdAndVersion(skillId, sourceVersion) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", sourceVersion)); + if (publishedVersion.getStatus() != SkillVersionStatus.PUBLISHED) { + throw new DomainBadRequestException("error.skill.version.notPublished", sourceVersion); + } + if (skillVersionRepository.findBySkillIdAndVersion(skillId, targetVersion).isPresent()) { + throw new DomainBadRequestException("error.skill.version.exists", targetVersion); + } + + List entries = rebuildEntriesForRerelease(skillId, publishedVersion.getId(), targetVersion); + + return publishFromEntriesInternal( + resolveNamespaceSlug(skill.getNamespaceId()), + entries, + publisherId, + skill.getVisibility(), + Set.of(), + true, + true + ); + } + + private PublishResult publishFromEntriesInternal( + String namespaceSlug, + List entries, + String publisherId, + SkillVisibility visibility, + Set platformRoles, + boolean forceAutoPublish, + boolean bypassMembershipCheck) { // 1. Find namespace by slug Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) @@ -96,7 +148,7 @@ public class SkillPublishService { boolean isSuperAdmin = platformRoles.contains("SUPER_ADMIN"); // 2. Check publisher is member unless SUPER_ADMIN short-circuits permission checks - if (!isSuperAdmin) { + if (!isSuperAdmin && !bypassMembershipCheck) { namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId) .orElseThrow(() -> new DomainBadRequestException("error.skill.publish.publisher.notMember", namespaceSlug)); } @@ -153,7 +205,7 @@ public class SkillPublishService { // 8. Create SkillVersion SkillVersion version = new SkillVersion(skill.getId(), metadata.version(), publisherId); - boolean autoPublish = isSuperAdmin; + boolean autoPublish = forceAutoPublish || isSuperAdmin; if (autoPublish) { version.setStatus(SkillVersionStatus.PUBLISHED); version.setPublishedAt(LocalDateTime.now()); @@ -253,6 +305,64 @@ public class SkillPublishService { return new PublishResult(skill.getId(), skill.getSlug(), version); } + private String resolveNamespaceSlug(Long namespaceId) { + return namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.notFound", namespaceId)) + .getSlug(); + } + + private void assertCanManageLifecycle(Skill skill, + String actorUserId, + Map userNamespaceRoles) { + NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId()); + boolean canManage = skill.getOwnerId().equals(actorUserId) + || namespaceRole == NamespaceRole.ADMIN + || namespaceRole == NamespaceRole.OWNER; + if (!canManage) { + throw new DomainForbiddenException("error.skill.lifecycle.noPermission"); + } + } + + private List rebuildEntriesForRerelease(Long skillId, Long versionId, String targetVersion) { + List files = skillFileRepository.findByVersionId(versionId).stream() + .sorted(Comparator.comparing(SkillFile::getFilePath)) + .toList(); + List entries = new ArrayList<>(files.size()); + for (SkillFile file : files) { + byte[] content = readAllBytes(objectStorageService.getObject(file.getStorageKey())); + if ("SKILL.md".equals(file.getFilePath())) { + content = rewriteSkillMdVersion(content, targetVersion); + } + entries.add(new PackageEntry( + file.getFilePath(), + content, + content.length, + file.getContentType() != null ? file.getContentType() : "application/octet-stream" + )); + } + return entries; + } + + private byte[] readAllBytes(InputStream inputStream) { + try (InputStream in = inputStream) { + return in.readAllBytes(); + } catch (IOException e) { + throw new IllegalStateException("Failed to read stored skill file", e); + } + } + + private byte[] rewriteSkillMdVersion(byte[] content, String targetVersion) { + String skillMdContent = new String(content); + SkillMetadata metadata = skillMetadataParser.parse(skillMdContent); + Map frontmatter = new LinkedHashMap<>(metadata.frontmatter()); + frontmatter.put("version", targetVersion); + String rewritten = "---\n" + + new Yaml().dump(frontmatter).trim() + + "\n---\n" + + metadata.body(); + return rewritten.getBytes(); + } + private List> buildManifest(List entries) { return entries.stream() .map(entry -> Map.of( diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 02cc64e2..e5cfb885 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -26,6 +26,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Optional; @@ -415,6 +417,106 @@ class SkillPublishServiceTest { verify(skillRepository).save(skill); } + @Test + void testRereleasePublishedVersion_ShouldCloneFilesAndAutoPublish() throws Exception { + String publisherId = "user-100"; + Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 11L); + skill.setDisplayName("Demo Skill"); + skill.setSummary("Original summary"); + Namespace namespace = new Namespace("global", "Global", "owner"); + setId(namespace, 1L); + + SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId); + setId(sourceVersion, 21L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + sourceVersion.setPublishedAt(LocalDateTime.of(2026, 3, 15, 10, 0)); + + String sourceSkillMd = """ + --- + name: Demo Skill + description: Original summary + version: 1.2.3 + --- + Hello world + """; + byte[] readmeBytes = "# Demo".getBytes(StandardCharsets.UTF_8); + + SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md"); + SkillFile readmeFile = new SkillFile(sourceVersion.getId(), "README.md", (long) readmeBytes.length, "text/markdown", "hash2", "skills/11/21/README.md"); + + SkillMetadata rereleaseMetadata = new SkillMetadata( + "Demo Skill", + "Original summary", + "1.2.4", + "Hello world", + Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4")); + + when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty()); + when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile, readmeFile)); + when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8))); + when(objectStorageService.getObject(readmeFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(readmeBytes)); + when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 30L); + } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); + + SkillPublishService.PublishResult result = service.rereleasePublishedVersion( + skill.getId(), + "1.2.3", + "1.2.4", + publisherId, + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER) + ); + + assertEquals("1.2.4", result.version().getVersion()); + assertEquals(SkillVersionStatus.PUBLISHED, result.version().getStatus()); + assertEquals(30L, skill.getLatestVersionId()); + verify(reviewTaskRepository, never()).save(any()); + verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); + verify(skillPackageValidator).validate(argThat(entries -> + entries.size() == 2 + && entries.stream().anyMatch(entry -> + entry.path().equals("SKILL.md") + && new String(entry.content(), StandardCharsets.UTF_8).contains("version: 1.2.4")))); + verify(prePublishValidator).validate(any()); + } + + @Test + void testRereleasePublishedVersion_ShouldRejectDuplicateTargetVersion() throws Exception { + String publisherId = "user-100"; + Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 11L); + SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId); + setId(sourceVersion, 21L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion existingTarget = new SkillVersion(skill.getId(), "1.2.4", publisherId); + setId(existingTarget, 22L); + + when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.of(existingTarget)); + + assertThrows(DomainBadRequestException.class, () -> service.rereleasePublishedVersion( + skill.getId(), + "1.2.3", + "1.2.4", + publisherId, + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER) + )); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); From 4ad97aa55a0c337ec11eb8137513eec5d3c847e6 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 19:45:10 +0800 Subject: [PATCH 4/9] feat(skill): add version management APIs --- .../portal/SkillLifecycleController.java | 40 +++++++++++++++++ .../dto/SkillVersionRereleaseRequest.java | 9 ++++ .../portal/SkillLifecycleControllerTest.java | 43 +++++++++++++++++++ .../skill/service/SkillQueryService.java | 13 +++++- .../skill/service/SkillQueryServiceTest.java | 37 ++++++++++++++++ 5 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java index d5f23324..7bb2964c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -12,10 +12,13 @@ import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.dto.AdminSkillActionRequest; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; +import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; +import jakarta.validation.Valid; import jakarta.servlet.http.HttpServletRequest; import java.util.Map; import org.springframework.web.bind.annotation.DeleteMapping; @@ -35,6 +38,7 @@ public class SkillLifecycleController extends BaseApiController { private final SkillVersionRepository skillVersionRepository; private final SkillGovernanceService skillGovernanceService; private final ReviewService reviewService; + private final SkillPublishService skillPublishService; private final AuditLogService auditLogService; public SkillLifecycleController(NamespaceRepository namespaceRepository, @@ -42,6 +46,7 @@ public class SkillLifecycleController extends BaseApiController { SkillVersionRepository skillVersionRepository, SkillGovernanceService skillGovernanceService, ReviewService reviewService, + SkillPublishService skillPublishService, AuditLogService auditLogService, ApiResponseFactory responseFactory) { super(responseFactory); @@ -50,6 +55,7 @@ public class SkillLifecycleController extends BaseApiController { this.skillVersionRepository = skillVersionRepository; this.skillGovernanceService = skillGovernanceService; this.reviewService = reviewService; + this.skillPublishService = skillPublishService; this.auditLogService = auditLogService; } @@ -141,6 +147,40 @@ public class SkillLifecycleController extends BaseApiController { new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "WITHDRAW_REVIEW", "DELETED")); } + @PostMapping("/{namespace}/{slug}/versions/{version}/rerelease") + public ApiResponse rereleaseVersion(@PathVariable String namespace, + @PathVariable String slug, + @PathVariable String version, + @Valid @RequestBody SkillVersionRereleaseRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + Skill skill = findSkill(namespace, slug); + SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); + SkillPublishService.PublishResult result = skillPublishService.rereleasePublishedVersion( + skill.getId(), + skillVersion.getVersion(), + request.targetVersion().trim(), + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); + auditLogService.record( + userId, + "RERELEASE_SKILL_VERSION", + "SKILL_VERSION", + skillVersion.getId(), + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + "{\"sourceVersion\":\"" + version.replace("\"", "\\\"") + + "\",\"targetVersion\":\"" + request.targetVersion().trim().replace("\"", "\\\"") + "\"}" + ); + + return ok("response.success.updated", + new SkillLifecycleMutationResponse(result.skillId(), result.version().getId(), "RERELEASE_VERSION", result.version().getStatus().name())); + } + private Skill findSkill(String namespaceSlug, String skillSlug) { String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug; Namespace namespace = namespaceRepository.findBySlug(cleanNamespace) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java new file mode 100644 index 00000000..6ca9e708 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SkillVersionRereleaseRequest( + @NotBlank(message = "{validation.required}") + String targetVersion +) { +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java index 40acf537..26613505 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -25,6 +25,7 @@ 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.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -60,6 +61,9 @@ class SkillLifecycleControllerTest { @MockBean private ReviewService reviewService; + @MockBean + private SkillPublishService skillPublishService; + @MockBean private AuditLogService auditLogService; @@ -173,6 +177,45 @@ class SkillLifecycleControllerTest { .andExpect(jsonPath("$.data.status").value("DELETED")); } + @Test + void rereleaseVersion_returnsUnifiedEnvelope() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + SkillVersion newVersion = new SkillVersion(1L, "1.2.4", "owner"); + setSkillVersionId(newVersion, 3L); + newVersion.setStatus(SkillVersionStatus.PUBLISHED); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill)); + SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner"); + setSkillVersionId(sourceVersion, 2L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.3")).willReturn(java.util.Optional.of(sourceVersion)); + given(skillPublishService.rereleasePublishedVersion( + eq(1L), + eq("1.2.3"), + eq("1.2.4"), + eq("usr_1"), + anyMap())) + .willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion)); + + mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease") + .requestAttr("userId", "usr_1") + .requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetVersion\":\"1.2.4\"}") + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(1)) + .andExpect(jsonPath("$.data.versionId").value(3)) + .andExpect(jsonPath("$.data.action").value("RERELEASE_VERSION")) + .andExpect(jsonPath("$.data.status").value("PUBLISHED")); + } + private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) { skill.setStatus(status); return skill; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 938979cd..a371940d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -256,8 +256,10 @@ public class SkillQueryService { if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream() .filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED + || version.getStatus() == SkillVersionStatus.PENDING_REVIEW || version.getStatus() == SkillVersionStatus.DRAFT - || version.getStatus() == SkillVersionStatus.REJECTED) + || version.getStatus() == SkillVersionStatus.REJECTED + || version.getStatus() == SkillVersionStatus.YANKED) .sorted(Comparator .comparingInt((SkillVersion version) -> lifecycleListPriority(version.getStatus())) .thenComparing(SkillVersion::getPublishedAt, @@ -435,6 +437,15 @@ public class SkillQueryService { if (status == SkillVersionStatus.REJECTED) { return 1; } + if (status == SkillVersionStatus.PENDING_REVIEW) { + return 2; + } + if (status == SkillVersionStatus.DRAFT) { + return 3; + } + if (status == SkillVersionStatus.YANKED) { + return 4; + } return 2; } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 00d4adef..2dfbaba6 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -266,6 +266,43 @@ class SkillQueryServiceTest { assertEquals("README.md", result.get(0).getFilePath()); } + @Test + void testListVersions_ShouldIncludePendingAndRejectedForLifecycleManagers() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String ownerId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.OWNER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId); + setId(published, 10L); + published.setStatus(SkillVersionStatus.PUBLISHED); + published.setPublishedAt(java.time.LocalDateTime.of(2026, 3, 1, 10, 0)); + + SkillVersion pending = new SkillVersion(1L, "1.1.0", ownerId); + setId(pending, 11L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + + SkillVersion rejected = new SkillVersion(1L, "1.2.0", ownerId); + setId(rejected, 12L); + rejected.setStatus(SkillVersionStatus.REJECTED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(pending, published, rejected)); + + Page result = service.listVersions(namespaceSlug, skillSlug, ownerId, userNsRoles, PageRequest.of(0, 20)); + + assertEquals(List.of("1.0.0", "1.2.0", "1.1.0"), + result.getContent().stream().map(SkillVersion::getVersion).toList()); + } + @Test void testResolveVersion_ShouldReturnLatestWhenHashDoesNotMatch() throws Exception { String namespaceSlug = "test-ns"; From 3446740b5f1400b86efdc42fe0b4eaf9e5cd74ce Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 19:48:29 +0800 Subject: [PATCH 5/9] feat(web): add skill version management UI --- web/src/api/client.ts | 11 + web/src/api/types.ts | 12 + web/src/i18n/locales/en.json | 25 +++ web/src/i18n/locales/zh.json | 25 +++ web/src/pages/skill-detail.tsx | 259 ++++++++++++++++++++++ web/src/shared/hooks/use-skill-queries.ts | 30 ++- 6 files changed, 361 insertions(+), 1 deletion(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 7fcb02bf..06d8db58 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -447,6 +447,17 @@ export const skillLifecycleApi = { headers: await ensureCsrfHeaders(), }) }, + + async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${encodeURIComponent(version)}/rerelease`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ targetVersion }), + }) + }, } export const tokenApi = { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 13ffb46d..36a203eb 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -145,6 +145,18 @@ export interface SkillVersion { publishedAt: string } +export interface SkillVersionDetail { + id: number + version: string + status: string + changelog?: string + fileCount: number + totalSize: number + publishedAt: string + parsedMetadataJson?: string + manifestJson?: string +} + export interface SkillFile { id: number filePath: string diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 56283497..0568cb92 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -429,6 +429,31 @@ "deleteVersionSuccessTitle": "Version deleted", "deleteVersionSuccessDescription": "Version {{version}} has been deleted.", "deleteVersionErrorTitle": "Failed to delete version", + "currentVersion": "Current", + "compareVersions": "Compare", + "compareDialogTitle": "Version comparison", + "compareDialogDescription": "Compare v{{source}} with v{{target}}.", + "compareSourceLabel": "Selected version", + "compareTargetLabel": "Compared with", + "versionCompareUnavailableTitle": "Not enough versions to compare", + "versionCompareUnavailableDescription": "Publish at least two versions before using version comparison.", + "metadataChanges": "Metadata changes", + "noMetadataChanges": "No metadata changes", + "readmeChange": "README change", + "readmeChanged": "README content changed", + "readmeUnchanged": "README content unchanged", + "fileChanges": "File changes", + "filesAdded": "Added", + "filesRemoved": "Removed", + "filesChanged": "Changed", + "rereleaseVersion": "Re-release", + "rereleaseDialogTitle": "Re-release from version", + "rereleaseDialogDescription": "Create a new published version based on v{{version}}.", + "rereleaseSourceVersion": "Source version", + "rereleaseTargetVersion": "New version", + "rereleaseSuccessTitle": "Version re-released", + "rereleaseSuccessDescription": "Created v{{target}} from v{{source}}.", + "rereleaseErrorTitle": "Failed to re-release version", "yankVersion": "Yank Current Version", "reportSkill": "Report Skill", "reportDialogTitle": "Report skill", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ab37f248..34e840b8 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -429,6 +429,31 @@ "deleteVersionSuccessTitle": "版本已删除", "deleteVersionSuccessDescription": "版本 {{version}} 已删除。", "deleteVersionErrorTitle": "删除版本失败", + "currentVersion": "当前版本", + "compareVersions": "对比版本", + "compareDialogTitle": "版本对比", + "compareDialogDescription": "对比 v{{source}} 与 v{{target}}。", + "compareSourceLabel": "选中版本", + "compareTargetLabel": "对比版本", + "versionCompareUnavailableTitle": "暂无可对比版本", + "versionCompareUnavailableDescription": "至少发布两个版本后才能进行版本对比。", + "metadataChanges": "元数据变化", + "noMetadataChanges": "元数据没有变化", + "readmeChange": "README 变化", + "readmeChanged": "README 内容已变化", + "readmeUnchanged": "README 内容未变化", + "fileChanges": "文件变化", + "filesAdded": "新增", + "filesRemoved": "删除", + "filesChanged": "修改", + "rereleaseVersion": "重新发布", + "rereleaseDialogTitle": "基于旧版本重新发布", + "rereleaseDialogDescription": "基于 v{{version}} 创建一个新的已发布版本。", + "rereleaseSourceVersion": "来源版本", + "rereleaseTargetVersion": "新版本号", + "rereleaseSuccessTitle": "版本已重新发布", + "rereleaseSuccessDescription": "已基于 v{{source}} 创建新版本 v{{target}}。", + "rereleaseErrorTitle": "重新发布版本失败", "yankVersion": "撤回当前版本", "reportSkill": "举报技能", "reportDialogTitle": "举报技能", diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 7fe06f77..a82d0c82 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -25,14 +25,37 @@ import { toast } from '@/shared/lib/toast' import { useSkillDetail, useSkillVersions, + useSkillVersionDetail, useSkillFiles, useSkillReadme, useArchiveSkill, useDeleteSkillVersion, + useRereleaseSkillVersion, useUnarchiveSkill, useWithdrawSkillReview, } from '@/shared/hooks/use-skill-queries' +function suggestNextVersion(version: string) { + const semverMatch = version.match(/^(\d+)\.(\d+)\.(\d+)$/) + if (semverMatch) { + const [, major, minor, patch] = semverMatch + return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}` + } + return `${version}.1` +} + +function parseMetadataJson(parsed?: string) { + if (!parsed) { + return {} + } + try { + const value = JSON.parse(parsed) + return typeof value === 'object' && value !== null ? value : {} + } catch { + return {} + } +} + export function SkillDetailPage() { const { t, i18n } = useTranslation() const navigate = useNavigate() @@ -45,6 +68,10 @@ export function SkillDetailPage() { const [unarchiveConfirmOpen, setUnarchiveConfirmOpen] = useState(false) const [deleteVersionTarget, setDeleteVersionTarget] = useState(null) const [withdrawVersionTarget, setWithdrawVersionTarget] = useState(null) + const [rereleaseTarget, setRereleaseTarget] = useState(null) + const [targetVersionInput, setTargetVersionInput] = useState('') + const [diffSourceVersion, setDiffSourceVersion] = useState(null) + const [diffCompareVersion, setDiffCompareVersion] = useState(null) const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' }) const { user, hasRole } = useAuth() @@ -53,6 +80,12 @@ export function SkillDetailPage() { const latestVersion = versions?.[0] const { data: files } = useSkillFiles(namespace, slug, latestVersion?.version) const { data: readme } = useSkillReadme(namespace, slug, latestVersion?.version) + const { data: diffSourceDetail } = useSkillVersionDetail(namespace, slug, diffSourceVersion ?? undefined) + const { data: diffCompareDetail } = useSkillVersionDetail(namespace, slug, diffCompareVersion ?? undefined) + const { data: diffSourceFiles } = useSkillFiles(namespace, slug, diffSourceVersion ?? undefined) + const { data: diffCompareFiles } = useSkillFiles(namespace, slug, diffCompareVersion ?? undefined) + const { data: diffSourceReadme } = useSkillReadme(namespace, slug, diffSourceVersion ?? undefined) + const { data: diffCompareReadme } = useSkillReadme(namespace, slug, diffCompareVersion ?? undefined) const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const refreshSkill = () => { @@ -79,6 +112,7 @@ export function SkillDetailPage() { const unarchiveMutation = useUnarchiveSkill() const deleteVersionMutation = useDeleteSkillVersion() const withdrawReviewMutation = useWithdrawSkillReview() + const rereleaseVersionMutation = useRereleaseSkillVersion() const reportMutation = useSubmitSkillReport(namespace, slug) const handleDownload = () => { @@ -154,6 +188,43 @@ export function SkillDetailPage() { const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED' const canWithdrawVersion = (status?: string) => status === 'PENDING_REVIEW' + const canRereleaseVersion = (status?: string) => status === 'PUBLISHED' + + const metadataDiffEntries = (() => { + const source = parseMetadataJson(diffSourceDetail?.parsedMetadataJson) + const compare = parseMetadataJson(diffCompareDetail?.parsedMetadataJson) + const keys = Array.from(new Set([...Object.keys(source), ...Object.keys(compare)])).sort() + return keys + .filter((key) => JSON.stringify(source[key]) !== JSON.stringify(compare[key])) + .map((key) => ({ + key, + source: source[key], + target: compare[key], + })) + })() + + const fileDiffSummary = (() => { + const sourceMap = new Map((diffSourceFiles ?? []).map((file) => [file.filePath, file.sha256])) + const compareMap = new Map((diffCompareFiles ?? []).map((file) => [file.filePath, file.sha256])) + const added: string[] = [] + const removed: string[] = [] + const changed: string[] = [] + + for (const [path, hash] of sourceMap.entries()) { + if (!compareMap.has(path)) { + removed.push(path) + } else if (compareMap.get(path) !== hash) { + changed.push(path) + } + } + for (const path of compareMap.keys()) { + if (!sourceMap.has(path)) { + added.push(path) + } + } + + return { added, removed, changed } + })() const handleArchive = async () => { try { @@ -218,6 +289,45 @@ export function SkillDetailPage() { } } + const handleOpenRerelease = (version: string) => { + setRereleaseTarget(version) + setTargetVersionInput(suggestNextVersion(version)) + } + + const handleRereleaseVersion = async () => { + if (!rereleaseTarget || !targetVersionInput.trim()) { + return + } + try { + await rereleaseVersionMutation.mutateAsync({ + namespace, + slug, + version: rereleaseTarget, + targetVersion: targetVersionInput.trim(), + }) + toast.success( + t('skillDetail.rereleaseSuccessTitle'), + t('skillDetail.rereleaseSuccessDescription', { source: rereleaseTarget, target: targetVersionInput.trim() }), + ) + setRereleaseTarget(null) + setTargetVersionInput('') + } catch (error) { + toast.error(t('skillDetail.rereleaseErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + + const handleOpenDiff = (version: string) => { + const publishedVersions = versions?.filter((item) => item.status === 'PUBLISHED') ?? [] + const compareVersion = publishedVersions.find((item) => item.version !== version)?.version ?? null + if (!compareVersion) { + toast.error(t('skillDetail.versionCompareUnavailableTitle'), t('skillDetail.versionCompareUnavailableDescription')) + return + } + setDiffSourceVersion(version) + setDiffCompareVersion(compareVersion) + } + if (isLoadingSkill) { return (
@@ -331,11 +441,34 @@ export function SkillDetailPage() { {version.status} )} + {skill.latestVersion === version.version && ( + + {t('skillDetail.currentVersion')} + + )}
{formatLocalDateTime(version.publishedAt, i18n.language)} + {skill.canManageLifecycle && canRereleaseVersion(version.status) && ( + <> + + + + )} {skill.canManageLifecycle && canDeleteVersion(version.status) && ( + + + + + + { + if (!open) { + setDiffSourceVersion(null) + setDiffCompareVersion(null) + } + }} + > + + + {t('skillDetail.compareDialogTitle')} + + {diffSourceVersion && diffCompareVersion + ? t('skillDetail.compareDialogDescription', { source: diffSourceVersion, target: diffCompareVersion }) + : ''} + + +
+
+
+
{t('skillDetail.compareSourceLabel')}
+
v{diffSourceVersion}
+
+
+
{t('skillDetail.compareTargetLabel')}
+
v{diffCompareVersion}
+
+
+ +
+
{t('skillDetail.metadataChanges')}
+ {metadataDiffEntries.length > 0 ? ( +
+ {metadataDiffEntries.map((entry) => ( +
+
{entry.key}
+
+ {String(entry.source ?? '—')} → {String(entry.target ?? '—')} +
+
+ ))} +
+ ) : ( +
{t('skillDetail.noMetadataChanges')}
+ )} +
+ +
+
{t('skillDetail.readmeChange')}
+
+ {diffSourceReadme !== diffCompareReadme ? t('skillDetail.readmeChanged') : t('skillDetail.readmeUnchanged')} +
+
+ +
+
{t('skillDetail.fileChanges')}
+
+
+
{t('skillDetail.filesAdded')}
+
{fileDiffSummary.added.length}
+
+
+
{t('skillDetail.filesRemoved')}
+
{fileDiffSummary.removed.length}
+
+
+
{t('skillDetail.filesChanged')}
+
{fileDiffSummary.changed.length}
+
+
+
+
+ + + +
+
) } diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index d16dc584..ce2bf1b5 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import type { SkillSummary, SkillDetail, SkillVersion, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types' +import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types' import { fetchJson, fetchText, getCsrfHeaders, meApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client' const PUBLISH_REQUEST_TIMEOUT_MS = 60_000 @@ -34,6 +34,11 @@ async function getSkillFiles(namespace: string, slug: string, version: string): return fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/files`) } +async function getSkillVersionDetail(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + return fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}`) +} + async function getSkillReadme(namespace: string, slug: string, version: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace try { @@ -122,6 +127,14 @@ export function useSkillReadme(namespace: string, slug: string, version?: string }) } +export function useSkillVersionDetail(namespace: string, slug: string, version?: string) { + return useQuery({ + queryKey: ['skills', namespace, slug, 'versions', version, 'detail'], + queryFn: () => getSkillVersionDetail(namespace, slug, version!), + enabled: !!namespace && !!slug && !!version, + }) +} + export function useMySkills() { return useQuery({ queryKey: ['skills', 'my'], @@ -233,3 +246,18 @@ export function useWithdrawSkillReview() { }, }) } + +export function useRereleaseSkillVersion() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ namespace, slug, version, targetVersion }: { namespace: string; slug: string; version: string; targetVersion: string }) => + skillLifecycleApi.rereleaseVersion(namespace, slug, version, targetVersion), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug, 'versions'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} From 28393669e0fa4c7d6b7196da4f5d8902674f7e71 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 19:59:19 +0800 Subject: [PATCH 6/9] feat(skill): allow owner preview of pending submissions --- .../controller/portal/SkillController.java | 4 +- .../skillhub/dto/SkillDetailResponse.java | 4 +- .../controller/SkillControllerTest.java | 36 +++++ .../skill/service/SkillQueryService.java | 59 ++++++-- .../skill/service/SkillQueryServiceTest.java | 130 ++++++++++++++++++ web/src/api/types.ts | 2 + web/src/i18n/locales/en.json | 4 + web/src/i18n/locales/zh.json | 4 + web/src/pages/skill-detail.tsx | 50 +++++-- 9 files changed, 265 insertions(+), 28 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 059da89b..b2ad113d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -71,7 +71,9 @@ public class SkillController extends BaseApiController { detail.hidden(), detail.latestVersion(), namespace, - detail.canManageLifecycle() + detail.canManageLifecycle(), + detail.viewingVersionStatus(), + detail.canInteract() ); return ok("response.success.read", response); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index 09a67b51..f7152d7b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -16,5 +16,7 @@ public record SkillDetailResponse( boolean hidden, String latestVersion, String namespace, - boolean canManageLifecycle + boolean canManageLifecycle, + String viewingVersionStatus, + boolean canInteract ) {} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 5022d199..d34698f0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -102,6 +102,42 @@ class SkillControllerTest { .andExpect(jsonPath("$.requestId").isNotEmpty()); } + @Test + void getSkillDetailShouldExposePendingPreviewFlags() throws Exception { + when(skillQueryService.getSkillDetail( + eq("team"), + eq("demo"), + eq((String) null), + eq(Map.of()))) + .thenReturn(new SkillQueryService.SkillDetailDTO( + 1L, + "demo", + "Demo", + "Pending preview", + "PUBLIC", + "ACTIVE", + 10L, + 2, + null, + 0, + false, + "1.1.0", + 1L, + LocalDateTime.of(2026, 3, 15, 10, 0), + LocalDateTime.of(2026, 3, 15, 10, 0), + null, + true, + "PENDING_REVIEW", + false + )); + + mockMvc.perform(get("/api/web/skills/team/demo")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.latestVersion").value("1.1.0")) + .andExpect(jsonPath("$.data.viewingVersionStatus").value("PENDING_REVIEW")) + .andExpect(jsonPath("$.data.canInteract").value(false)); + } + @Test void listFilesByTagShouldReturnUnifiedEnvelope() throws Exception { when(skillQueryService.listFilesByTag( diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index a371940d..facf66ad 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -69,7 +69,9 @@ public class SkillQueryService { java.time.LocalDateTime createdAt, java.time.LocalDateTime updatedAt, SkillVersion latestVersionEntity, - boolean canManageLifecycle + boolean canManageLifecycle, + String viewingVersionStatus, + boolean canInteract ) {} public record SkillVersionDetailDTO( @@ -111,12 +113,9 @@ public class SkillQueryService { } String latestVersion = null; - SkillVersion latestVersionEntity = null; - if (skill.getLatestVersionId() != null) { - latestVersionEntity = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); - if (latestVersionEntity != null) { - latestVersion = latestVersionEntity.getVersion(); - } + SkillVersion latestVersionEntity = resolvePreviewVersion(skill, currentUserId); + if (latestVersionEntity != null) { + latestVersion = latestVersionEntity.getVersion(); } return new SkillDetailDTO( @@ -136,7 +135,9 @@ public class SkillQueryService { skill.getCreatedAt(), skill.getUpdatedAt(), latestVersionEntity, - canManageRestrictedSkill(skill, currentUserId, userNsRoles) + canManageRestrictedSkill(skill, currentUserId, userNsRoles), + latestVersionEntity != null ? latestVersionEntity.getStatus().name() : null, + latestVersionEntity == null || latestVersionEntity.getStatus() == SkillVersionStatus.PUBLISHED ); } @@ -171,7 +172,7 @@ public class SkillQueryService { Skill skill = findSkill(namespaceSlug, skillSlug); assertPublishedAccessible(skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); - assertPublishedVersion(skillVersion, version); + assertPreviewAccessible(skill, skillVersion, version, currentUserId); return new SkillVersionDetailDTO( skillVersion.getId(), @@ -196,7 +197,7 @@ public class SkillQueryService { assertPublishedAccessible(skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); - assertPublishedVersion(skillVersion, version); + assertPreviewAccessible(skill, skillVersion, version, currentUserId); return skillFileRepository.findByVersionId(skillVersion.getId()); } @@ -224,7 +225,7 @@ public class SkillQueryService { assertPublishedAccessible(skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); - assertPublishedVersion(skillVersion, version); + assertPreviewAccessible(skill, skillVersion, version, currentUserId); SkillFile file = findFile(skillVersion, filePath); @@ -388,6 +389,28 @@ public class SkillQueryService { return latestVersion; } + private SkillVersion resolvePreviewVersion(Skill skill, String currentUserId) { + SkillVersion ownerPreview = resolveOwnerPendingPreview(skill, currentUserId); + if (ownerPreview != null) { + return ownerPreview; + } + if (skill.getLatestVersionId() == null) { + return null; + } + return skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); + } + + private SkillVersion resolveOwnerPendingPreview(Skill skill, String currentUserId) { + if (!isOwner(skill, currentUserId)) { + return null; + } + return skillVersionRepository.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW).stream() + .max(Comparator + .comparing(SkillVersion::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder()))) + .orElse(null); + } + private String computeFingerprint(SkillVersion version) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); @@ -430,6 +453,10 @@ public class SkillQueryService { || role == NamespaceRole.OWNER; } + private boolean isOwner(Skill skill, String currentUserId) { + return currentUserId != null && skill.getOwnerId().equals(currentUserId); + } + private int lifecycleListPriority(SkillVersionStatus status) { if (status == SkillVersionStatus.PUBLISHED) { return 0; @@ -454,4 +481,14 @@ public class SkillQueryService { throw new DomainBadRequestException("error.skill.version.notPublished", versionStr); } } + + private void assertPreviewAccessible(Skill skill, SkillVersion version, String versionStr, String currentUserId) { + if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + return; + } + if (version.getStatus() == SkillVersionStatus.PENDING_REVIEW && isOwner(skill, currentUserId)) { + return; + } + throw new DomainBadRequestException("error.skill.version.notPublished", versionStr); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 2dfbaba6..55e62bae 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -432,6 +432,136 @@ class SkillQueryServiceTest { assertFalse(result.canManageLifecycle()); } + @Test + void testGetSkillDetail_ShouldPreferPendingVersionForOwnerPreview() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String ownerId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + published.setPublishedAt(java.time.LocalDateTime.of(2026, 3, 1, 10, 0)); + + SkillVersion pending = new SkillVersion(1L, "1.1.0", ownerId); + setId(pending, 12L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)) + .thenReturn(List.of(pending)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles); + + assertEquals("1.1.0", result.latestVersion()); + assertEquals("PENDING_REVIEW", result.viewingVersionStatus()); + assertFalse(result.canInteract()); + } + + @Test + void testGetVersionDetail_ShouldAllowPendingVersionForOwnerPreview() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.1.0"; + String ownerId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + SkillVersion pending = new SkillVersion(1L, version, ownerId); + setId(pending, 11L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + pending.setParsedMetadataJson("{\"name\":\"test-skill\"}"); + pending.setManifestJson("[{\"path\":\"SKILL.md\"}]"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); + + SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail( + namespaceSlug, + skillSlug, + version, + ownerId, + userNsRoles + ); + + assertEquals("PENDING_REVIEW", result.status()); + assertEquals("{\"name\":\"test-skill\"}", result.parsedMetadataJson()); + } + + @Test + void testListFiles_ShouldAllowPendingVersionForOwnerPreview() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.1.0"; + String ownerId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion pending = new SkillVersion(1L, version, ownerId); + setId(pending, 11L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); + when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); + + List result = service.listFiles(namespaceSlug, skillSlug, version, ownerId, userNsRoles); + + assertEquals(1, result.size()); + assertEquals("README.md", result.get(0).getFilePath()); + } + + @Test + void testGetVersionDetail_ShouldRejectPendingVersionForNonOwner() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.1.0"; + String viewerId = "viewer-1"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + SkillVersion pending = new SkillVersion(1L, version, "owner-1"); + setId(pending, 11L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); + + assertThrows(DomainBadRequestException.class, () -> + service.getVersionDetail(namespaceSlug, skillSlug, version, viewerId, userNsRoles)); + } + @Test void testListVersions_ShouldIncludeDraftAndRejectedForLifecycleManagers() throws Exception { String namespaceSlug = "test-ns"; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 36a203eb..8c59a289 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -133,6 +133,8 @@ export interface SkillDetail { latestVersion?: string namespace: string canManageLifecycle: boolean + viewingVersionStatus?: string + canInteract: boolean } export interface SkillVersion { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 0568cb92..1cd483e9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -402,6 +402,10 @@ "statusActive": "Active", "statusArchived": "Archived", "statusHidden": "Hidden", + "pendingPreviewBadge": "Pending Preview", + "pendingPreviewTitle": "You are previewing a pending version", + "pendingPreviewDescription": "This version is only visible to you. Before review approval, you can inspect the README, files, and version information, but you cannot star, rate, report, or download it.", + "pendingPreviewInteractionHint": "Stars, ratings, and reports are disabled while this version is pending review.", "governance": "Governance", "processing": "Processing...", "archiveSkill": "Archive Skill", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 34e840b8..ac69df7f 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -402,6 +402,10 @@ "statusActive": "正常", "statusArchived": "已归档", "statusHidden": "已隐藏", + "pendingPreviewBadge": "待审核预览", + "pendingPreviewTitle": "当前正在预览待审核版本", + "pendingPreviewDescription": "该版本仅你本人可见。审核通过前,你可以查看 README、文件和版本信息,但不能收藏、评分、举报或下载。", + "pendingPreviewInteractionHint": "待审核预览期间不可收藏、评分或举报。", "governance": "治理操作", "processing": "处理中...", "archiveSkill": "归档技能", diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index a82d0c82..b748dbc2 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -77,9 +77,10 @@ export function SkillDetailPage() { const { data: skill, isLoading: isLoadingSkill, error: skillError } = useSkillDetail(namespace, slug) const { data: versions } = useSkillVersions(namespace, slug) - const latestVersion = versions?.[0] - const { data: files } = useSkillFiles(namespace, slug, latestVersion?.version) - const { data: readme } = useSkillReadme(namespace, slug, latestVersion?.version) + const selectedVersion = skill?.latestVersion ?? versions?.[0]?.version + const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion) ?? versions?.[0] + const { data: files } = useSkillFiles(namespace, slug, selectedVersion) + const { data: readme } = useSkillReadme(namespace, slug, selectedVersion) const { data: diffSourceDetail } = useSkillVersionDetail(namespace, slug, diffSourceVersion ?? undefined) const { data: diffCompareDetail } = useSkillVersionDetail(namespace, slug, diffCompareVersion ?? undefined) const { data: diffSourceFiles } = useSkillFiles(namespace, slug, diffSourceVersion ?? undefined) @@ -87,6 +88,8 @@ export function SkillDetailPage() { const { data: diffSourceReadme } = useSkillReadme(namespace, slug, diffSourceVersion ?? undefined) const { data: diffCompareReadme } = useSkillReadme(namespace, slug, diffCompareVersion ?? undefined) const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') + const isPendingPreview = skill?.viewingVersionStatus === 'PENDING_REVIEW' + const canInteract = skill?.canInteract ?? true const refreshSkill = () => { queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] }) @@ -105,7 +108,7 @@ export function SkillDetailPage() { }) const yankMutation = useMutation({ - mutationFn: () => adminApi.yankVersion(latestVersion!.id), + mutationFn: () => adminApi.yankVersion(selectedVersionEntry!.id), onSuccess: refreshSkill, }) const archiveMutation = useArchiveSkill() @@ -120,11 +123,11 @@ export function SkillDetailPage() { requireLogin() return } - if (!latestVersion) { + if (!selectedVersionEntry || isPendingPreview) { return } const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace - const downloadUrl = `${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${latestVersion.version}/download` + const downloadUrl = `${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${selectedVersionEntry.version}/download` window.open(downloadUrl, '_blank') } @@ -389,11 +392,22 @@ export function SkillDetailPage() { {resolveSkillStatusLabel(skill.status)} )} + {isPendingPreview && ( + + {t('skillDetail.pendingPreviewBadge')} + + )}

{skill.displayName}

{skill.summary && (

{skill.summary}

)} + {isPendingPreview && ( + +
{t('skillDetail.pendingPreviewTitle')}
+

{t('skillDetail.pendingPreviewDescription')}

+
+ )} @@ -544,18 +558,24 @@ export function SkillDetailPage() {
- - - - {!user && ( + {canInteract ? ( + <> + + + + + ) : ( +

{t('skillDetail.pendingPreviewInteractionHint')}

+ )} + {!user && canInteract && (

{t('skillDetail.loginToRate')}

)}
- {skill.latestVersion && ( + {skill.latestVersion && canInteract && (
{t('skillDetail.install')}
{skill.status === 'ARCHIVED' && ( @@ -574,7 +594,7 @@ export function SkillDetailPage() { variant="outline" size="lg" onClick={handleDownload} - disabled={!latestVersion || skill.status === 'ARCHIVED'} + disabled={!selectedVersionEntry || skill.status === 'ARCHIVED' || isPendingPreview} > @@ -615,7 +635,7 @@ export function SkillDetailPage() { {unhideMutation.isPending ? t('skillDetail.processing') : t('skillDetail.unhideSkill')} )} - {latestVersion && ( + {selectedVersionEntry && ( From 2757f0ede7d33366e20ed6f8e3953781f60ce98f Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 20:02:31 +0800 Subject: [PATCH 7/9] fix(skill): handle missing stored files gracefully --- .../skill/service/SkillQueryService.java | 13 +++++-- .../skill/service/SkillQueryServiceTest.java | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index facf66ad..5dbc990e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -13,6 +13,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import java.io.InputStream; +import java.io.UncheckedIOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -229,7 +230,7 @@ public class SkillQueryService { SkillFile file = findFile(skillVersion, filePath); - return objectStorageService.getObject(file.getStorageKey()); + return readFileContent(file); } public InputStream getFileContentByTag( @@ -243,7 +244,7 @@ public class SkillQueryService { assertPublishedAccessible(skill, currentUserId, userNsRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); SkillFile file = findFile(skillVersion, filePath); - return objectStorageService.getObject(file.getStorageKey()); + return readFileContent(file); } public Page listVersions(String namespaceSlug, @@ -339,6 +340,14 @@ public class SkillQueryService { .orElseThrow(() -> new DomainBadRequestException("error.skill.file.notFound", filePath)); } + private InputStream readFileContent(SkillFile file) { + try { + return objectStorageService.getObject(file.getStorageKey()); + } catch (UncheckedIOException e) { + throw new DomainBadRequestException("error.skill.file.notFound", file.getFilePath()); + } + } + private SkillVersion resolveVersionEntity(Skill skill, String version, String tag, String hash) { if (version != null && !version.isBlank()) { SkillVersion exactVersion = findVersion(skill, version); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 55e62bae..937c72f1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -18,6 +18,7 @@ import org.springframework.data.domain.Pageable; import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.io.UncheckedIOException; import java.lang.reflect.Field; import java.util.List; import java.util.Map; @@ -202,6 +203,39 @@ class SkillQueryServiceTest { service.listFiles(namespaceSlug, skillSlug, version, "user-100", userNsRoles)); } + @Test + void testGetFileContent_ShouldTranslateMissingStorageObject() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.0.0"; + String filePath = "SKILL.md"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion skillVersion = new SkillVersion(1L, version, "user-100"); + setId(skillVersion, 1L); + skillVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillFile file = new SkillFile(1L, filePath, 100L, "text/markdown", "hash1", "skills/1/1/SKILL.md"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); + when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file)); + when(objectStorageService.getObject(file.getStorageKey())) + .thenThrow(new UncheckedIOException(new java.io.FileNotFoundException(file.getStorageKey()))); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.getFileContent(namespaceSlug, skillSlug, version, filePath, "user-100", userNsRoles)); + + assertEquals("error.skill.file.notFound", ex.messageCode()); + assertArrayEquals(new Object[]{filePath}, ex.messageArgs()); + } + @Test void testGetVersionDetail_ShouldReturnMetadataPayload() throws Exception { String namespaceSlug = "test-ns"; From c6b3eab034756f8a2d2728ac94f3c390572ed437 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 20:06:45 +0800 Subject: [PATCH 8/9] fix(skill): keep published view stable for owners --- .../skill/service/SkillQueryService.java | 12 +++---- .../skill/service/SkillQueryServiceTest.java | 34 +++++++++++++++++++ web/src/shared/lib/date-time.ts | 5 ++- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 5dbc990e..d03565f5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -399,14 +399,14 @@ public class SkillQueryService { } private SkillVersion resolvePreviewVersion(Skill skill, String currentUserId) { - SkillVersion ownerPreview = resolveOwnerPendingPreview(skill, currentUserId); - if (ownerPreview != null) { - return ownerPreview; + SkillVersion publishedVersion = null; + if (skill.getLatestVersionId() != null) { + publishedVersion = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); } - if (skill.getLatestVersionId() == null) { - return null; + if (publishedVersion != null) { + return publishedVersion; } - return skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); + return resolveOwnerPendingPreview(skill, currentUserId); } private SkillVersion resolveOwnerPendingPreview(Skill skill, String currentUserId) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 937c72f1..143e8b7a 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -502,6 +502,40 @@ class SkillQueryServiceTest { assertFalse(result.canInteract()); } + @Test + void testGetSkillDetail_ShouldKeepPublishedVersionWhenSkillAlreadyPublic() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String ownerId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + SkillVersion pending = new SkillVersion(1L, "1.1.0", ownerId); + setId(pending, 12L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles); + + assertEquals("1.0.0", result.latestVersion()); + assertEquals("PUBLISHED", result.viewingVersionStatus()); + assertTrue(result.canInteract()); + } + @Test void testGetVersionDetail_ShouldAllowPendingVersionForOwnerPreview() throws Exception { String namespaceSlug = "test-ns"; diff --git a/web/src/shared/lib/date-time.ts b/web/src/shared/lib/date-time.ts index ff229610..64c3e457 100644 --- a/web/src/shared/lib/date-time.ts +++ b/web/src/shared/lib/date-time.ts @@ -13,9 +13,12 @@ function parseServerDateTime(value: string): Date { } export function formatLocalDateTime( - value: string, + value: string | null | undefined, locale: string, options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' }, ) { + if (!value) { + return '—' + } return new Intl.DateTimeFormat(locale, options).format(parseServerDateTime(value)) } From 907885eb79f9b3f5283e56908c99d7e85913795b Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 20:09:32 +0800 Subject: [PATCH 9/9] fix(skill): surface unavailable readme states --- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 1 + web/src/pages/skill-detail.tsx | 6 +++++- web/src/shared/hooks/use-skill-queries.ts | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1cd483e9..3ba8c57e 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -384,6 +384,7 @@ "tabFiles": "Files", "tabVersions": "Versions", "noReadme": "No README", + "readmeUnavailable": "README is temporarily unavailable. Please try again later or re-upload this skill version.", "noFiles": "No files", "noVersions": "No versions", "fileCount": "{{count}} files", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ac69df7f..78e9b37b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -384,6 +384,7 @@ "tabFiles": "文件", "tabVersions": "版本", "noReadme": "暂无 README", + "readmeUnavailable": "README 暂时不可用,请稍后重试或重新上传该技能版本。", "noFiles": "暂无文件", "noVersions": "暂无版本", "fileCount": "{{count}} 个文件", diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index b748dbc2..7794ffad 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -80,7 +80,7 @@ export function SkillDetailPage() { const selectedVersion = skill?.latestVersion ?? versions?.[0]?.version const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion) ?? versions?.[0] const { data: files } = useSkillFiles(namespace, slug, selectedVersion) - const { data: readme } = useSkillReadme(namespace, slug, selectedVersion) + const { data: readme, error: readmeError } = useSkillReadme(namespace, slug, selectedVersion) const { data: diffSourceDetail } = useSkillVersionDetail(namespace, slug, diffSourceVersion ?? undefined) const { data: diffCompareDetail } = useSkillVersionDetail(namespace, slug, diffCompareVersion ?? undefined) const { data: diffSourceFiles } = useSkillFiles(namespace, slug, diffSourceVersion ?? undefined) @@ -422,6 +422,10 @@ export function SkillDetailPage() { + ) : readmeError ? ( + + {t('skillDetail.readmeUnavailable')} + ) : ( {t('skillDetail.noReadme')} diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index ce2bf1b5..7b177fa0 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -44,7 +44,7 @@ async function getSkillReadme(namespace: string, slug: string, version: string): try { return await fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=SKILL.md`) } catch { - return '' + return await fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=README.md`) } }