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/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-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-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"); + } } 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'] }) + }, + }) +}