mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-07 08:26:00 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
9df63be0bb
24 changed files with 1103 additions and 19 deletions
22
README.md
22
README.md
|
|
@ -58,7 +58,21 @@ firewall, with the same polish you'd expect from a public registry.
|
|||
|
||||
## Quick Start
|
||||
|
||||
Start the full local stack with: `curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up`
|
||||
Start the full local stack with one of the following commands:
|
||||
|
||||
Official images:
|
||||
```bash
|
||||
rm -rf /tmp/skillhub-runtime
|
||||
curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up
|
||||
```
|
||||
|
||||
Aliyun mirror shortcut:
|
||||
```bash
|
||||
rm -rf /tmp/skillhub-aliyun
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --home /tmp/skillhub-aliyun --aliyun --version edge
|
||||
```
|
||||
|
||||
If deployment runs into problems, clear the existing runtime home and retry.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
|
|
@ -137,12 +151,6 @@ Recommended image tags:
|
|||
- `SKILLHUB_VERSION=edge` for the latest `main` build
|
||||
- `SKILLHUB_VERSION=vX.Y.Z` for a fixed release
|
||||
|
||||
Use the bundled Aliyun mirror shortcut when a nearer registry is configured:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --version edge
|
||||
```
|
||||
|
||||
Start the runtime:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -1,19 +1,24 @@
|
|||
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;
|
||||
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;
|
||||
|
|
@ -32,17 +37,26 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
private final SkillRepository skillRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillGovernanceService skillGovernanceService;
|
||||
private final ReviewService reviewService;
|
||||
private final SkillPublishService skillPublishService;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
public SkillLifecycleController(NamespaceRepository namespaceRepository,
|
||||
SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillGovernanceService skillGovernanceService,
|
||||
ReviewService reviewService,
|
||||
SkillPublishService skillPublishService,
|
||||
AuditLogService auditLogService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillGovernanceService = skillGovernanceService;
|
||||
this.reviewService = reviewService;
|
||||
this.skillPublishService = skillPublishService;
|
||||
this.auditLogService = auditLogService;
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/archive")
|
||||
|
|
@ -108,6 +122,65 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "DELETE_VERSION", version));
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/versions/{version}/withdraw-review")
|
||||
public ApiResponse<SkillLifecycleMutationResponse> 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"));
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/versions/{version}/rerelease")
|
||||
public ApiResponse<SkillLifecycleMutationResponse> rereleaseVersion(@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
@PathVariable String version,
|
||||
@Valid @RequestBody SkillVersionRereleaseRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record SkillVersionRereleaseRequest(
|
||||
@NotBlank(message = "{validation.required}")
|
||||
String targetVersion
|
||||
) {
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -13,16 +13,19 @@ 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;
|
||||
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;
|
||||
|
|
@ -55,6 +58,15 @@ class SkillLifecycleControllerTest {
|
|||
@MockBean
|
||||
private SkillGovernanceService skillGovernanceService;
|
||||
|
||||
@MockBean
|
||||
private ReviewService reviewService;
|
||||
|
||||
@MockBean
|
||||
private SkillPublishService skillPublishService;
|
||||
|
||||
@MockBean
|
||||
private AuditLogService auditLogService;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
|
|
@ -139,6 +151,71 @@ 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"));
|
||||
}
|
||||
|
||||
@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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ public interface SkillRepository {
|
|||
Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
|
||||
List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status);
|
||||
Skill save(Skill skill);
|
||||
void delete(Skill skill);
|
||||
List<Skill> findByOwnerId(String ownerId);
|
||||
void incrementDownloadCount(Long skillId);
|
||||
List<Skill> findBySlug(String slug);
|
||||
|
|
|
|||
|
|
@ -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<SkillFile> 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<SkillVersion> 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)
|
||||
|
|
|
|||
|
|
@ -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<String> platformRoles) {
|
||||
return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, false, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PublishResult rereleasePublishedVersion(
|
||||
Long skillId,
|
||||
String sourceVersion,
|
||||
String targetVersion,
|
||||
String publisherId,
|
||||
Map<Long, NamespaceRole> 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<PackageEntry> 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<PackageEntry> entries,
|
||||
String publisherId,
|
||||
SkillVisibility visibility,
|
||||
Set<String> 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<Long, NamespaceRole> 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<PackageEntry> rebuildEntriesForRerelease(Long skillId, Long versionId, String targetVersion) {
|
||||
List<SkillFile> files = skillFileRepository.findByVersionId(versionId).stream()
|
||||
.sorted(Comparator.comparing(SkillFile::getFilePath))
|
||||
.toList();
|
||||
List<PackageEntry> 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<String, Object> 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<Map<String, Object>> buildManifest(List<PackageEntry> entries) {
|
||||
return entries.stream()
|
||||
.map(entry -> Map.<String, Object>of(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> 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<SkillVersion> 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";
|
||||
|
|
|
|||
|
|
@ -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<Skill> findByOwnerId(String ownerId) {
|
||||
return delegate.findByOwnerId(ownerId);
|
||||
|
|
|
|||
|
|
@ -439,6 +439,25 @@ export const skillLifecycleApi = {
|
|||
headers: await ensureCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async withdrawReview(namespace: string, slug: string, version: string): Promise<void> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${encodeURIComponent(version)}/withdraw-review`, {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise<void> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
await fetchJson<void>(`${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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,12 +418,42 @@
|
|||
"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?",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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,12 +418,42 @@
|
|||
"unarchiveSuccessTitle": "技能已恢复",
|
||||
"unarchiveSuccessDescription": "“{{skill}}”已恢复。",
|
||||
"unarchiveErrorTitle": "恢复技能失败",
|
||||
"withdrawReviewConfirmTitle": "确认撤销审核",
|
||||
"withdrawReviewConfirmDescription": "撤销后,版本 {{version}} 将不再进入审核流程,并从当前技能中移除。",
|
||||
"withdrawReviewSuccessTitle": "已撤销审核",
|
||||
"withdrawReviewSuccessDescription": "版本 {{version}} 已撤销审核。",
|
||||
"withdrawReviewErrorTitle": "撤销审核失败",
|
||||
"deleteVersion": "删除版本",
|
||||
"deleteVersionConfirmTitle": "确认删除版本",
|
||||
"deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?",
|
||||
"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": "举报技能",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
|
|
@ -151,7 +174,27 @@ export function MySkillsPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
{skill.status === 'ARCHIVED' ? (
|
||||
{skill.latestVersionStatus === 'PENDING_REVIEW' && skill.latestVersion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
const pendingVersion = skill.latestVersion
|
||||
if (!pendingVersion) {
|
||||
return
|
||||
}
|
||||
setWithdrawTarget({
|
||||
namespace: skill.namespace,
|
||||
slug: skill.slug,
|
||||
name: skill.displayName,
|
||||
version: pendingVersion,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('mySkills.withdrawReview')}
|
||||
</Button>
|
||||
) : skill.status === 'ARCHIVED' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -227,6 +270,19 @@ export function MySkillsPage() {
|
|||
confirmText={t('mySkills.unarchive')}
|
||||
onConfirm={handleUnarchiveSkill}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!withdrawTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setWithdrawTarget(null)
|
||||
}
|
||||
}}
|
||||
title={t('mySkills.withdrawConfirmTitle')}
|
||||
description={withdrawTarget ? t('mySkills.withdrawConfirmDescription', { skill: withdrawTarget.name }) : ''}
|
||||
confirmText={t('mySkills.withdrawReview')}
|
||||
onConfirm={handleWithdrawSkill}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,13 +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()
|
||||
|
|
@ -43,6 +67,11 @@ export function SkillDetailPage() {
|
|||
const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false)
|
||||
const [unarchiveConfirmOpen, setUnarchiveConfirmOpen] = useState(false)
|
||||
const [deleteVersionTarget, setDeleteVersionTarget] = useState<string | null>(null)
|
||||
const [withdrawVersionTarget, setWithdrawVersionTarget] = useState<string | null>(null)
|
||||
const [rereleaseTarget, setRereleaseTarget] = useState<string | null>(null)
|
||||
const [targetVersionInput, setTargetVersionInput] = useState('')
|
||||
const [diffSourceVersion, setDiffSourceVersion] = useState<string | null>(null)
|
||||
const [diffCompareVersion, setDiffCompareVersion] = useState<string | null>(null)
|
||||
const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' })
|
||||
const { user, hasRole } = useAuth()
|
||||
|
||||
|
|
@ -51,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 = () => {
|
||||
|
|
@ -76,6 +111,8 @@ export function SkillDetailPage() {
|
|||
const archiveMutation = useArchiveSkill()
|
||||
const unarchiveMutation = useUnarchiveSkill()
|
||||
const deleteVersionMutation = useDeleteSkillVersion()
|
||||
const withdrawReviewMutation = useWithdrawSkillReview()
|
||||
const rereleaseVersionMutation = useRereleaseSkillVersion()
|
||||
const reportMutation = useSubmitSkillReport(namespace, slug)
|
||||
|
||||
const handleDownload = () => {
|
||||
|
|
@ -150,6 +187,44 @@ 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 {
|
||||
|
|
@ -196,6 +271,63 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6 animate-fade-up">
|
||||
|
|
@ -309,11 +441,34 @@ export function SkillDetailPage() {
|
|||
{version.status}
|
||||
</span>
|
||||
)}
|
||||
{skill.latestVersion === version.version && (
|
||||
<span className="rounded-full bg-primary px-2.5 py-0.5 text-xs text-primary-foreground">
|
||||
{t('skillDetail.currentVersion')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatLocalDateTime(version.publishedAt, i18n.language)}
|
||||
</span>
|
||||
{skill.canManageLifecycle && canRereleaseVersion(version.status) && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenDiff(version.version)}
|
||||
>
|
||||
{t('skillDetail.compareVersions')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenRerelease(version.version)}
|
||||
>
|
||||
{t('skillDetail.rereleaseVersion')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{skill.canManageLifecycle && canDeleteVersion(version.status) && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -323,6 +478,15 @@ export function SkillDetailPage() {
|
|||
{t('skillDetail.deleteVersion')}
|
||||
</Button>
|
||||
)}
|
||||
{skill.canManageLifecycle && canWithdrawVersion(version.status) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setWithdrawVersionTarget(version.version)}
|
||||
>
|
||||
{t('skillDetail.withdrawReview')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{version.changelog && (
|
||||
|
|
@ -523,6 +687,145 @@ export function SkillDetailPage() {
|
|||
variant="destructive"
|
||||
onConfirm={handleDeleteVersion}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!withdrawVersionTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setWithdrawVersionTarget(null)
|
||||
}
|
||||
}}
|
||||
title={t('skillDetail.withdrawReviewConfirmTitle')}
|
||||
description={withdrawVersionTarget ? t('skillDetail.withdrawReviewConfirmDescription', { version: withdrawVersionTarget }) : ''}
|
||||
confirmText={t('skillDetail.withdrawReview')}
|
||||
onConfirm={handleWithdrawVersion}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={!!rereleaseTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setRereleaseTarget(null)
|
||||
setTargetVersionInput('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('skillDetail.rereleaseDialogTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{rereleaseTarget ? t('skillDetail.rereleaseDialogDescription', { version: rereleaseTarget }) : ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">{t('skillDetail.rereleaseSourceVersion')}</div>
|
||||
<div className="rounded-lg border border-border/60 bg-secondary/30 px-3 py-2 font-mono text-sm text-foreground">
|
||||
{rereleaseTarget ? `v${rereleaseTarget}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">{t('skillDetail.rereleaseTargetVersion')}</div>
|
||||
<Input value={targetVersionInput} onChange={(event) => setTargetVersionInput(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRereleaseTarget(null)}>
|
||||
{t('dialog.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleRereleaseVersion} disabled={rereleaseVersionMutation.isPending || !targetVersionInput.trim()}>
|
||||
{rereleaseVersionMutation.isPending ? t('skillDetail.processing') : t('skillDetail.rereleaseVersion')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!diffSourceVersion && !!diffCompareVersion}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDiffSourceVersion(null)
|
||||
setDiffCompareVersion(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('skillDetail.compareDialogTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{diffSourceVersion && diffCompareVersion
|
||||
? t('skillDetail.compareDialogDescription', { source: diffSourceVersion, target: diffCompareVersion })
|
||||
: ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="rounded-lg border border-border/60 p-3">
|
||||
<div className="text-muted-foreground">{t('skillDetail.compareSourceLabel')}</div>
|
||||
<div className="mt-1 font-mono text-foreground">v{diffSourceVersion}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 p-3">
|
||||
<div className="text-muted-foreground">{t('skillDetail.compareTargetLabel')}</div>
|
||||
<div className="mt-1 font-mono text-foreground">v{diffCompareVersion}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold text-foreground">{t('skillDetail.metadataChanges')}</div>
|
||||
{metadataDiffEntries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{metadataDiffEntries.map((entry) => (
|
||||
<div key={entry.key} className="rounded-lg border border-border/60 p-3 text-sm">
|
||||
<div className="font-medium text-foreground">{entry.key}</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{String(entry.source ?? '—')} → {String(entry.target ?? '—')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">{t('skillDetail.noMetadataChanges')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold text-foreground">{t('skillDetail.readmeChange')}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{diffSourceReadme !== diffCompareReadme ? t('skillDetail.readmeChanged') : t('skillDetail.readmeUnchanged')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold text-foreground">{t('skillDetail.fileChanges')}</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div className="rounded-lg border border-border/60 p-3">
|
||||
<div className="text-muted-foreground">{t('skillDetail.filesAdded')}</div>
|
||||
<div className="mt-1 font-semibold text-foreground">{fileDiffSummary.added.length}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 p-3">
|
||||
<div className="text-muted-foreground">{t('skillDetail.filesRemoved')}</div>
|
||||
<div className="mt-1 font-semibold text-foreground">{fileDiffSummary.removed.length}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 p-3">
|
||||
<div className="text-muted-foreground">{t('skillDetail.filesChanged')}</div>
|
||||
<div className="mt-1 font-semibold text-foreground">{fileDiffSummary.changed.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDiffSourceVersion(null)
|
||||
setDiffCompareVersion(null)
|
||||
}}
|
||||
>
|
||||
{t('dialog.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SkillFile[]>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/files`)
|
||||
}
|
||||
|
||||
async function getSkillVersionDetail(namespace: string, slug: string, version: string): Promise<SkillVersionDetail> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
return fetchJson<SkillVersionDetail>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}`)
|
||||
}
|
||||
|
||||
async function getSkillReadme(namespace: string, slug: string, version: string): Promise<string> {
|
||||
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'],
|
||||
|
|
@ -218,3 +231,33 @@ 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue