feat(skill): add version management APIs

This commit is contained in:
yun-zhi-ztl 2026-03-15 19:45:10 +08:00
parent 3f82a88c4e
commit 4ad97aa55a
5 changed files with 141 additions and 1 deletions

View file

@ -12,10 +12,13 @@ import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.dto.AdminSkillActionRequest;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest;
import jakarta.validation.Valid;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
import org.springframework.web.bind.annotation.DeleteMapping;
@ -35,6 +38,7 @@ public class SkillLifecycleController extends BaseApiController {
private final SkillVersionRepository skillVersionRepository;
private final SkillGovernanceService skillGovernanceService;
private final ReviewService reviewService;
private final SkillPublishService skillPublishService;
private final AuditLogService auditLogService;
public SkillLifecycleController(NamespaceRepository namespaceRepository,
@ -42,6 +46,7 @@ public class SkillLifecycleController extends BaseApiController {
SkillVersionRepository skillVersionRepository,
SkillGovernanceService skillGovernanceService,
ReviewService reviewService,
SkillPublishService skillPublishService,
AuditLogService auditLogService,
ApiResponseFactory responseFactory) {
super(responseFactory);
@ -50,6 +55,7 @@ public class SkillLifecycleController extends BaseApiController {
this.skillVersionRepository = skillVersionRepository;
this.skillGovernanceService = skillGovernanceService;
this.reviewService = reviewService;
this.skillPublishService = skillPublishService;
this.auditLogService = auditLogService;
}
@ -141,6 +147,40 @@ public class SkillLifecycleController extends BaseApiController {
new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "WITHDRAW_REVIEW", "DELETED"));
}
@PostMapping("/{namespace}/{slug}/versions/{version}/rerelease")
public ApiResponse<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)

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record SkillVersionRereleaseRequest(
@NotBlank(message = "{validation.required}")
String targetVersion
) {
}

View file

@ -25,6 +25,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -60,6 +61,9 @@ class SkillLifecycleControllerTest {
@MockBean
private ReviewService reviewService;
@MockBean
private SkillPublishService skillPublishService;
@MockBean
private AuditLogService auditLogService;
@ -173,6 +177,45 @@ class SkillLifecycleControllerTest {
.andExpect(jsonPath("$.data.status").value("DELETED"));
}
@Test
void rereleaseVersion_returnsUnifiedEnvelope() throws Exception {
Namespace namespace = new Namespace("global", "Global", "owner");
setNamespaceId(namespace, 1L);
Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC);
setSkillId(skill, 1L);
SkillVersion newVersion = new SkillVersion(1L, "1.2.4", "owner");
setSkillVersionId(newVersion, 3L);
newVersion.setStatus(SkillVersionStatus.PUBLISHED);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner");
setSkillVersionId(sourceVersion, 2L);
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.3")).willReturn(java.util.Optional.of(sourceVersion));
given(skillPublishService.rereleasePublishedVersion(
eq(1L),
eq("1.2.3"),
eq("1.2.4"),
eq("usr_1"),
anyMap()))
.willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion));
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease")
.requestAttr("userId", "usr_1")
.requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetVersion\":\"1.2.4\"}")
.with(user("usr_1"))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.skillId").value(1))
.andExpect(jsonPath("$.data.versionId").value(3))
.andExpect(jsonPath("$.data.action").value("RERELEASE_VERSION"))
.andExpect(jsonPath("$.data.status").value("PUBLISHED"));
}
private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) {
skill.setStatus(status);
return skill;

View file

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

View file

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