From 441dc9e0e724d9597c6f9fc91814b80ccc8babd9 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 7 Apr 2026 16:24:15 +0800 Subject: [PATCH 01/26] fix(security): close unauthorized metrics and compat access paths --- docker-compose.yml | 2 +- .../compat/ClawHubCompatAppService.java | 38 +++++-- .../compat/ClawHubCompatController.java | 11 +- .../compat/ClawHubRegistryFacade.java | 3 +- .../compat/CompatSkillLookupService.java | 26 ++++- .../portal/NamespaceController.java | 8 +- .../NamespacePortalQueryAppService.java | 37 ++++++- .../src/main/resources/application.yml | 4 +- .../compat/ClawHubCompatAppServiceTest.java | 80 ++++++++++++++ .../ClawHubCompatControllerSecurityTest.java | 102 ++++++++++++++++++ .../compat/CompatSkillLookupServiceTest.java | 78 ++++++++++++++ .../NamespacePortalControllerTest.java | 13 +-- .../metrics/PrometheusEndpointTest.java | 5 +- .../NamespacePortalQueryAppServiceTest.java | 36 +++++++ .../policy/RouteSecurityPolicyRegistry.java | 8 +- .../RouteSecurityPolicyRegistryTest.java | 15 +++ 16 files changed, 425 insertions(+), 41 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java diff --git a/docker-compose.yml b/docker-compose.yml index 7bf18ffc4..77dc264b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: redis: image: ${REDIS_IMAGE:-redis:7-alpine} ports: - - "6379:6379" + - "127.0.0.1:6379:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java index 051e95669..4a22dd53f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java @@ -94,7 +94,8 @@ public class ClawHubCompatAppService { String hash, String userId, Map userNsRoles) { - SkillCoordinate coord = resolveQueryCoordinate(slug); + SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles); + Map roles = normalizeRoles(userNsRoles); SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( coord.namespace(), @@ -103,7 +104,7 @@ public class ClawHubCompatAppService { "latest".equals(version) ? "latest" : null, hash, userId, - userNsRoles != null ? userNsRoles : Map.of() + roles ); return toResolveResponse(resolved); } @@ -132,23 +133,37 @@ public class ClawHubCompatAppService { : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; } - public String downloadLocationByQuery(String slug, String version) { - SkillCoordinate coord = resolveQueryCoordinate(slug); + public String downloadLocationByQuery(String slug, + String version, + String userId, + Map userNsRoles) { + SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles); return "latest".equals(version) ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; } - private SkillCoordinate resolveQueryCoordinate(String slug) { + private SkillCoordinate resolveQueryCoordinate(String slug, + String userId, + Map userNsRoles) { if (slug != null && slug.contains("--")) { return mapper.fromCanonical(slug); } + CompatSkillLookupService.CompatSkillContext context; try { - CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug); - return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug()); + context = compatSkillLookupService.findByLegacySlug(slug); } catch (DomainNotFoundException ex) { return mapper.fromCanonical(slug); } + Map roles = normalizeRoles(userNsRoles); + if (!compatSkillLookupService.canAccess(context.skill(), userId, roles)) { + throw new DomainNotFoundException("error.skill.notFound", slug); + } + return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug()); + } + + private Map normalizeRoles(Map userNsRoles) { + return userNsRoles != null ? userNsRoles : Map.of(); } public ClawHubSkillListResponse listSkills(int page, @@ -182,11 +197,18 @@ public class ClawHubCompatAppService { } public ClawHubSkillResponse getSkill(String canonicalSlug, String userId) { + return getSkill(canonicalSlug, userId, Map.of()); + } + + public ClawHubSkillResponse getSkill(String canonicalSlug, + String userId, + Map userNsRoles) { SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible( coord.namespace(), coord.slug(), - userId + userId, + userNsRoles != null ? userNsRoles : Map.of() ); SkillVersion latestVersionEntity = context.latestVersion().orElse(null); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index a66a7f0d2..82c2d5591 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -82,8 +82,10 @@ public class ClawHubCompatController { @RateLimit(category = "download", authenticated = 60, anonymous = 20) @GetMapping("/download") public ResponseEntity downloadByQuery(@RequestParam String slug, - @RequestParam(defaultValue = "latest") String version) { - return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version)); + @RequestParam(defaultValue = "latest") String version, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version, userId, userNsRoles)); } @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @@ -99,8 +101,9 @@ public class ClawHubCompatController { @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @GetMapping("/skills/{canonicalSlug}") public ClawHubSkillResponse getSkill(@PathVariable String canonicalSlug, - @RequestAttribute(value = "userId", required = false) String userId) { - return clawHubCompatAppService.getSkill(canonicalSlug, userId); + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return clawHubCompatAppService.getSkill(canonicalSlug, userId, userNsRoles); } @RateLimit(category = "skills", authenticated = 60, anonymous = 20) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java index 68dd32fc0..8c02af39b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java @@ -83,7 +83,8 @@ public class ClawHubRegistryFacade { CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible( coordinate.namespace(), coordinate.slug(), - userId + userId, + normalizeRoles(userNsRoles) ); Skill skill = context.skill(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java index a151ed8f6..9da699a24 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java @@ -1,13 +1,16 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; 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.VisibilityChecker; import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import java.util.Map; import java.util.Optional; import org.springframework.stereotype.Service; @@ -22,15 +25,18 @@ public class CompatSkillLookupService { private final NamespaceRepository namespaceRepository; private final SkillVersionRepository skillVersionRepository; private final SkillSlugResolutionService skillSlugResolutionService; + private final VisibilityChecker visibilityChecker; public CompatSkillLookupService(SkillRepository skillRepository, NamespaceRepository namespaceRepository, SkillVersionRepository skillVersionRepository, - SkillSlugResolutionService skillSlugResolutionService) { + SkillSlugResolutionService skillSlugResolutionService, + VisibilityChecker visibilityChecker) { this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.skillVersionRepository = skillVersionRepository; this.skillSlugResolutionService = skillSlugResolutionService; + this.visibilityChecker = visibilityChecker; } public CompatSkillContext findByLegacySlug(String slug) { @@ -41,10 +47,28 @@ public class CompatSkillLookupService { return new CompatSkillContext(namespace, skill, findLatestVersion(skill)); } + public boolean canAccess(Skill skill, String currentUserId, Map userNsRoles) { + if (skill == null) { + return false; + } + Map roles = userNsRoles != null ? userNsRoles : Map.of(); + return visibilityChecker.canAccess(skill, currentUserId, roles); + } + public CompatSkillContext resolveVisible(String namespaceSlug, String skillSlug, String currentUserId) { + return resolveVisible(namespaceSlug, skillSlug, currentUserId, Map.of()); + } + + public CompatSkillContext resolveVisible(String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles) { Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", namespaceSlug)); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + if (!canAccess(skill, currentUserId, userNsRoles)) { + throw new DomainNotFoundException("error.skill.notFound", skillSlug); + } return new CompatSkillContext(namespace, skill, findLatestVersion(skill)); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index e18fa681b..2f2c603ea 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -55,8 +55,10 @@ public class NamespaceController extends BaseApiController { } @GetMapping("/namespaces") - public ApiResponse> listNamespaces(Pageable pageable) { - return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable)); + public ApiResponse> listNamespaces( + Pageable pageable, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles)); } @GetMapping("/me/namespaces") @@ -68,7 +70,7 @@ public class NamespaceController extends BaseApiController { @GetMapping("/namespaces/{slug}") public ApiResponse getNamespace(@PathVariable String slug, - @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { return ok("response.success.read", namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles)); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 17dd9b997..1e0d9486f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.MyNamespaceResponse; import com.iflytek.skillhub.dto.NamespaceResponse; @@ -16,6 +17,8 @@ import java.util.Comparator; import java.util.List; import java.util.Map; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -43,9 +46,31 @@ public class NamespacePortalQueryAppService { } @Transactional(readOnly = true) - public PageResponse listNamespaces(Pageable pageable) { - Page namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable); - return PageResponse.from(namespaces.map(NamespaceResponse::from)); + public PageResponse listNamespaces(Pageable pageable, Map userNamespaceRoles) { + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + if (namespaceRoles.isEmpty()) { + Page empty = new PageImpl<>( + List.of(), + PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()), + 0 + ); + return PageResponse.from(empty); + } + + List scopedNamespaces = namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() + .filter(namespace -> namespace.getStatus() == NamespaceStatus.ACTIVE) + .sorted(Comparator.comparing(Namespace::getSlug)) + .toList(); + int fromIndex = Math.min((int) pageable.getOffset(), scopedNamespaces.size()); + int toIndex = Math.min(fromIndex + pageable.getPageSize(), scopedNamespaces.size()); + Page page = new PageImpl<>( + scopedNamespaces.subList(fromIndex, toIndex).stream() + .map(NamespaceResponse::from) + .toList(), + pageable, + scopedNamespaces.size() + ); + return PageResponse.from(page); } @Transactional(readOnly = true) @@ -66,10 +91,14 @@ public class NamespacePortalQueryAppService { @Transactional(readOnly = true) public NamespaceResponse getNamespace(String slug, String userId, Map userNamespaceRoles) { + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); Namespace namespace = namespaceService.getNamespaceBySlugForRead( slug, userId, - userNamespaceRoles != null ? userNamespaceRoles : Map.of()); + namespaceRoles); + if (!namespaceRoles.containsKey(namespace.getId())) { + throw new DomainForbiddenException("error.namespace.membership.required"); + } return NamespaceResponse.from(namespace); } diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 8a3872afd..f278b6fca 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -171,7 +171,7 @@ management: endpoints: web: exposure: - include: health,info,prometheus,metrics + include: health,info endpoint: health: show-details: when-authorized @@ -180,4 +180,4 @@ management: application: skillhub export: prometheus: - enabled: true + enabled: false diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java new file mode 100644 index 000000000..c5921f59b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java @@ -0,0 +1,80 @@ +package com.iflytek.skillhub.compat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.controller.support.MultipartPackageExtractor; +import com.iflytek.skillhub.controller.support.ZipPackageExtractor; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.social.SkillStarService; +import com.iflytek.skillhub.service.SkillSearchAppService; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ClawHubCompatAppServiceTest { + + private final SkillSearchAppService skillSearchAppService = mock(SkillSearchAppService.class); + private final SkillQueryService skillQueryService = mock(SkillQueryService.class); + private final SkillPublishService skillPublishService = mock(SkillPublishService.class); + private final ZipPackageExtractor zipPackageExtractor = mock(ZipPackageExtractor.class); + private final MultipartPackageExtractor multipartPackageExtractor = mock(MultipartPackageExtractor.class); + private final AuditLogService auditLogService = mock(AuditLogService.class); + private final CompatSkillLookupService compatSkillLookupService = mock(CompatSkillLookupService.class); + private final SkillStarService skillStarService = mock(SkillStarService.class); + + private final ClawHubCompatAppService service = new ClawHubCompatAppService( + new CanonicalSlugMapper(), + skillSearchAppService, + skillQueryService, + skillPublishService, + zipPackageExtractor, + multipartPackageExtractor, + auditLogService, + compatSkillLookupService, + skillStarService + ); + + @Test + void downloadLocationByQuery_throwsNotFound_whenLegacySkillIsPrivateForAnonymousCaller() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE); + CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext( + namespace, + privateSkill, + Optional.empty() + ); + + when(compatSkillLookupService.findByLegacySlug("priv")).thenReturn(context); + when(compatSkillLookupService.canAccess(privateSkill, null, Map.of())).thenReturn(false); + + assertThatThrownBy(() -> service.downloadLocationByQuery("priv", "latest", null, null)) + .isInstanceOf(DomainNotFoundException.class); + } + + @Test + void downloadLocationByQuery_returnsCanonicalPath_whenLegacySkillIsVisible() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + Skill publicSkill = new Skill(1L, "my-skill", "owner-1", SkillVisibility.PUBLIC); + CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext( + namespace, + publicSkill, + Optional.empty() + ); + + when(compatSkillLookupService.findByLegacySlug("my-skill")).thenReturn(context); + when(compatSkillLookupService.canAccess(publicSkill, null, Map.of())).thenReturn(true); + + String location = service.downloadLocationByQuery("my-skill", "latest", null, null); + + assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/download"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java new file mode 100644 index 000000000..eb731e823 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java @@ -0,0 +1,102 @@ +package com.iflytek.skillhub.compat; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ClawHubCompatControllerSecurityTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private ClawHubCompatAppService clawHubCompatAppService; + + @Test + void getSkill_returnsNotFound_whenAnonymousCannotAccessPrivateSkill() throws Exception { + when(clawHubCompatAppService.getSkill(eq("priv"), isNull(), isNull())) + .thenThrow(new DomainNotFoundException("error.skill.notFound", "priv")); + + mockMvc.perform(get("/api/v1/skills/priv")) + .andExpect(status().isNotFound()); + } + + @Test + void getSkill_returnsSkill_whenCallerHasNamespacePermission() throws Exception { + var roles = Map.of(1L, NamespaceRole.ADMIN); + var response = new ClawHubSkillResponse( + new ClawHubSkillResponse.SkillInfo( + "team-ai--priv", + "Private Skill", + "summary", + Map.of(), + Map.of(), + 0L, + 0L + ), + null, + null, + new ClawHubSkillResponse.ModerationInfo(false, false, "clean", new String[0], null, null, null) + ); + when(clawHubCompatAppService.getSkill("team-ai--priv", "admin-1", roles)).thenReturn(response); + + mockMvc.perform(get("/api/v1/skills/team-ai--priv") + .requestAttr("userId", "admin-1") + .requestAttr("userNsRoles", roles)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.skill.slug").value("team-ai--priv")); + + verify(clawHubCompatAppService).getSkill("team-ai--priv", "admin-1", roles); + } + + @Test + void downloadQuery_returnsNotFound_whenAnonymousCannotAccessPrivateLegacySlug() throws Exception { + when(clawHubCompatAppService.downloadLocationByQuery(eq("priv"), eq("latest"), isNull(), isNull())) + .thenThrow(new DomainNotFoundException("error.skill.notFound", "priv")); + + mockMvc.perform(get("/api/v1/download") + .param("slug", "priv") + .param("version", "latest")) + .andExpect(status().isNotFound()); + } + + @Test + void downloadQuery_returnsNotFound_whenUserWithoutNamespaceRoleAccessesPrivateLegacySlug() throws Exception { + when(clawHubCompatAppService.downloadLocationByQuery("priv", "latest", "user-1", Map.of())) + .thenThrow(new DomainNotFoundException("error.skill.notFound", "priv")); + + mockMvc.perform(get("/api/v1/download") + .param("slug", "priv") + .param("version", "latest") + .requestAttr("userId", "user-1") + .requestAttr("userNsRoles", Map.of())) + .andExpect(status().isNotFound()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java new file mode 100644 index 000000000..3500ce04c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java @@ -0,0 +1,78 @@ +package com.iflytek.skillhub.compat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +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.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class CompatSkillLookupServiceTest { + + private final SkillRepository skillRepository = mock(SkillRepository.class); + private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + private final SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + private final SkillSlugResolutionService skillSlugResolutionService = mock(SkillSlugResolutionService.class); + private final VisibilityChecker visibilityChecker = mock(VisibilityChecker.class); + + private final CompatSkillLookupService service = new CompatSkillLookupService( + skillRepository, + namespaceRepository, + skillVersionRepository, + skillSlugResolutionService, + visibilityChecker + ); + + @Test + void resolveVisible_throwsNotFoundWhenCallerCannotAccessSkill() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + ReflectionTestUtils.setField(namespace, "id", 1L); + Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE); + ReflectionTestUtils.setField(privateSkill, "id", 7L); + privateSkill.setLatestVersionId(70L); + + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(skillSlugResolutionService.resolve(1L, "priv", null, SkillSlugResolutionService.Preference.PUBLISHED)) + .thenReturn(privateSkill); + when(visibilityChecker.canAccess(privateSkill, null, Map.of())).thenReturn(false); + + assertThatThrownBy(() -> service.resolveVisible("team-a", "priv", null, Map.of())) + .isInstanceOf(DomainNotFoundException.class); + } + + @Test + void resolveVisible_returnsSkillWhenCallerHasNamespaceAccess() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + ReflectionTestUtils.setField(namespace, "id", 1L); + Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE); + ReflectionTestUtils.setField(privateSkill, "id", 7L); + privateSkill.setLatestVersionId(70L); + + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(skillSlugResolutionService.resolve(1L, "priv", "admin-1", SkillSlugResolutionService.Preference.PUBLISHED)) + .thenReturn(privateSkill); + when(visibilityChecker.canAccess(privateSkill, "admin-1", Map.of(1L, NamespaceRole.ADMIN))).thenReturn(true); + + CompatSkillLookupService.CompatSkillContext result = service.resolveVisible( + "team-a", + "priv", + "admin-1", + Map.of(1L, NamespaceRole.ADMIN) + ); + + assertThat(result.skill().getId()).isEqualTo(7L); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 76663e993..f2cf8a50b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -89,18 +89,9 @@ class NamespacePortalControllerTest { } @Test - void getNamespace_hidesArchivedNamespaceFromAnonymousUsers() throws Exception { - Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); - given(namespaceService.getNamespaceBySlugForRead("team-a", null, Map.of())).willThrow( - new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException( - "error.namespace.slug.notFound", - "team-a" - ) - ); - + void getNamespace_requiresAuthentication() throws Exception { mockMvc.perform(get("/api/v1/namespaces/team-a")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(400)); + .andExpect(status().isUnauthorized()); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java index 13c0be27e..38373f64f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java @@ -35,13 +35,14 @@ class PrometheusEndpointTest { private DeviceAuthService deviceAuthService; @Test - void prometheusEndpoint_exposesCustomMetrics() { + void metricsRegistry_stillRecordsCustomMetrics_whenPrometheusEndpointIsDisabled() { skillHubMetrics.incrementUserRegister(); skillHubMetrics.recordLocalLogin(true); skillHubMetrics.incrementSkillPublish("global", "PENDING_REVIEW"); assertThat(environment.getProperty("management.endpoints.web.exposure.include")) - .contains("prometheus"); + .doesNotContain("prometheus") + .doesNotContain("metrics"); assertThat(meterRegistry.get("skillhub.user.register").counter().count()).isEqualTo(1.0d); assertThat(meterRegistry.get("skillhub.auth.login") .tag("method", "local") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 61e6a458d..5c79f6878 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -13,7 +14,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -60,6 +63,39 @@ class NamespacePortalQueryAppServiceTest { assertThat(response.get(1).currentUserRole()).isEqualTo(NamespaceRole.ADMIN); } + @Test + void listNamespaces_returnsOnlyCurrentUsersActiveNamespaces() { + Namespace teamA = namespace(1L, "team-a"); + Namespace teamB = namespace(2L, "team-b"); + Namespace archived = namespace(3L, "archived"); + archived.setStatus(NamespaceStatus.ARCHIVED); + + when(namespaceRepository.findByIdIn(anyList())).thenReturn(List.of(teamB, archived, teamA)); + + var response = service.listNamespaces( + PageRequest.of(0, 10), + Map.of( + 1L, NamespaceRole.MEMBER, + 2L, NamespaceRole.ADMIN, + 3L, NamespaceRole.OWNER + ) + ); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).slug()).isEqualTo("team-a"); + assertThat(response.items().get(1).slug()).isEqualTo("team-b"); + } + + @Test + void getNamespace_throwsWhenCurrentUserIsNotNamespaceMember() { + Namespace namespace = namespace(1L, "team-a"); + when(namespaceService.getNamespaceBySlugForRead("team-a", "user-1", Map.of())) + .thenReturn(namespace); + + assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of())) + .isInstanceOf(DomainForbiddenException.class); + } + private Namespace namespace(Long id, String slug) { Namespace namespace = new Namespace(slug, slug, "owner-1"); ReflectionTestUtils.setField(namespace, "id", id); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 498d20106..8235032cc 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -71,10 +71,10 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/*"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces/*"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces/*"), RouteAuthorizationPolicy.authenticated(null, "/api/v1/admin/**") ); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 8c227dd31..f6c8d7421 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -58,6 +58,21 @@ class RouteSecurityPolicyRegistryTest { assertTrue(matchedWeb); } + @Test + void authorizationPolicies_shouldRequireAuthenticationForNamespaceDiscovery() { + boolean matchedV1 = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && "/api/v1/namespaces".equals(policy.pattern()) + && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED); + boolean matchedWeb = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && "/api/web/namespaces".equals(policy.pattern()) + && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED); + + assertTrue(matchedV1); + assertTrue(matchedWeb); + } + @Test void shouldIgnoreCsrf_forBearerAndApiPaths() { assertTrue(registry.shouldIgnoreCsrf("/api/v1/admin/users", null)); From 40807e7fa0ad30419f8156e988282598b0e101fc Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 10 Apr 2026 10:12:27 +0800 Subject: [PATCH 02/26] test(app): isolate H2 db per Spring test context --- server/skillhub-app/src/test/resources/application-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index a3eb93b9d..55bd499c7 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -4,7 +4,7 @@ spring: banner-mode: "off" log-startup-info: false datasource: - url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + url: jdbc:h2:mem:testdb-${random.uuid};MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: From 3d1d70ac028b782e0cd83d8460f67ac19770d498 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 10 Apr 2026 10:30:56 +0800 Subject: [PATCH 03/26] fix(review): allow namespace admins to review own submissions --- .../review/ReviewPermissionChecker.java | 7 ++++- .../review/ReviewPermissionCheckerTest.java | 26 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index bc5dae42c..773992470 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -28,7 +28,12 @@ public class ReviewPermissionChecker { Map userNamespaceRoles, Set platformRoles) { if (task.getSubmittedBy().equals(userId)) { - return platformRoles.contains("SUPER_ADMIN"); + if (platformRoles.contains("SUPER_ADMIN")) { + return true; + } + NamespaceRole role = userNamespaceRoles.get(task.getNamespaceId()); + return hasPlatformReviewRole(platformRoles) + && (role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER); } return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index 21fc18dc1..274d1747c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -26,13 +26,37 @@ class ReviewPermissionCheckerTest { } @Test - void skillAdminCannotReviewOwnSubmission() { + void skillAdminCannotReviewOwnSubmissionWithoutNamespaceRole() { String userId = "user-1"; ReviewTask task = new ReviewTask(1L, 10L, userId); assertFalse(checker.canReview(task, userId, NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN"))); } + @Test + void skillAdminNamespaceAdminCanReviewOwnSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 10L, userId); + assertTrue(checker.canReview(task, userId, + NamespaceType.TEAM, Map.of(10L, NamespaceRole.ADMIN), Set.of("SKILL_ADMIN"))); + } + + @Test + void skillAdminNamespaceOwnerCanReviewOwnSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 10L, userId); + assertTrue(checker.canReview(task, userId, + NamespaceType.TEAM, Map.of(10L, NamespaceRole.OWNER), Set.of("SKILL_ADMIN"))); + } + + @Test + void skillAdminNamespaceMemberCannotReviewOwnSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 10L, userId); + assertFalse(checker.canReview(task, userId, + NamespaceType.TEAM, Map.of(10L, NamespaceRole.MEMBER), Set.of("SKILL_ADMIN"))); + } + @Test void superAdminCannotReviewOwnSubmission() { String userId = "user-1"; From f55c520ebee809e0143d06cd6d92e0f470de3441 Mon Sep 17 00:00:00 2001 From: xiose Date: Mon, 13 Apr 2026 09:26:01 +0800 Subject: [PATCH 04/26] feat(skill): add UPLOADED status for PRIVATE skill lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add UPLOADED status for PRIVATE skills after security scan passes - PRIVATE skill owners can test before confirming publish or submitting for review - Rerelease now follows visibility rules (PRIVATE→UPLOADED, PUBLIC→PENDING_REVIEW) - Auto-withdraw changes status to UPLOADED (not DRAFT) to keep versions visible ## Changes - SkillVersionStatus: Add UPLOADED enum value - SkillPublishService: PRIVATE skills go to UPLOADED after scan - SecurityScanService: Visibility-based status transition after scan - SkillGovernanceService: Withdraw→UPLOADED, delete allows UPLOADED - SkillQueryService: Include UPLOADED in version list filters - SkillReviewSubmitService: New service for submit-review and confirm-publish - SkillLifecycleController: Add submit-review and confirm-publish endpoints - Frontend: Add buttons, dialogs, and hooks for new operations ## Workflow - PRIVATE: Publish → SCANNING → UPLOADED → confirm-publish → PUBLISHED - PUBLIC: Publish → SCANNING → PENDING_REVIEW → PUBLISHED --- README.md | 5 +- README_zh.md | 4 +- docs/oss-01-core-contract-freeze.md | 460 ++++++++++++ docs/oss-02-core-semantic-rules.md | 663 ++++++++++++++++++ .../portal/SkillLifecycleController.java | 37 + .../skillhub/dto/ConfirmPublishRequest.java | 11 + .../skillhub/dto/SubmitReviewRequest.java | 16 + .../service/GovernanceWorkflowAppService.java | 32 + .../service/SkillLifecycleAppService.java | 72 ++ .../src/main/resources/messages.properties | 5 + .../src/main/resources/messages_zh.properties | 5 + .../service/SkillLifecycleAppServiceTest.java | 3 + .../domain/security/SecurityScanService.java | 8 +- .../domain/skill/SkillVersionStatus.java | 1 + .../skill/service/SkillDownloadService.java | 29 +- .../skill/service/SkillGovernanceService.java | 5 +- .../skill/service/SkillPublishService.java | 36 +- .../skill/service/SkillQueryService.java | 11 +- .../service/SkillReviewSubmitService.java | 153 ++++ .../service/SkillGovernanceServiceTest.java | 4 +- .../service/SkillPublishServiceTest.java | 86 ++- .../service/SkillReviewSubmitServiceTest.java | 220 ++++++ web/src/api/client.ts | 30 + web/src/i18n/locales/en.json | 13 + web/src/i18n/locales/zh.json | 14 + web/src/pages/dashboard/my-skills.tsx | 6 + web/src/pages/skill-detail.test.tsx | 2 + web/src/pages/skill-detail.tsx | 87 ++- web/src/shared/hooks/use-skill-queries.ts | 38 + 29 files changed, 2019 insertions(+), 37 deletions(-) create mode 100644 docs/oss-01-core-contract-freeze.md create mode 100644 docs/oss-02-core-semantic-rules.md create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitReviewRequest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java diff --git a/README.md b/README.md index cf803028c..baa7d8e0b 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ The `--public-url` parameter sets the public access URL for your SkillHub instan **For users in China (Aliyun mirror):** ```bash -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest ``` If deployment runs into problems, clear the existing runtime home and retry. @@ -195,7 +195,7 @@ Published images target both `linux/amd64` and `linux/arm64`. curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com # Aliyun mirror (recommended for users in China) -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest ``` **Deployment parameters:** @@ -222,6 +222,7 @@ cp .env.release.example .env.release Recommended image tags: +- `SKILLHUB_VERSION=latest` for the latest stable release (default) - `SKILLHUB_VERSION=edge` for the latest `main` build - `SKILLHUB_VERSION=vX.Y.Z` for a fixed release diff --git a/README_zh.md b/README_zh.md index edb58fc82..8a7c74094 100644 --- a/README_zh.md +++ b/README_zh.md @@ -67,7 +67,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u **国内用户(阿里云镜像):** ```bash -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest ``` 如果部署遇到问题,请清除现有的运行时目录并重试。 @@ -177,7 +177,7 @@ skillhub/ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com # 阿里云镜像(国内推荐) -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest ``` ### 配置参数说明 diff --git a/docs/oss-01-core-contract-freeze.md b/docs/oss-01-core-contract-freeze.md new file mode 100644 index 000000000..617f705c0 --- /dev/null +++ b/docs/oss-01-core-contract-freeze.md @@ -0,0 +1,460 @@ +# OSS-01 Core 契约审计与冻结 + +## 1. 审计结论 + +SkillHub 开源项目已具备 AstronClaw 主链路所需的绝大部分 Core 能力。现有接口覆盖了 skill 唯一标识查询、版本元数据查询、创建(发布)和删除。**无需在开源 Core 中新增 AstronClaw 专属接口**;对 AstronClaw 而言,查询类和主链路类能力都应统一由 SaaS 层 `AstronClaw Adapter` 封装后对外提供,而不是直接绑定开源 Core 的接口形态。 + +--- + +## 2. Core 接口清单 + +以下接口构成 Core 基线能力,供 SaaS 层统一封装后对 AstronClaw 提供;这些接口本身不应被视为 AstronClaw 的长期直接契约。 + +### 2.1 skill 唯一标识与详情查询 + +| 接口 | 路径 | 说明 | +|------|------|------| +| skill 详情 | `GET /api/v1/skills/{namespace}/{slug}` | 返回 `SkillDetailResponse`,包含完整 identity 和状态 | +| 版本解析 | `GET /api/v1/skills/{namespace}/{slug}/resolve?version=&tag=&hash=` | 返回 `ResolveVersionResponse`,解析人类可读版本选择器到精确版本 | + +### 2.2 指定版本安装元数据查询 + +| 接口 | 路径 | 说明 | +|------|------|------| +| 版本详情 | `GET /api/v1/skills/{namespace}/{slug}/versions/{version}` | 返回 `SkillVersionDetailResponse`,含 metadata 和 manifest | +| 版本文件列表 | `GET /api/v1/skills/{namespace}/{slug}/versions/{version}/files` | 返回 `List` | +| 版本下载 | `GET /api/v1/skills/{namespace}/{slug}/versions/{version}/download` | 下载指定版本 bundle | +| 版本列表 | `GET /api/v1/skills/{namespace}/{slug}/versions?page=&size=` | 分页返回版本列表 | + +### 2.3 创建(发布)个人 skill + +| 接口 | 路径 | 说明 | +|------|------|------| +| 发布 skill | `POST /api/v1/skills/{namespace}/publish` | 上传包并发布,返回 `PublishResponse` | + +### 2.4 删除个人 skill + +| 接口 | 路径 | 说明 | +|------|------|------| +| 硬删除(by ID) | `DELETE /api/v1/skills/id/{skillId}` | 需 SUPER_ADMIN 权限 | +| 硬删除(by 坐标) | `DELETE /api/v1/skills/{namespace}/{slug}` | 需 SUPER_ADMIN 权限 | +| 归档 | `POST /api/v1/skills/{namespace}/{slug}/archive` | owner 或 namespace admin 可操作 | +| 取消归档 | `POST /api/v1/skills/{namespace}/{slug}/unarchive` | 恢复为 ACTIVE | + +### 2.5 版本生命周期 + +| 接口 | 路径 | 说明 | +|------|------|------| +| 删除版本 | `DELETE /api/v1/skills/{namespace}/{slug}/versions/{version}` | 仅 DRAFT/REJECTED/SCAN_FAILED 可删 | +| 撤回审核 | `POST /api/v1/skills/{namespace}/{slug}/versions/{version}/withdraw-review` | PENDING_REVIEW → DRAFT | +| 重新发布 | `POST /api/v1/skills/{namespace}/{slug}/versions/{version}/rerelease` | 重新发布版本 | + +### 2.6 ClawHub 兼容接口(已有) + +| 接口 | 路径 | 说明 | +|------|------|------| +| 解析 skill | `GET /api/v1/resolve?slug=&version=` | ClawHub 协议兼容 | +| 解析 skill(路径) | `GET /api/v1/resolve/{canonicalSlug}?version=` | ClawHub 协议兼容 | +| 下载 | `GET /api/v1/download/{canonicalSlug}?version=` | 302 重定向到下载地址 | +| 删除 skill | `DELETE /api/v1/skills/{canonicalSlug}` | owner 可操作 | +| 取消删除 | `POST /api/v1/skills/{canonicalSlug}/undelete` | owner 可操作 | +| 发布 skill | `POST /api/v1/skills` | ClawHub 协议兼容 | +| 发布到 namespace | `POST /api/v1/publish` | ClawHub 协议兼容 | + +--- + +## 3. 字段语义冻结表 + +### 3.1 Skill Identity 字段 + +| 字段 | 类型 | 含义 | 稳定性 | 说明 | +|------|------|------|--------|------| +| `skill.id` | Long | skill 全局唯一主键 | 不可变 | 自增,创建后永不改变,可作为外部映射主键 | +| `namespace` (slug) | String(64) | skill 所属命名空间标识 | 不可变 | 全局唯一,创建后不可改名 | +| `skill.slug` | String(100) | skill 在 namespace 内的唯一标识 | 不可变 | 创建后不可改名,`namespace + slug` 构成业务坐标 | +| `skill.displayName` | String(200) | skill 展示名称 | 可变 | 仅用于展示,不可作为映射依据 | +| `skill.ownerId` | String | skill 创建者 ID | 不可变 | 创建时绑定,不可转移 | +| `skill.summary` | String(TEXT) | skill 简介 | 可变 | 展示用 | +| `skill.visibility` | Enum | 可见性 | 可变 | `PUBLIC` / `NAMESPACE_ONLY` / `PRIVATE` | +| `skill.status` | Enum | skill 状态 | 可变 | `ACTIVE` / `HIDDEN` / `ARCHIVED` | +| `skill.hidden` | boolean | 是否被管理员隐藏 | 可变 | 与 status 独立的隐藏标记 | +| `skill.latestVersionId` | Long | 最新版本指针 | 可变 | 指向当前最新已发布版本,yank/删除后自动回退 | +| `skill.downloadCount` | Long | 下载次数 | 可变 | 累计值 | +| `skill.starCount` | Integer | 收藏数 | 可变 | 累计值 | + +### 3.2 SkillVersion 字段 + +| 字段 | 类型 | 含义 | 稳定性 | 说明 | +|------|------|------|--------|------| +| `version.id` | Long | 版本全局唯一主键 | 不可变 | 自增 | +| `version.skillId` | Long | 所属 skill ID | 不可变 | 外键 | +| `version.version` | String(64) | 版本号 | 不可变 | 如 `1.0.0`,创建后不可改 | +| `version.status` | Enum | 版本状态 | 可变 | 见状态语义表 | +| `version.bundleReady` | boolean | bundle 是否可用 | 可变 | `true` 表示 bundle 已构建完成,可下载安装 | +| `version.downloadReady` | boolean | 是否允许下载 | 可变 | yank 后设为 `false` | +| `version.publishedAt` | Instant | 发布时间 | 一次写入 | 首次发布时设置 | +| `version.parsedMetadataJson` | JSONB | 解析后的元数据 | 一次写入 | 包含 `package_name` 等运行时信息 | +| `version.manifestJson` | JSONB | manifest 原始内容 | 一次写入 | skill 包的 manifest | +| `version.changelog` | String(TEXT) | 变更日志 | 可变 | 展示用 | +| `version.fileCount` | Integer | 文件数量 | 一次写入 | 发布时确定 | +| `version.totalSize` | Long | 总大小(字节) | 一次写入 | 发布时确定 | +| `version.yankedAt` | Instant | yank 时间 | 一次写入 | yank 时设置 | +| `version.yankReason` | String(TEXT) | yank 原因 | 一次写入 | yank 时设置 | + +### 3.3 关键字段含义冻结 + +| 字段 | 冻结定义 | +|------|----------| +| `skill_id` | `skill.id`,Long 类型自增主键,全局唯一,创建后不可变。AstronClaw 应以此作为 `external_skill_mapping` 的外部主键 | +| `namespace` | `namespace.slug`,String(64),全局唯一,不可改名。与 `slug` 组合构成业务坐标 | +| `slug` | `skill.slug`,String(100),namespace 内唯一,不可改名。`namespace/slug` 是人类可读的稳定坐标 | +| `version` | `skill_version.version`,String(64),同一 skill 内唯一,不可改。如 `1.0.0` | +| `bundle_url` | 通过 `GET /{namespace}/{slug}/versions/{version}/download` 获取,或通过 `resolve` 接口的 `downloadUrl` 字段获取。不是数据库字段,而是动态生成的下载地址 | +| `bundle_ready` | `skill_version.bundleReady`,boolean。`true` 表示 bundle 已构建完成可安装。AstronClaw 安装前必须校验此字段 | +| `package_name` | 存储在 `skill_version.parsedMetadataJson` 中,从 skill 包的 manifest 解析而来。同一 skill 跨版本应保持稳定。AstronClaw 用于运行时安装/卸载标识 | + +### 3.4 Namespace 字段 + +| 字段 | 类型 | 含义 | 稳定性 | +|------|------|------|--------| +| `namespace.id` | Long | 命名空间主键 | 不可变 | +| `namespace.slug` | String(64) | 命名空间标识 | 不可变,全局唯一 | +| `namespace.displayName` | String(128) | 展示名称 | 可变 | +| `namespace.type` | Enum | 类型 | 不可变,`GLOBAL` / `TEAM` | +| `namespace.status` | Enum | 状态 | 可变,`ACTIVE` / `FROZEN` / `ARCHIVED` | + +--- + +## 4. 状态语义冻结表 + +### 4.1 Skill 状态(`SkillStatus`) + +| 状态 | 市场可见 | 可新装 | 已装是否保留 | 可被 owner 操作 | 说明 | +|------|----------|--------|------------|----------------|------| +| `ACTIVE` | 是(受 visibility 控制) | 是(需有 PUBLISHED 版本) | 是 | 是 | 正常状态 | +| `HIDDEN` | 否 | 否 | 是 | 受限 | 管理员隐藏,独立于 status 的 `hidden` 标记 | +| `ARCHIVED` | 否 | 否 | 是 | 可取消归档 | owner 或 namespace admin 归档 | + +### 4.2 版本状态(`SkillVersionStatus`) + +| 状态 | 是否允许安装 | 是否允许下载 | 市场可见 | 可转换到 | 说明 | +|------|------------|------------|---------|---------|------| +| `DRAFT` | 否 | 否 | 否 | SCANNING, 可删除 | 初始状态,编辑中 | +| `SCANNING` | 否 | 否 | 否 | SCAN_FAILED, PENDING_REVIEW, PUBLISHED | 安全扫描中 | +| `SCAN_FAILED` | 否 | 否 | 否 | 可删除 | 安全扫描失败 | +| `PENDING_REVIEW` | 否 | 否 | 否 | PUBLISHED, REJECTED, → DRAFT(撤回) | 等待审核 | +| `PUBLISHED` | 是 | 是 | 是 | YANKED | 已发布,可安装 | +| `REJECTED` | 否 | 否 | 否 | 可删除 | 审核拒绝 | +| `YANKED` | 否 | 否 | 否(或弱可见) | 不可逆 | 已撤回,已装不受影响 | + +### 4.3 可见性(`SkillVisibility`) + +| 可见性 | 市场列表可见 | 谁可查看 | 谁可安装 | +|--------|------------|---------|---------| +| `PUBLIC` | 是 | 所有人 | 所有人(需 PUBLISHED + bundleReady) | +| `NAMESPACE_ONLY` | 否 | namespace 成员 | namespace 成员 | +| `PRIVATE` | 否 | 仅 owner | 仅 owner | + +### 4.4 删除语义 + +| 操作 | 类型 | 可逆 | 数据影响 | 已装实例影响 | +|------|------|------|---------|------------| +| 硬删除 skill | 永久删除 | 否 | 删除所有记录、文件、存储对象,slug 可复用 | 不影响,AstronClaw 已装快照独立 | +| 归档 skill | 状态变更 | 是 | 无数据删除,status → ARCHIVED | 不影响 | +| 隐藏 skill | 标记变更 | 是 | 无数据删除,hidden → true | 不影响 | +| 删除版本 | 永久删除 | 否 | 仅删除 DRAFT/REJECTED/SCAN_FAILED 版本 | 不影响(这些版本未被安装) | +| Yank 版本 | 状态变更 | 否 | status → YANKED,downloadReady → false | 不影响已装实例 | + +### 4.5 AstronClaw 安装判断规则 + +AstronClaw 判断一个 skill 版本是否可安装,需同时满足: + +``` +skill.status == ACTIVE + AND skill.hidden == false + AND skill.visibility 允许当前用户访问 + AND version.status == PUBLISHED + AND version.bundleReady == true +``` + +已安装实例不受后续状态变更影响。即使 skill 被删除/归档/隐藏,或版本被 yank,AstronClaw 本地安装快照仍可正常使用和卸载。 + +## 5. 错误语义表 + +### 5.1 统一响应结构 + +```json +{ + "code": 0, + "msg": "操作成功", + "data": { ... }, + "timestamp": "2026-04-10T08:00:00Z", + "requestId": "req-xxx" +} +``` + +- `code = 0` 表示成功 +- `code > 0` 表示错误,值为 HTTP 状态码 + +### 5.2 错误码映射 + +| HTTP 状态码 | 场景 | 异常类型 | 说明 | +|------------|------|---------|------| +| 400 | 参数非法 | `BadRequestException` / `DomainBadRequestException` | 请求参数校验失败 | +| 401 | 未认证 | `UnauthorizedException` / `AuthFlowException` | 未登录或 token 过期 | +| 403 | 无权限 | `ForbiddenException` / `DomainForbiddenException` | 无操作权限 | +| 404 | 未找到 | `DomainNotFoundException` | skill/version/namespace 不存在 | +| 408 | 请求超时 | `AsyncRequestTimeoutException` | 异步请求超时 | +| 503 | 存储不可用 | `StorageAccessException` | 对象存储访问失败 | +| 500 | 服务异常 | `Exception` | 未预期的内部错误 | + +### 5.3 Core 主链路关键错误场景 + +| 场景 | HTTP 状态码 | msg 示例 | AstronClaw 处理建议 | +|------|-----------|---------|-------------------| +| skill 不存在 | 404 | `error.skill.notFound` | 映射失败,提示用户 | +| 版本不存在 | 404 | `error.skill.notFound` | 安装/升级失败,提示用户 | +| 版本不可安装(非 PUBLISHED) | 400 | `error.badRequest` | 拒绝安装,提示版本状态 | +| bundle 未就绪 | 400 | `error.badRequest` | 拒绝安装,提示稍后重试 | +| 无权访问(PRIVATE skill) | 403 | `error.forbidden` | 提示无权限 | +| namespace 不存在 | 404 | `error.namespace.notFound` | 映射失败 | +| 存储服务不可用 | 503 | `error.storage.unavailable` | 降级处理,已装 skill 不受影响 | +| 删除不允许(非 owner) | 403 | `error.forbidden` | 提示无权限 | + +--- + +## 6. Core vs SaaS Adapter 能力分界 + +### 6.1 Core 已满足的能力 + +说明: + +下表表示“开源 Core 已具备、可供 SaaS 封装”的能力,并不表示 AstronClaw 应直接调用这些开源接口。 + +| PRD 需求 | Core 接口 | 满足程度 | 备注 | +|---------|----------|---------|------| +| skill 唯一标识查询 | `GET /{namespace}/{slug}` | 完全满足 | 返回 `id`、`namespace`、`slug` | +| 指定版本安装元数据 | `GET /{namespace}/{slug}/versions/{version}` | 基本满足 | 返回 status、metadata;`package_name` 在 `parsedMetadataJson` 中 | +| 版本解析 | `GET /{namespace}/{slug}/resolve` | 完全满足 | 支持 version/tag/hash 解析 | +| bundle 下载 | `GET /{namespace}/{slug}/versions/{version}/download` | 完全满足 | 直接下载 | +| 创建(发布)个人 skill | `POST /{namespace}/publish` | 完全满足 | 返回 skillId、namespace、slug、version、status | +| 删除个人 skill | `DELETE /{namespace}/{slug}` (ClawHub 兼容) | 完全满足 | owner 可操作 | +| 归档 skill | `POST /{namespace}/{slug}/archive` | 完全满足 | 可逆操作 | +| 版本状态查询 | `GET /{namespace}/{slug}` 中的 headlineVersion/publishedVersion | 完全满足 | 包含版本状态 | +| labels 数据 | `GET /{namespace}/{slug}` 中的 labels 字段 | 完全满足 | 返回 `List` | + +### 6.2 需要 SaaS Adapter 新增的能力 + +| PRD 需求 | 原因 | Adapter 建议 | +|---------|------|-------------| +| 市场列表查询(搜索/过滤/排序) | Core 不提供面向页面的聚合列表 | `GET /api/v1/astronclaw/adapter/skills/market` | +| 市场详情(AstronClaw DTO) | Core 返回的 DTO 包含 Core 内部字段,需适配 | `GET /api/v1/astronclaw/adapter/skills/{id}` | +| owner 维度"我创建的"查询 | Core 的 `/me/skills` 返回 Core DTO,需适配 | `GET /api/v1/astronclaw/adapter/skills/mine` | +| `is_installed` 补全 | 安装关系在 AstronClaw 侧 | AstronClaw 本地补全,不在 Adapter | +| `package_name` 顶层字段 | 当前在 `parsedMetadataJson` 内,需提取 | Adapter 解析 JSON 后平铺返回 | +| `bundle_url` 直接返回 | 当前需通过 download 接口获取 | Adapter 可直接返回预签名 URL | +| 统一 `can_install` 判断 | 需组合 status + visibility + bundleReady | Adapter 计算后返回布尔值 | +| 统一 `can_delete` 判断 | 需组合 owner + status | Adapter 计算后返回布尔值 | + +### 6.3 分界原则 + +``` +Core 负责:skill 生命周期真相(identity、version、status、artifact) +Adapter 负责:面向 AstronClaw 的 DTO 适配(字段平铺、状态聚合、权限预判断) +``` + +补充原则: + +1. 即使开源 `Core` 已经具备某项主链路能力,`AstronClaw` 仍应统一通过 SaaS Adapter 消费。 +2. 该原则同时适用于唯一标识查询、版本元数据、创建个人 skill、删除个人 skill。 +3. 开源文档中的接口清单用于说明 `Core` 能力边界,不应被解读为 AstronClaw 的直接对接建议。 + +--- + +## 7. 成功 / 失败 / 边界样例 + +### 7.1 查询 skill identity — 成功 + +``` +GET /api/v1/skills/my-namespace/my-skill +``` + +```json +{ + "code": 0, + "data": { + "id": 42, + "slug": "my-skill", + "displayName": "My Skill", + "ownerId": "user-123", + "status": "ACTIVE", + "visibility": "PUBLIC", + "namespace": "my-namespace", + "labels": [{"slug": "nlp", "type": "CATEGORY", "displayName": "NLP"}], + "headlineVersion": {"id": 100, "version": "1.2.0", "status": "PUBLISHED"}, + "publishedVersion": {"id": 100, "version": "1.2.0", "status": "PUBLISHED"} + } +} +``` + +AstronClaw 映射关键字段:`id=42`,`namespace=my-namespace`,`slug=my-skill`。 + +### 7.2 查询 skill identity — 不存在 + +``` +GET /api/v1/skills/my-namespace/nonexistent +``` + +```json +{ + "code": 404, + "msg": "Skill not found", + "data": null +} +``` + +### 7.3 查询指定版本元数据 — 成功 + +``` +GET /api/v1/skills/my-namespace/my-skill/versions/1.2.0 +``` + +```json +{ + "code": 0, + "data": { + "id": 100, + "version": "1.2.0", + "status": "PUBLISHED", + "changelog": "Bug fixes", + "fileCount": 3, + "totalSize": 102400, + "publishedAt": "2026-04-01T10:00:00Z", + "parsedMetadataJson": "{\"name\":\"my-skill\",\"package_name\":\"my_namespace__my_skill\",\"version\":\"1.2.0\"}", + "manifestJson": "{...}" + } +} +``` + +`package_name` 从 `parsedMetadataJson` 中提取。 + +### 7.4 查询已 YANKED 版本 + +``` +GET /api/v1/skills/my-namespace/my-skill/versions/1.0.0 +``` + +```json +{ + "code": 0, + "data": { + "id": 98, + "version": "1.0.0", + "status": "YANKED", + "publishedAt": "2026-03-01T10:00:00Z" + } +} +``` + +AstronClaw 判断 `status != PUBLISHED`,拒绝新安装。已装实例不受影响。 + +### 7.5 发布(创建)个人 skill — 成功 + +``` +POST /api/v1/skills/my-namespace/publish +Content-Type: multipart/form-data +file: +visibility: PRIVATE +``` + +```json +{ + "code": 0, + "data": { + "skillId": 43, + "namespace": "my-namespace", + "slug": "new-skill", + "version": "0.1.0", + "status": "DRAFT", + "fileCount": 2, + "totalSize": 51200 + } +} +``` + +### 7.6 删除个人 skill — 成功 + +``` +DELETE /api/v1/skills/my-namespace/my-skill +``` + +```json +{ + "code": 0, + "data": { + "ok": true + } +} +``` + +### 7.7 删除个人 skill — 无权限 + +``` +DELETE /api/v1/skills/other-namespace/other-skill +``` + +```json +{ + "code": 403, + "msg": "Forbidden", + "data": null +} +``` + +### 7.8 边界:skill 已归档后查询 + +``` +GET /api/v1/skills/my-namespace/archived-skill +``` + +```json +{ + "code": 0, + "data": { + "id": 44, + "slug": "archived-skill", + "status": "ARCHIVED", + "visibility": "PUBLIC" + } +} +``` + +skill 仍可查询,但 AstronClaw 应根据 `status=ARCHIVED` 判断不可新装。 + +--- + +## 8. 遗留问题与建议 + +### 8.1 `package_name` 提取 + +当前 `package_name` 嵌套在 `parsedMetadataJson` JSONB 字段中,不是顶层字段。 + +建议:SaaS Adapter 在返回 AstronClaw DTO 时,解析 JSON 并将 `package_name` 提取为顶层字段。Core 不需要改动。 + +### 8.2 `bundle_url` 获取方式 + +当前没有直接返回 `bundle_url` 的字段,需通过 download 接口获取。`ResolveVersionResponse` 中有 `downloadUrl` 字段。 + +建议:SaaS Adapter 可通过 `resolve` 接口获取 `downloadUrl`,或直接生成预签名 URL 返回给 AstronClaw。 + +### 8.3 删除接口权限 + +当前 `DELETE /api/v1/skills/{namespace}/{slug}`(portal 路径)需要 SUPER_ADMIN 权限。ClawHub 兼容接口 `DELETE /api/v1/skills/{canonicalSlug}` 允许 owner 操作。 + +建议:SaaS Adapter 应统一封装 owner 可操作的删除接口,对 AstronClaw 暴露稳定契约;AstronClaw 不直接依赖开源删除接口路径。 + +### 8.4 `hidden` 与 `status` 的关系 + +当前 `hidden` 是独立于 `status` 的布尔标记(管理员操作),而 `HIDDEN` 是 `SkillStatus` 枚举值之一但实际代码中 skill 的 status 枚举包含 `ACTIVE`、`HIDDEN`、`ARCHIVED`。 + +建议:SaaS Adapter 统一为 AstronClaw 提供一个 `is_visible` 聚合字段,屏蔽内部 hidden 标记与 status 的复杂关系。 diff --git a/docs/oss-02-core-semantic-rules.md b/docs/oss-02-core-semantic-rules.md new file mode 100644 index 000000000..cd256d056 --- /dev/null +++ b/docs/oss-02-core-semantic-rules.md @@ -0,0 +1,663 @@ +# OSS-02 Core 语义规则收口 + +## 1. 文档目标 + +本文档固化 SkillHub Core 的运行时语义规则,确保开源版与 SaaS 版对删除、YANKED、同名冲突、package_name 等规则口径一致,避免 AstronClaw 接入后出现状态漂移。本文定义的是可由 SaaS 统一封装并对 AstronClaw 提供的 `Core` 规则基线,不表示 AstronClaw 直接对接这些开源接口。 + +--- + +## 2. 变更概要 + +### 2.1 新增功能 + +| 功能 | 说明 | +|------|------| +| UPLOADED 状态 | 新增版本状态,表示"已上传,未提交审核" | +| PRIVATE skill 自动发布 | PRIVATE skill 发布后进入 UPLOADED 状态,不自动进入审核 | +| 提交审核接口 | 新增 `POST /{namespace}/{slug}/submit-review`,允许 UPLOADED 状态的版本提交审核 | +| 撤回审核后进入 UPLOADED | 撤回审核后版本状态变为 UPLOADED,而不是 DRAFT | + +### 2.2 状态机变更 + +**变更前**: +``` +DRAFT → SCANNING → PENDING_REVIEW → PUBLISHED + ↓ ↓ + REJECTED YANKED +``` + +**变更后**: +``` +DRAFT → SCANNING → UPLOADED → PENDING_REVIEW → PUBLISHED + ↓ ↓ ↓ ↓ + SCAN_FAILED (可删除) REJECTED YANKED + ↓ ↓ + (可删除) (可删除) +``` + +### 2.3 权限模型变更 + +**核心原则**:权限只和 status 相关,visibility 只影响状态流转。 + +--- + +## 3. 版本状态定义 + +### 3.1 状态枚举 + +```java +public enum SkillVersionStatus { + DRAFT, // 草稿,编辑中 + SCANNING, // 安全扫描中 + SCAN_FAILED, // 扫描失败 + UPLOADED, // 已上传,未提交审核(新增) + PENDING_REVIEW, // 等待审核 + PUBLISHED, // 已发布 + REJECTED, // 审核拒绝 + YANKED // 已撤回 +} +``` + +### 3.2 状态语义 + +| 状态 | 含义 | 文件状态 | 可下载 | 可编辑 | 有检测报告 | +|------|------|---------|-------|-------|----------| +| DRAFT | 草稿,编辑中 | 可能不完整 | 否 | 是 | 否 | +| SCANNING | 安全扫描中 | 完整 | 否 | 否 | 否 | +| SCAN_FAILED | 扫描失败 | 完整 | 否 | 是 | 是(失败) | +| UPLOADED | 已上传,扫描通过 | 完整 | owner | 否 | 是 | +| PENDING_REVIEW | 审核中 | 完整 | owner | 否 | 是 | +| PUBLISHED | 已发布 | 完整 | 看 visibility | 否 | 是 | +| REJECTED | 审核拒绝 | 完整 | 否 | 是 | 是 | +| YANKED | 已撤回 | 完整 | 否 | 否 | 是 | + +--- + +## 4. 发布流程设计 + +### 4.1 发布路径 + +| visibility | 发布后初始状态 | 是否创建审核任务 | +|------------|--------------|----------------| +| PRIVATE | UPLOADED | 否 | +| NAMESPACE_ONLY | PENDING_REVIEW | 是 | +| PUBLIC | PENDING_REVIEW | 是 | + +### 4.2 PRIVATE skill 完整生命周期 + +``` +用户发布 PRIVATE skill + ↓ +状态:SCANNING(安全扫描中) + ↓ +扫描通过 + ↓ +状态:UPLOADED +visibility:PRIVATE + ↓ +owner 可下载/安装/测试 +市场不可见 +管理员可见(用于审计) +已有检测报告 + ↓ +owner 测试满意,确认发布(confirm-publish) + ↓ +状态:PUBLISHED +visibility:PRIVATE(正式私有版本) + ↓ +owner 可下载/安装 +市场不可见 + ↓ +用户想公开,提交审核 + ↓ +状态:PENDING_REVIEW +requestedVisibility:PUBLIC + ↓ +owner 仍可下载/测试 + ↓ +审核通过 + ↓ +状态:PUBLISHED +visibility:PUBLIC(不再是 PRIVATE) + ↓ +市场可见,所有人可下载 +``` + +### 4.3 PUBLIC/NAMESPACE_ONLY skill 生命周期 + +``` +用户发布 PUBLIC/NAMESPACE_ONLY skill + ↓ +状态:PENDING_REVIEW + ↓ +owner 可下载/测试 + ↓ +审核通过 + ↓ +状态:PUBLISHED +visibility:PUBLIC 或 NAMESPACE_ONLY + ↓ +市场可见(受 visibility 控制) +``` + +--- + +## 5. 权限矩阵 + +### 5.1 status 决定下载权限 + +| status | 市场可见 | 可下载 | +|--------|---------|-------| +| DRAFT | 否 | 否 | +| SCANNING | 否 | 否 | +| SCAN_FAILED | 否 | 否 | +| UPLOADED | 否 | owner | +| PENDING_REVIEW | 否 | owner | +| PUBLISHED | 看 visibility | 看 visibility | +| REJECTED | 否 | 否 | +| YANKED | 否 | 否 | + +### 5.2 PUBLISHED 状态下,visibility 决定可见性 + +| visibility | 市场可见 | 可下载 | +|------------|---------|-------| +| PUBLIC | 是 | 所有人 | +| NAMESPACE_ONLY | 命名空间内 | 命名空间成员 | +| PRIVATE | 否 | owner | + +### 5.3 AstronClaw 安装判断规则 + +``` +可安装 = + skill.status == ACTIVE + AND skill.hidden == false + AND 存在至少一个可下载版本 + AND 该版本 bundleReady == true + +可下载版本判断: + - UPLOADED/PENDING_REVIEW:仅 owner + - PUBLISHED:按 visibility 规则 +``` + +--- + +## 6. 状态流转详细设计 + +### 6.1 状态转换表 + +| 当前状态 | 操作 | 目标状态 | 说明 | +|---------|------|---------|------| +| DRAFT | 上传包 | SCANNING | 开始安全扫描 | +| SCANNING | 扫描通过 | UPLOADED 或 PENDING_REVIEW | 看 visibility | +| SCANNING | 扫描失败 | SCAN_FAILED | - | +| SCAN_FAILED | 重新上传 | SCANNING | - | +| UPLOADED | 提交审核 | PENDING_REVIEW | 新增操作 | +| UPLOADED | 确认发布 | PUBLISHED | PRIVATE skill 正式发布,不触发新扫描 | +| UPLOADED | 重新上传 | SCANNING | 允许重新上传 | +| UPLOADED | 删除 | (删除) | 允许删除,未正式发布 | +| PENDING_REVIEW | 审核通过 | PUBLISHED | - | +| PENDING_REVIEW | 审核拒绝 | REJECTED | - | +| PENDING_REVIEW | 撤回审核 | UPLOADED | 变更:原为 DRAFT | +| PUBLISHED | Yank | YANKED | - | +| REJECTED | 重新上传 | SCANNING | - | + +### 6.2 状态机图 + +``` + ┌─────────────────────────────────────────┐ + │ 上传包 │ + └─────────────────────────────────────────┘ + ↓ + ┌───────────────┐ + │ SCANNING │ + └───────────────┘ + / \ + 扫描通过 / \ 扫描失败 + / \ + ┌────────────────────────┐ ┌───────────────┐ + │ visibility=PRIVATE │ │ SCAN_FAILED │ + │ → UPLOADED │ └───────────────┘ + │ visibility=PUBLIC/ │ │ + │ NAMESPACE_ONLY │ │ 重新上传 + │ → PENDING_REVIEW │ ↓ + └────────────────────────┘ ┌───────────────┐ + │ │ SCANNING │ + ↓ └───────────────┘ + ┌────────────────────────┐ + │ UPLOADED │◄────────────────────────┐ + │ (PRIVATE skill 专属) │ │ + │ 已有检测报告 │ │ + └────────────────────────┘ │ + / \ │ + 确认发布 / \ 提交审核 │ + (不触发新扫描) / \ │ + / \ │ + ↓ ↓ │ + ┌───────────────────┐ ┌───────────────────┐ │ + │ PUBLISHED │ │ PENDING_REVIEW │ │ + │ visibility=PRIVATE│ └───────────────────┘ │ + └───────────────────┘ │ │ + │ │ │ + │ 提交审核 │ 审核通过 │ + ↓ ↓ │ + ┌───────────────────┐ ┌───────────────────┐ │ + │ PENDING_REVIEW │ │ PUBLISHED │ │ + └───────────────────┘ │ visibility=PUBLIC │ │ + │ │ 或 NAMESPACE_ONLY │ │ + │ └───────────────────┘ │ + │ 撤回审核 │ │ + └──────────────────────┘ │ + (进入 UPLOADED) │ + │ + ┌───────────────────┐ │ + │ REJECTED │────────────────────────────────────────┘ + └───────────────────┘ 重新上传 + │ + │ 删除 + ↓ + (删除) +``` + +--- + +## 7. 新增接口设计 + +说明: + +以下接口属于开源 `Core` 为 SaaS 提供的基础状态机能力。对 `AstronClaw` 而言,后续仍应统一通过 `SkillHub SaaS` 的 `AstronClaw Adapter` 消费这些能力,而不是直接绑定这些开源接口路径。 + +### 7.1 提交审核接口 + +**接口**:`POST /api/v1/skills/{namespace}/{slug}/submit-review` + +**请求参数**: +```json +{ + "version": "1.0.0", + "targetVisibility": "PUBLIC" +} +``` + +**前置条件**: +- 版本状态为 UPLOADED +- 操作者为 skill owner 或 namespace ADMIN/OWNER + +**执行效果**: +- 版本状态 → PENDING_REVIEW +- `requestedVisibility` 设为目标可见性 +- 创建审核任务 + +**响应**: +```json +{ + "code": 0, + "data": { + "versionId": 100, + "status": "PENDING_REVIEW", + "requestedVisibility": "PUBLIC" + } +} +``` + +### 7.2 确认发布接口(PRIVATE skill) + +**接口**:`POST /api/v1/skills/{namespace}/{slug}/confirm-publish` + +**请求参数**: +```json +{ + "version": "1.0.0" +} +``` + +**前置条件**: +- 版本状态为 UPLOADED +- skill.visibility = PRIVATE +- 操作者为 skill owner + +**执行效果**: +- 版本状态 → PUBLISHED +- visibility 保持 PRIVATE +- **不触发新的扫描**,复用 UPLOADED 时的扫描结果 +- 未来可扩展:加入"发布扫描"功能 + +**响应**: +```json +{ + "code": 0, + "data": { + "skillId": 42, + "versionId": 100, + "status": "PUBLISHED", + "visibility": "PRIVATE" + } +} +``` + +--- + +## 8. 删除 / 隐藏 / 归档 / YANKED 语义规则 + +### 8.1 操作语义总表 + +| 操作 | 触发方式 | 可逆 | 市场可见 | 可新装 | 已装保留 | 可卸载 | slug 可复用 | +|------|---------|------|---------|-------|---------|-------|-----------| +| **硬删除 skill** | owner 或 SUPER_ADMIN | 否 | 否 | 否 | 是 | 是 | 是 | +| **归档 skill** | owner / namespace admin | 是 | 否 | 否 | 是 | 是 | 否 | +| **隐藏 skill** | 管理员 | 是 | 否 | 否 | 是 | 是 | 否 | +| **Yank 版本** | owner / namespace admin | 否 | 否 | 否 | 是 | 是 | N/A | + +### 8.2 Yank 版本 + +**定义**:YANK 是"撤回已发布版本"的操作,用于将一个已发布的版本从可用状态移除。 + +**触发条件**: +- owner 或 namespace ADMIN/OWNER 对 PUBLISHED 状态的版本执行 yank + +**执行效果**: +- `version.status` → `YANKED`(不可逆,无 un-yank 操作) +- `version.downloadReady` → `false` +- 记录 `yankedAt`、`yankedBy`、`yankReason` +- 如果该版本是 `skill.latestVersionId` 指向的版本: + - 自动回退到上一个 PUBLISHED 版本 + - 如果没有其他 PUBLISHED 版本,`latestVersionId` → `null` + +**对 AstronClaw 的影响**: +- 已安装实例不受影响 +- 无法新装该版本 +- 升级场景:目标版本被 yank → 升级失败 + +对接原则: +- 上述语义应由 SaaS Adapter 原样继承并稳定对外提供 +- AstronClaw 通过 Adapter 感知这些状态,不直接绑定开源返回形态 + +**补救方式**: +- 不能 un-yank +- 只能发布新版本(rerelease 或重新上传) + +--- + +## 9. 同名冲突规则 + +### 9.1 唯一性约束 + +数据库约束:`UNIQUE(namespace_id, slug, owner_id)` + +含义: +- 同一 namespace 下,不同 owner 可以有相同 slug +- 同一 namespace 下,同一 owner 只能有一个相同 slug 的 skill + +### 9.2 冲突规则设计原则 + +**核心原则**:只有 PUBLISHED 状态才会阻塞同名发布,但区分 visibility。 + +| 对方状态 | 我发布同名 PRIVATE | 我发布同名 PUBLIC | 说明 | +|---------|-------------------|------------------|------| +| UPLOADED | ✅ 允许 | ✅ 允许 | 多个 UPLOADED 可共存 | +| PENDING_REVIEW | ✅ 允许 | ✅ 允许 | 还未正式发布 | +| PRIVATE + PUBLISHED | ❌ 拒绝 | ❌ 拒绝 | 只允许一个正式私有版本 | +| PUBLIC + PUBLISHED | ❌ 拒绝 | ❌ 拒绝 | 市场已占用 | + +### 9.3 冲突规则表(详细) + +| 场景 | 是否允许 | 说明 | +|------|---------|------| +| 同 namespace,同 slug,同 owner | 允许(复用) | 新版本挂到已有 skill 下 | +| 同 namespace,同 slug,不同 owner,对方只有 UPLOADED | 允许 | 多个 UPLOADED 可共存测试 | +| 同 namespace,同 slug,不同 owner,对方只有 PENDING_REVIEW | 允许 | 还未正式发布 | +| 同 namespace,同 slug,不同 owner,对方有 PRIVATE + PUBLISHED | 拒绝 | 只允许一个正式私有版本 | +| 同 namespace,同 slug,不同 owner,对方有 PUBLIC/NAMESPACE_ONLY + PUBLISHED | 拒绝 | 市场已占用 | +| 不同 namespace,同 slug | 允许 | namespace 隔离 | + +### 9.4 完整流程示例 + +``` +用户 A 发布 PRIVATE `ns/my-skill` + ↓ +状态:UPLOADED + ↓ +用户 B 发布 PRIVATE `ns/my-skill` + ↓ +状态:UPLOADED ✅ 允许(多个 UPLOADED 可共存) + ↓ +用户 A 确认发布 → PRIVATE + PUBLISHED ✅ 允许 + ↓ +用户 B 确认发布 → ❌ 被拒绝 + ↓ +错误信息:error.skill.publish.nameConflict.private + ↓ +用户 B 可以: + 1. 改名发布 + 2. 等用户 A 删除/归档后再发布 + 3. 提交审核变成 PUBLIC(如果 A 是 PRIVATE) +``` + +### 9.5 代码改动 + +**文件**:`SkillPublishService.java` + +```java +// 冲突检查逻辑(第 230-242 行) +for (Skill existing : existingSkills) { + if (!existing.getOwnerId().equals(publisherId)) { + // 检查是否有 PUBLISHED 版本 + boolean hasPublished = !skillVersionRepository + .findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED) + .isEmpty(); + + if (hasPublished) { + // PUBLISHED 版本存在,无论 visibility 如何都拒绝 + // 因为只允许一个 PRIVATE + PUBLISHED 或 PUBLIC + PUBLISHED + if (existing.getVisibility() == SkillVisibility.PRIVATE) { + throw new DomainBadRequestException("error.skill.publish.nameConflict.private", skillSlug); + } else { + throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug); + } + } + } +} +``` + +### 9.6 错误信息 + +| 错误码 | 说明 | +|-------|------| +| `error.skill.publish.nameConflict` | 已有同名 PUBLIC/NAMESPACE_ONLY skill 发布 | +| `error.skill.publish.nameConflict.private` | 已有同名 PRIVATE skill 正式发布 | + +--- + +## 10. package_name / runtime 规则 + +### 10.1 当前实现 + +- `package_name` 不是 Core 的结构化字段 +- 存储在 `skill_version.parsedMetadataJson` JSONB 字段中 +- 由 skill 作者在 SKILL.md frontmatter 中定义 + +### 10.2 SaaS Adapter 职责 + +- 从 `parsedMetadataJson` 中提取 `package_name` +- 作为顶层字段返回给 AstronClaw +- 可选:检查跨 skill 的 package_name 唯一性 +- 统一封装 `submit-review`、`confirm-publish`、删除、查询等 Core 能力,对 AstronClaw 暴露稳定接口 + +### 10.3 规则建议 + +| 规则 | 建议 | +|------|------| +| 格式 | 建议使用 `namespace__slug` 格式,避免冲突 | +| 跨版本稳定性 | 同一 skill 跨版本应保持 package_name 一致 | +| 唯一性 | SaaS Adapter 可检查并警告冲突,但不强制阻止 | + +--- + +## 11. 代码改动清单 + +说明: + +以下改动属于开源 `Core` 的规则实现,用于给 SaaS 封装层提供稳定能力基线;不等同于直接向 AstronClaw 暴露这些开源接口。 + +### 11.1 枚举新增 + +**文件**:`SkillVersionStatus.java` + +```java +public enum SkillVersionStatus { + DRAFT, + SCANNING, + SCAN_FAILED, + UPLOADED, // 新增 + PENDING_REVIEW, + PUBLISHED, + REJECTED, + YANKED +} +``` + +### 11.2 发布逻辑改动 + +**文件**:`SkillPublishService.java` + +```java +// 第 279-285 行,改为 +if (visibility == SkillVisibility.PRIVATE) { + version.setStatus(SkillVersionStatus.UPLOADED); + version.setPublishedAt(currentTime()); + // 不创建审核任务 +} else if (autoPublish) { + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setPublishedAt(currentTime()); +} else { + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + // 创建审核任务 +} +``` + +### 11.3 撤回审核改动 + +**文件**:`SkillGovernanceService.java` + +```java +// withdrawPendingVersion 方法,改为 +skillVersion.setStatus(SkillVersionStatus.UPLOADED); // 原为 DRAFT +``` + +### 11.4 下载权限改动 + +**文件**:`SkillDownloadService.java`、`SkillQueryService.java` + +```java +// UPLOADED 和 PENDING_REVIEW 状态允许 owner 下载 +private boolean canDownload(SkillVersion version, Skill skill, String currentUserId) { + return switch (version.getStatus()) { + case UPLOADED, PENDING_REVIEW -> skill.getOwnerId().equals(currentUserId); + case PUBLISHED -> true; // 按 visibility 判断 + default -> false; + }; +} +``` + +### 11.5 新增服务 + +**文件**:`SkillReviewSubmitService.java`(新增) + +- 实现 UPLOADED 版本提交审核逻辑 + +### 11.6 新增控制器 + +**文件**:`SkillReviewSubmitController.java`(新增) + +- 暴露 `POST /{namespace}/{slug}/submit-review` 接口 +- 暴露 `POST /{namespace}/{slug}/confirm-publish` 接口 + +### 11.7 管理员可见性 + +**文件**:`VisibilityChecker.java` + +- SUPER_ADMIN 可以看到所有 skill,包括 UPLOADED 状态 + +### 11.8 数据库迁移 + +**文件**:新增迁移脚本 + +- 更新 `skill_version_status` 枚举类型,添加 UPLOADED 值 + +--- + +## 12. 阻塞上线条件 + +| 问题 | 严重程度 | 状态 | +|------|---------|------| +| 新增 UPLOADED 状态 | 高 | 待实现 | +| PRIVATE skill 发布逻辑改动 | 高 | 待实现 | +| 提交审核接口 | 高 | 待实现 | +| 撤回审核后进入 UPLOADED | 中 | 待实现 | +| 同名冲突检查补全 | 中 | 待实现 | +| 管理员可见 UPLOADED skill | 低 | 待实现 | +| package_name 唯一性检查 | 低 | 可选 | + +--- + +## 13. 对老版本的影响 + +### 13.1 数据兼容性 + +| 影响点 | 分析 | 需要处理 | +|--------|------|---------| +| 老版本数据 | 不受影响,状态不变 | 否 | +| 数据库枚举 | 需添加 UPLOADED 值 | 是 | +| API 兼容性 | 新接口是新增,不影响老接口 | 否 | + +### 13.2 状态流转影响 + +| 场景 | 老逻辑 | 新逻辑 | 影响 | +|------|--------|--------|------| +| 老版本撤回审核 | PENDING_REVIEW → DRAFT | PENDING_REVIEW → UPLOADED | 前端需适配新状态 | +| 老版本删除 | DRAFT/REJECTED/SCAN_FAILED 可删 | UPLOADED 也可删 | 需更新代码判断 | + +### 13.3 代码改动点 + +**文件**:`SkillGovernanceService.java` + +**1. 删除版本逻辑**(第163-166行): +```java +// 原代码 +if (version.getStatus() != SkillVersionStatus.DRAFT + && version.getStatus() != SkillVersionStatus.REJECTED + && version.getStatus() != SkillVersionStatus.SCAN_FAILED) { + throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion()); +} + +// 改为:允许删除 UPLOADED 状态 +if (version.getStatus() != SkillVersionStatus.DRAFT + && version.getStatus() != SkillVersionStatus.REJECTED + && version.getStatus() != SkillVersionStatus.SCAN_FAILED + && version.getStatus() != SkillVersionStatus.UPLOADED) { + throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion()); +} +``` + +**2. 撤回审核逻辑**(第245行): +```java +// 原代码 +version.setStatus(SkillVersionStatus.DRAFT); + +// 改为 +version.setStatus(SkillVersionStatus.UPLOADED); +``` + +### 13.4 前端适配 + +| 状态 | 前端展示建议 | +|------|-------------| +| UPLOADED | "已上传" 或 "待确认" | +| 可删除状态 | DRAFT、SCAN_FAILED、REJECTED、UPLOADED | +| 可编辑状态 | DRAFT、SCAN_FAILED、REJECTED | + +### 13.5 迁移策略 + +1. **数据库迁移**:添加 UPLOADED 枚举值 +2. **代码部署**:先部署后端,再部署前端 +3. **老数据处理**:无需处理,老版本状态保持不变 +4. **回滚方案**:如需回滚,UPLOADED 状态的版本按 DRAFT 处理 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 16b7d2e44..b590fa22d 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 @@ -5,8 +5,10 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.dto.AdminSkillActionRequest; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.ConfirmPublishRequest; import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; +import com.iflytek.skillhub.dto.SubmitReviewRequest; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.GovernanceWorkflowAppService; import jakarta.validation.Valid; @@ -118,4 +120,39 @@ public class SkillLifecycleController extends BaseApiController { userNsRoles, AuditRequestContext.from(httpRequest))); } + + @PostMapping("/{namespace}/{slug}/submit-review") + public ApiResponse submitForReview(@PathVariable String namespace, + @PathVariable String slug, + @Valid @RequestBody SubmitReviewRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + governanceWorkflowAppService.submitForReview( + namespace, + slug, + request.version(), + request.targetVisibility(), + userId, + userNsRoles, + AuditRequestContext.from(httpRequest))); + } + + @PostMapping("/{namespace}/{slug}/confirm-publish") + public ApiResponse confirmPublish(@PathVariable String namespace, + @PathVariable String slug, + @Valid @RequestBody ConfirmPublishRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + governanceWorkflowAppService.confirmPublish( + namespace, + slug, + request.version(), + userId, + userNsRoles, + AuditRequestContext.from(httpRequest))); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java new file mode 100644 index 000000000..cfcb29918 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * Request to confirm publish for a PRIVATE skill version. + */ +public record ConfirmPublishRequest( + @NotBlank(message = "Version is required") + String version +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitReviewRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitReviewRequest.java new file mode 100644 index 000000000..817e9c970 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitReviewRequest.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/** + * Request to submit a skill version for review. + */ +public record SubmitReviewRequest( + @NotBlank(message = "Version is required") + String version, + + @NotBlank(message = "Target visibility is required") + @Pattern(regexp = "PUBLIC|NAMESPACE_ONLY", message = "Target visibility must be PUBLIC or NAMESPACE_ONLY") + String targetVisibility +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index 4432d0605..6cca39525 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -254,4 +254,36 @@ public class GovernanceWorkflowAppService { AuditRequestContext auditContext) { return namespacePortalCommandAppService.restoreNamespace(slug, userId, auditContext); } + + public SkillLifecycleMutationResponse submitForReview(String namespace, + String slug, + String version, + String targetVisibility, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + return skillLifecycleAppService.submitForReview( + namespace, + slug, + version, + targetVisibility, + userId, + userNsRoles, + auditContext); + } + + public SkillLifecycleMutationResponse confirmPublish(String namespace, + String slug, + String version, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + return skillLifecycleAppService.confirmPublish( + namespace, + slug, + version, + userId, + userNsRoles, + auditContext); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java index fda05a726..5670ebef9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java @@ -11,6 +11,7 @@ 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.domain.skill.service.SkillReviewSubmitService; import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; import com.iflytek.skillhub.dto.AdminSkillActionRequest; import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; @@ -31,6 +32,7 @@ public class SkillLifecycleAppService { private final SkillGovernanceService skillGovernanceService; private final ReviewService reviewService; private final SkillPublishService skillPublishService; + private final SkillReviewSubmitService skillReviewSubmitService; private final AuditLogService auditLogService; private final SkillSlugResolutionService skillSlugResolutionService; @@ -39,6 +41,7 @@ public class SkillLifecycleAppService { SkillGovernanceService skillGovernanceService, ReviewService reviewService, SkillPublishService skillPublishService, + SkillReviewSubmitService skillReviewSubmitService, AuditLogService auditLogService, SkillSlugResolutionService skillSlugResolutionService) { this.namespaceRepository = namespaceRepository; @@ -46,6 +49,7 @@ public class SkillLifecycleAppService { this.skillGovernanceService = skillGovernanceService; this.reviewService = reviewService; this.skillPublishService = skillPublishService; + this.skillReviewSubmitService = skillReviewSubmitService; this.auditLogService = auditLogService; this.skillSlugResolutionService = skillSlugResolutionService; } @@ -171,6 +175,74 @@ public class SkillLifecycleAppService { ); } + @Transactional + public SkillLifecycleMutationResponse submitForReview(String namespace, + String slug, + String version, + String targetVisibility, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + SkillVersion skillVersion = findVersion(skill.getId(), version); + skillReviewSubmitService.submitForReview( + skill.getId(), + skillVersion.getId(), + com.iflytek.skillhub.domain.skill.SkillVisibility.valueOf(targetVisibility), + userId, + normalizeRoles(userNamespaceRoles) + ); + auditLogService.record( + userId, + "SUBMIT_REVIEW", + "SKILL_VERSION", + skillVersion.getId(), + null, + auditContext.clientIp(), + auditContext.userAgent(), + "{\"version\":\"" + version.replace("\"", "\\\"") + "\",\"targetVisibility\":\"" + targetVisibility + "\"}" + ); + return new SkillLifecycleMutationResponse( + skill.getId(), + skillVersion.getId(), + "SUBMIT_REVIEW", + "PENDING_REVIEW" + ); + } + + @Transactional + public SkillLifecycleMutationResponse confirmPublish(String namespace, + String slug, + String version, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + SkillVersion skillVersion = findVersion(skill.getId(), version); + skillReviewSubmitService.confirmPublish( + skill.getId(), + skillVersion.getId(), + userId, + normalizeRoles(userNamespaceRoles) + ); + auditLogService.record( + userId, + "CONFIRM_PUBLISH", + "SKILL_VERSION", + skillVersion.getId(), + null, + auditContext.clientIp(), + auditContext.userAgent(), + "{\"version\":\"" + version.replace("\"", "\\\"") + "\"}" + ); + return new SkillLifecycleMutationResponse( + skill.getId(), + skillVersion.getId(), + "CONFIRM_PUBLISH", + "PUBLISHED" + ); + } + private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) { 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 eba859f54..6e7541862 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -133,7 +133,12 @@ error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ error.admin.user.status.invalid=Invalid user status: {0} error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace +error.skill.publish.nameConflict.private=A private skill with name ''{0}'' has already been published in this namespace error.skill.approve.nameConflict=Cannot approve: a published skill with name ''{0}'' already exists in this namespace +error.skill.version.submit.notUploaded=Version ''{0}'' is not in UPLOADED status and cannot be submitted for review +error.skill.version.confirm.notUploaded=Version ''{0}'' is not in UPLOADED status and cannot be confirmed +error.skill.confirm.notPrivate=Only PRIVATE skills can use confirm-publish +error.skill.version.notDownloadable=Version ''{0}'' is not available for download # Profile update error.profile.displayName.length=Display name must be between 2 and 32 characters diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index d220e409b..541770db9 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -133,7 +133,12 @@ error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SU error.admin.user.status.invalid=无效的用户状态:{0} error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户 error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交 +error.skill.publish.nameConflict.private=该命名空间下已存在名为"{0}"的已发布私有技能,无法提交 error.skill.approve.nameConflict=无法通过审核:该命名空间下已存在名为"{0}"的已发布技能 +error.skill.version.submit.notUploaded=版本"{0}"不在 UPLOADED 状态,无法提交审核 +error.skill.version.confirm.notUploaded=版本"{0}"不在 UPLOADED 状态,无法确认发布 +error.skill.confirm.notPrivate=只有 PRIVATE 技能可以使用确认发布功能 +error.skill.version.notDownloadable=版本"{0}"不可下载 # 用户资料修改 error.profile.displayName.length=昵称长度需在 2-32 个字符之间 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java index 532150111..e2c101462 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java @@ -18,6 +18,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService; import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; import com.iflytek.skillhub.dto.AdminSkillActionRequest; import org.junit.jupiter.api.Test; @@ -33,6 +34,7 @@ class SkillLifecycleAppServiceTest { private final SkillGovernanceService skillGovernanceService = mock(SkillGovernanceService.class); private final ReviewService reviewService = mock(ReviewService.class); private final SkillPublishService skillPublishService = mock(SkillPublishService.class); + private final SkillReviewSubmitService skillReviewSubmitService = mock(SkillReviewSubmitService.class); private final AuditLogService auditLogService = mock(AuditLogService.class); private final SkillSlugResolutionService skillSlugResolutionService = mock(SkillSlugResolutionService.class); private final SkillLifecycleAppService service = new SkillLifecycleAppService( @@ -41,6 +43,7 @@ class SkillLifecycleAppServiceTest { skillGovernanceService, reviewService, skillPublishService, + skillReviewSubmitService, auditLogService, skillSlugResolutionService ); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java index 195510a5e..fb24451f1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.security; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; @@ -105,7 +106,12 @@ public class SecurityScanService { audit.setScannedAt(Instant.now(Clock.systemUTC())); auditRepository.save(audit); - version.setStatus(SkillVersionStatus.PENDING_REVIEW); + // Set status based on requestedVisibility + if (version.getRequestedVisibility() == SkillVisibility.PRIVATE) { + version.setStatus(SkillVersionStatus.UPLOADED); + } else { + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + } skillVersionRepository.save(version); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java index 78fa2bb16..21978985b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java @@ -4,6 +4,7 @@ public enum SkillVersionStatus { DRAFT, SCANNING, SCAN_FAILED, + UPLOADED, PENDING_REVIEW, PUBLISHED, REJECTED, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 259d9e18c..3bb194ff3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -164,12 +164,15 @@ public class SkillDownloadService { private DownloadResult downloadVersion(Skill skill, SkillVersion version) { assertPublishedAccessible(skill); - assertPublishedVersion(version); + assertDownloadableVersion(skill, version); DownloadResult result = buildDownloadResult(skill, version); - skillRepository.incrementDownloadCount(skill.getId()); - skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); - eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + // Only increment download count for PUBLISHED versions + if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + skillRepository.incrementDownloadCount(skill.getId()); + skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); + eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + } return result; } @@ -292,9 +295,21 @@ public class SkillDownloadService { } } - private void assertPublishedVersion(SkillVersion version) { - if (version.getStatus() != SkillVersionStatus.PUBLISHED) { - throw new DomainBadRequestException("error.skill.version.notPublished", version.getVersion()); + /** + * Asserts that the version can be downloaded. + * - PUBLISHED: anyone with skill access can download + * - UPLOADED/PENDING_REVIEW: only skill owner can download + */ + private void assertDownloadableVersion(Skill skill, SkillVersion version) { + switch (version.getStatus()) { + case PUBLISHED -> { + // Anyone with skill access can download published versions + } + case UPLOADED, PENDING_REVIEW -> { + // Only owner can download UPLOADED/PENDING_REVIEW versions + // Note: This check is already done in assertCanDownload via visibilityChecker + } + default -> throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion()); } } } 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 9f0be2bf7..0dfcb5f35 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 @@ -162,7 +162,8 @@ public class SkillGovernanceService { assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); if (version.getStatus() != SkillVersionStatus.DRAFT && version.getStatus() != SkillVersionStatus.REJECTED - && version.getStatus() != SkillVersionStatus.SCAN_FAILED) { + && version.getStatus() != SkillVersionStatus.SCAN_FAILED + && version.getStatus() != SkillVersionStatus.UPLOADED) { throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion()); } @@ -242,7 +243,7 @@ public class SkillGovernanceService { if (version.getStatus() != SkillVersionStatus.PENDING_REVIEW) { throw new DomainBadRequestException("review.withdraw.not_pending", version.getId()); } - version.setStatus(SkillVersionStatus.DRAFT); + version.setStatus(SkillVersionStatus.UPLOADED); SkillVersion savedVersion = skillVersionRepository.save(version); skill.setUpdatedBy(actorUserId); skillRepository.save(skill); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 1afb2a1c9..07ca9735f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -172,14 +172,17 @@ public class SkillPublishService { List entries = rebuildEntriesForRerelease(skillId, publishedVersion.getId(), targetVersion); + // Rerelease follows the same visibility-based workflow as normal publish: + // - PRIVATE skills go to UPLOADED status + // - PUBLIC/NAMESPACE_ONLY skills go to PENDING_REVIEW (or UPLOADED after scan) return publishFromEntriesInternal( resolveNamespaceSlug(skill.getNamespaceId()), entries, publisherId, skill.getVisibility(), Set.of(), - true, - true, + false, // confirmWarnings=false: no warnings to confirm for rerelease + false, // forceAutoPublish=false: respect visibility rules true ); } @@ -250,13 +253,19 @@ public class SkillPublishService { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug); // Check if any other owner's skill has published versions + // Only PUBLISHED status blocks same-name publishing (UPLOADED/PENDING_REVIEW allowed) for (Skill existing : existingSkills) { if (!existing.getOwnerId().equals(publisherId)) { boolean hasPublished = !skillVersionRepository .findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED) .isEmpty(); if (hasPublished) { - throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug); + // Distinguish between PRIVATE and PUBLIC/NAMESPACE_ONLY conflicts + if (existing.getVisibility() == SkillVisibility.PRIVATE) { + throw new DomainBadRequestException("error.skill.publish.nameConflict.private", skillSlug); + } else { + throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug); + } } } } @@ -274,12 +283,13 @@ public class SkillPublishService { } // 6c. Auto-withdraw pending review versions + // When publishing a new version, existing PENDING_REVIEW versions are withdrawn to UPLOADED status List pendingVersions = skillVersionRepository .findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW); for (SkillVersion pending : pendingVersions) { reviewTaskRepository.findBySkillVersionIdAndStatus(pending.getId(), ReviewTaskStatus.PENDING) .ifPresent(reviewTaskRepository::delete); - pending.setStatus(SkillVersionStatus.DRAFT); + pending.setStatus(SkillVersionStatus.UPLOADED); skillVersionRepository.save(pending); } @@ -300,6 +310,10 @@ public class SkillPublishService { if (autoPublish) { version.setStatus(SkillVersionStatus.PUBLISHED); version.setPublishedAt(currentTime()); + } else if (visibility == SkillVisibility.PRIVATE) { + // PRIVATE skill goes to UPLOADED status, no review task created + version.setStatus(SkillVersionStatus.UPLOADED); + version.setPublishedAt(currentTime()); } else { version.setStatus(SkillVersionStatus.PENDING_REVIEW); } @@ -376,7 +390,8 @@ public class SkillPublishService { version.setDownloadReady(!skillFiles.isEmpty()); skillVersionRepository.save(version); - if (!autoPublish) { + // Create review task for PUBLIC/NAMESPACE_ONLY (not PRIVATE) + if (!autoPublish && visibility != SkillVisibility.PRIVATE) { ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId); ReviewTask savedReviewTask = reviewTaskRepository.save(reviewTask); eventPublisher.publishEvent(new ReviewSubmittedEvent( @@ -386,15 +401,18 @@ public class SkillPublishService { savedReviewTask.getSubmittedBy(), savedReviewTask.getNamespaceId() )); - if (securityScanService.isEnabled()) { - securityScanService.triggerScan(version.getId(), entries, publisherId); - } + } + + // Trigger security scan for all non-autoPublish versions + if (!autoPublish && securityScanService.isEnabled()) { + securityScanService.triggerScan(version.getId(), entries, publisherId); } // 12. Update skill metadata and move the published pointer for auto-publish flows skill.setDisplayName(metadata.name()); skill.setSummary(metadata.description()); - if (autoPublish) { + if (autoPublish || visibility == SkillVisibility.PRIVATE) { + // Update latestVersionId for autoPublish or PRIVATE skill (UPLOADED status) skill.setLatestVersionId(version.getId()); skill.setVisibility(visibility); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 376f43b32..0a64260c4 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -333,6 +333,7 @@ public class SkillQueryService { visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream() .filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED || version.getStatus() == SkillVersionStatus.PENDING_REVIEW + || version.getStatus() == SkillVersionStatus.UPLOADED || version.getStatus() == SkillVersionStatus.DRAFT || version.getStatus() == SkillVersionStatus.REJECTED || version.getStatus() == SkillVersionStatus.YANKED @@ -382,6 +383,7 @@ public class SkillQueryService { List versions = skillVersionRepository.findBySkillId(skill.getId()).stream() .filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED || version.getStatus() == SkillVersionStatus.PENDING_REVIEW + || version.getStatus() == SkillVersionStatus.UPLOADED || version.getStatus() == SkillVersionStatus.DRAFT || version.getStatus() == SkillVersionStatus.REJECTED || version.getStatus() == SkillVersionStatus.YANKED @@ -689,15 +691,18 @@ public class SkillQueryService { if (status == SkillVersionStatus.SCAN_FAILED) { return 1; } - if (status == SkillVersionStatus.REJECTED) { + if (status == SkillVersionStatus.UPLOADED) { return 2; } - if (status == SkillVersionStatus.PENDING_REVIEW) { + if (status == SkillVersionStatus.REJECTED) { return 3; } - if (status == SkillVersionStatus.DRAFT) { + if (status == SkillVersionStatus.PENDING_REVIEW) { return 4; } + if (status == SkillVersionStatus.DRAFT) { + return 5; + } if (status == SkillVersionStatus.YANKED) { return 5; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java new file mode 100644 index 000000000..1ce26fd38 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java @@ -0,0 +1,153 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +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 org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.Map; + +/** + * Service for submitting skill versions for review and confirming private publishes. + * + *

This service handles two key workflows for UPLOADED skill versions: + *

    + *
  • submitForReview: Transitions an UPLOADED version to PENDING_REVIEW status, + * creating a review task for PUBLIC/NAMESPACE_ONLY visibility changes.
  • + *
  • confirmPublish: Transitions an UPLOADED version directly to PUBLISHED status + * for PRIVATE skills without requiring review.
  • + *
+ * + * @see SkillVersionStatus#UPLOADED + * @see SkillVisibility#PRIVATE + */ +@Service +public class SkillReviewSubmitService { + + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final ReviewTaskRepository reviewTaskRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; + + public SkillReviewSubmitService( + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + ReviewTaskRepository reviewTaskRepository, + NamespaceMemberRepository namespaceMemberRepository, + ApplicationEventPublisher eventPublisher, + Clock clock) { + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.reviewTaskRepository = reviewTaskRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.eventPublisher = eventPublisher; + this.clock = clock; + } + + /** + * Submit an UPLOADED version for review. + * Transitions version status from UPLOADED to PENDING_REVIEW. + * + * @param skillId the skill ID + * @param versionId the version ID + * @param targetVisibility the target visibility after approval + * @param actorUserId the user performing the action + * @param userNamespaceRoles user's namespace roles + */ + @Transactional + public void submitForReview(Long skillId, Long versionId, SkillVisibility targetVisibility, + String actorUserId, Map userNamespaceRoles) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); + SkillVersion version = skillVersionRepository.findById(versionId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + + // Validate ownership + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + + // Validate version status + if (version.getStatus() != SkillVersionStatus.UPLOADED) { + throw new DomainBadRequestException("error.skill.version.submit.notUploaded", version.getVersion()); + } + + // Validate version belongs to skill + if (!version.getSkillId().equals(skillId)) { + throw new DomainBadRequestException("error.skill.version.mismatch"); + } + + // Update version + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + version.setRequestedVisibility(targetVisibility); + skillVersionRepository.save(version); + + // Create review task + ReviewTask reviewTask = new ReviewTask(versionId, skill.getNamespaceId(), actorUserId); + reviewTaskRepository.save(reviewTask); + } + + /** + * Confirm publish for a PRIVATE skill version. + * Transitions version status from UPLOADED to PUBLISHED without review. + * + * @param skillId the skill ID + * @param versionId the version ID + * @param actorUserId the user performing the action + * @param userNamespaceRoles user's namespace roles + */ + @Transactional + public void confirmPublish(Long skillId, Long versionId, String actorUserId, + Map userNamespaceRoles) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); + SkillVersion version = skillVersionRepository.findById(versionId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + + // Validate ownership + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + + // Validate skill visibility is PRIVATE + if (skill.getVisibility() != SkillVisibility.PRIVATE) { + throw new DomainBadRequestException("error.skill.confirm.notPrivate"); + } + + // Validate version status + if (version.getStatus() != SkillVersionStatus.UPLOADED) { + throw new DomainBadRequestException("error.skill.version.confirm.notUploaded", version.getVersion()); + } + + // Validate version belongs to skill + if (!version.getSkillId().equals(skillId)) { + throw new DomainBadRequestException("error.skill.version.mismatch"); + } + + // Update version to PUBLISHED + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setPublishedAt(Instant.now(clock)); + skillVersionRepository.save(version); + + // Update skill's latest version + skill.setLatestVersionId(versionId); + skill.setUpdatedBy(actorUserId); + skillRepository.save(skill); + } + + private void assertCanManageLifecycle(Skill skill, String actorUserId, Map userNamespaceRoles) { + NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId()); + boolean canManage = skill.getOwnerId().equals(actorUserId) + || namespaceRole == NamespaceRole.ADMIN + || namespaceRole == NamespaceRole.OWNER; + if (!canManage) { + throw new DomainForbiddenException("error.skill.lifecycle.noPermission"); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index f2b254fa6..b6f3faff4 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -156,7 +156,7 @@ class SkillGovernanceServiceTest { } @Test - void withdrawPendingVersion_demotesVersionToDraft() { + void withdrawPendingVersion_demotesVersionToUploaded() { Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); setField(skill, "id", 1L); SkillVersion version = new SkillVersion(1L, "1.0.0", "owner"); @@ -167,7 +167,7 @@ class SkillGovernanceServiceTest { SkillVersion result = service.withdrawPendingVersion(skill, version, "owner"); - assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.DRAFT); + assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.UPLOADED); verify(skillVersionRepository).save(version); verify(skillRepository).save(skill); verify(objectStorageService, never()).deleteObject(any()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 7e2ff7c10..ae0918893 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -796,7 +796,7 @@ class SkillPublishServiceTest { } @Test - void testRereleasePublishedVersion_ShouldCloneFilesAndAutoPublish() throws Exception { + void testRereleasePublishedVersion_ShouldCloneFilesAndSubmitForReview() throws Exception { String publisherId = "user-100"; Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC); setId(skill, 11L); @@ -859,11 +859,11 @@ class SkillPublishServiceTest { ); assertEquals("1.2.4", result.version().getVersion()); - assertEquals(SkillVersionStatus.PUBLISHED, result.version().getStatus()); - assertEquals(Instant.now(CLOCK), result.version().getPublishedAt()); - assertEquals(30L, skill.getLatestVersionId()); - verify(reviewTaskRepository, never()).save(any()); - verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); + // Rerelease for PUBLIC skill should go to PENDING_REVIEW (respecting visibility rules) + assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + // Review task should be created for PUBLIC skill + verify(reviewTaskRepository).save(any()); + verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class)); verify(skillPackageValidator).validate(argThat(entries -> entries.size() == 2 && entries.stream().anyMatch(entry -> @@ -896,6 +896,76 @@ class SkillPublishServiceTest { )); } + @Test + void testRereleasePublishedVersion_PrivateSkill_ShouldGoToUploaded() throws Exception { + String publisherId = "user-100"; + Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PRIVATE); + 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(Instant.parse("2026-03-15T10:00:00Z")); + + String sourceSkillMd = """ + --- + name: Demo Skill + description: Original summary + version: 1.2.3 + --- + Hello world + """; + + SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.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)); + when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8))); + 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()); + // Rerelease for PRIVATE skill should go to UPLOADED status + assertEquals(SkillVersionStatus.UPLOADED, result.version().getStatus()); + // No review task for PRIVATE skill + verify(reviewTaskRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class)); + // latestVersionId should be updated for PRIVATE skill + assertEquals(30L, skill.getLatestVersionId()); + } + @Test void testPublishFromEntries_ShouldRejectWhenOtherOwnerHasPublishedSkill() throws Exception { String namespaceSlug = "test-ns"; @@ -1017,8 +1087,8 @@ class SkillPublishServiceTest { service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()); - // Verify pending version was withdrawn to DRAFT - assertEquals(SkillVersionStatus.DRAFT, pendingV1.getStatus()); + // Verify pending version was withdrawn to UPLOADED (not DRAFT, so it remains visible) + assertEquals(SkillVersionStatus.UPLOADED, pendingV1.getStatus()); verify(reviewTaskRepository).delete(pendingTask); verify(skillVersionRepository).save(pendingV1); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java new file mode 100644 index 000000000..1fc266b5c --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java @@ -0,0 +1,220 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.Clock; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link SkillReviewSubmitService}. + */ +@ExtendWith(MockitoExtension.class) +class SkillReviewSubmitServiceTest { + + @Mock + private SkillRepository skillRepository; + + @Mock + private SkillVersionRepository skillVersionRepository; + + @Mock + private ReviewTaskRepository reviewTaskRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private SkillReviewSubmitService service; + + @BeforeEach + void setUp() { + service = new SkillReviewSubmitService( + skillRepository, + skillVersionRepository, + reviewTaskRepository, + null, // namespaceMemberRepository not used in these tests + eventPublisher, + Clock.systemUTC() + ); + } + + @Nested + @DisplayName("submitForReview") + class SubmitForReviewTests { + + @Test + @DisplayName("should transition UPLOADED version to PENDING_REVIEW") + void shouldTransitionToPendingReview() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + Long namespaceId = 10L; + + Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + when(reviewTaskRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Map roles = Map.of(); + + // When + service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, roles); + + // Then + assertEquals(SkillVersionStatus.PENDING_REVIEW, version.getStatus()); + assertEquals(SkillVisibility.PUBLIC, version.getRequestedVisibility()); + verify(reviewTaskRepository).save(any(ReviewTask.class)); + } + + @Test + @DisplayName("should reject when version is not UPLOADED") + void shouldRejectWhenNotUploaded() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + + Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.DRAFT); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When/Then + assertThrows(DomainBadRequestException.class, + () -> service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, Map.of())); + } + + @Test + @DisplayName("should reject when user is not owner") + void shouldRejectWhenNotOwner() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String ownerId = "owner-1"; + String otherUserId = "other-user"; + + Skill skill = createSkill(skillId, ownerId, 10L, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When/Then + assertThrows(DomainForbiddenException.class, + () -> service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, otherUserId, Map.of())); + } + } + + @Nested + @DisplayName("confirmPublish") + class ConfirmPublishTests { + + @Test + @DisplayName("should transition UPLOADED version to PUBLISHED for PRIVATE skill") + void shouldTransitionToPublished() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + Long namespaceId = 10L; + + Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When + service.confirmPublish(skillId, versionId, userId, Map.of()); + + // Then + assertEquals(SkillVersionStatus.PUBLISHED, version.getStatus()); + assertNotNull(version.getPublishedAt()); + assertEquals(versionId, skill.getLatestVersionId()); + verify(skillRepository).save(skill); + } + + @Test + @DisplayName("should reject when skill is not PRIVATE") + void shouldRejectWhenNotPrivate() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + + Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PUBLIC); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When/Then + assertThrows(DomainBadRequestException.class, + () -> service.confirmPublish(skillId, versionId, userId, Map.of())); + } + + @Test + @DisplayName("should reject when version is not UPLOADED") + void shouldRejectWhenNotUploaded() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + + Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.PUBLISHED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When/Then + assertThrows(DomainBadRequestException.class, + () -> service.confirmPublish(skillId, versionId, userId, Map.of())); + } + } + + private Skill createSkill(Long id, String ownerId, Long namespaceId, SkillVisibility visibility) { + Skill skill = new Skill(namespaceId, "test-skill", ownerId, visibility); + setField(skill, "id", id); + return skill; + } + + private SkillVersion createVersion(Long id, Long skillId, SkillVersionStatus status) { + SkillVersion version = new SkillVersion(skillId, "1.0.0", "user-1"); + setField(version, "id", id); + version.setStatus(status); + return version; + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 928036a0a..714111e39 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -476,6 +476,36 @@ export const skillLifecycleApi = { body: JSON.stringify({ targetVersion }), }) }, + + /** + * Submit an UPLOADED version for review. + * Transitions version status from UPLOADED to PENDING_REVIEW. + */ + async submitForReview(namespace: string, slug: string, version: string, targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY'): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/submit-review`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ version, targetVisibility }), + }) + }, + + /** + * Confirm publish for a PRIVATE skill version. + * Transitions version status from UPLOADED to PUBLISHED without review. + */ + async confirmPublish(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/confirm-publish`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ version }), + }) + }, } function normalizeNamespaceSlug(namespace: string): string { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 9835b1b68..3af596c91 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -777,6 +777,7 @@ "versionStatusDraft": "Draft", "versionStatusScanning": "Scanning", "versionStatusScanFailed": "Scan Failed", + "versionStatusUploaded": "Uploaded", "versionStatusPendingReview": "Pending Review", "versionStatusPublished": "Published", "versionStatusRejected": "Rejected", @@ -825,6 +826,18 @@ "withdrawReviewSuccessTitle": "Review withdrawn", "withdrawReviewSuccessDescription": "Version {{version}} has been withdrawn from review.", "withdrawReviewErrorTitle": "Failed to withdraw review", + "confirmPublish": "Confirm Publish", + "confirmPublishDialogTitle": "Confirm publish", + "confirmPublishDialogDescription": "Publish version {{version}} as a private skill? It will be available for you to download and install, but not visible on the marketplace.", + "confirmPublishSuccessTitle": "Version published", + "confirmPublishSuccessDescription": "Version {{version}} has been published as a private skill.", + "confirmPublishErrorTitle": "Failed to confirm publish", + "submitReview": "Submit for Review", + "submitReviewDialogTitle": "Submit for review", + "submitReviewDialogDescription": "Submit version {{version}} for public review? Once approved, it will be visible on the marketplace.", + "submitReviewSuccessTitle": "Submitted for review", + "submitReviewSuccessDescription": "Version {{version}} has been submitted for review.", + "submitReviewErrorTitle": "Failed to submit for 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 7a8e187b6..17c2eb9a2 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -777,6 +777,7 @@ "versionStatusDraft": "草稿", "versionStatusScanning": "安全扫描中", "versionStatusScanFailed": "扫描失败", + "versionStatusUploaded": "已上传", "versionStatusPendingReview": "审核中", "versionStatusPublished": "已发布", "versionStatusRejected": "已拒绝", @@ -825,6 +826,19 @@ "withdrawReviewSuccessTitle": "已撤销审核", "withdrawReviewSuccessDescription": "版本 {{version}} 已撤销审核。", "withdrawReviewErrorTitle": "撤销审核失败", + "confirmPublish": "确认发布", + "confirmPublishDialogTitle": "确认发布", + "confirmPublishDialogDescription": "将版本 {{version}} 发布为私有技能?发布后您可以下载和安装,但不会在市场展示。", + "confirmPublishSuccessTitle": "版本已发布", + "confirmPublishSuccessDescription": "版本 {{version}} 已发布为私有技能。", + "confirmPublishErrorTitle": "确认发布失败", + "submitReview": "提交审核", + "submitReviewDialogTitle": "提交审核", + "submitReviewDialogDescription": "将版本 {{version}} 提交公开审核?审核通过后将在市场展示。", + "submitReviewSuccessTitle": "已提交审核", + "submitReviewSuccessDescription": "版本 {{version}} 已提交审核。", + "submitReviewErrorTitle": "提交审核失败", + "withdrawReviewErrorTitle": "撤销审核失败", "deleteVersion": "删除版本", "deleteVersionConfirmTitle": "确认删除版本", "deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?", diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 74cd0c65f..5f437cd79 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -83,6 +83,9 @@ export function MySkillsPage() { if (status === 'SCAN_FAILED') { return t('mySkills.statusScanFailed') } + if (status === 'UPLOADED') { + return t('skillDetail.versionStatusUploaded') + } return status } @@ -108,6 +111,9 @@ export function MySkillsPage() { if (status === 'SCAN_FAILED') { return 'status-pill status-pill--rejected' } + if (status === 'UPLOADED') { + return 'status-pill status-pill--review' + } return 'status-pill' } diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index deed65d32..067a171fb 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -112,6 +112,8 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useRereleaseSkillVersion: () => ({ mutateAsync: vi.fn(), isPending: false }), useUnarchiveSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), useWithdrawSkillReview: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSubmitForReview: () => ({ mutateAsync: vi.fn(), isPending: false }), + useConfirmPublish: () => ({ mutateAsync: vi.fn(), isPending: false }), })) vi.mock('@/shared/hooks/use-label-queries', () => ({ diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 990396b4c..d28b4f75d 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -53,6 +53,8 @@ import { useRereleaseSkillVersion, useUnarchiveSkill, useWithdrawSkillReview, + useSubmitForReview, + useConfirmPublish, } from '@/shared/hooks/use-skill-queries' import { useSubmitPromotion } from '@/shared/hooks/use-user-queries' @@ -115,6 +117,8 @@ export function SkillDetailPage() { const [rereleaseTarget, setRereleaseTarget] = useState(null) const [targetVersionInput, setTargetVersionInput] = useState('') const [diffSourceVersion, setDiffSourceVersion] = useState(null) + const [confirmPublishTarget, setConfirmPublishTarget] = useState(null) + const [submitReviewTarget, setSubmitReviewTarget] = useState(null) const [diffCompareVersion, setDiffCompareVersion] = useState(null) const [isOverviewExpanded, setIsOverviewExpanded] = useState(false) const [isOverviewCollapsible, setIsOverviewCollapsible] = useState(false) @@ -260,6 +264,8 @@ export function SkillDetailPage() { const rereleaseVersionMutation = useRereleaseSkillVersion() const submitPromotionMutation = useSubmitPromotion() const reportMutation = useSubmitSkillReport(namespace, slug) + const submitForReviewMutation = useSubmitForReview() + const confirmPublishMutation = useConfirmPublish() const triggerBrowserDownload = (url: string) => { const link = document.createElement('a') @@ -380,6 +386,7 @@ export function SkillDetailPage() { DRAFT: t('skillDetail.versionStatusDraft'), SCANNING: t('skillDetail.versionStatusScanning'), SCAN_FAILED: t('skillDetail.versionStatusScanFailed'), + UPLOADED: t('skillDetail.versionStatusUploaded'), PENDING_REVIEW: t('skillDetail.versionStatusPendingReview'), PUBLISHED: t('skillDetail.versionStatusPublished'), REJECTED: t('skillDetail.versionStatusRejected'), @@ -388,7 +395,7 @@ export function SkillDetailPage() { return status ? (map[status] ?? status) : '' } - const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED' || status === 'SCAN_FAILED' + const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED' || status === 'SCAN_FAILED' || status === 'UPLOADED' const isLastVersion = versions?.length === 1 const canWithdrawVersion = (status?: string) => status === 'PENDING_REVIEW' const canRereleaseVersion = (status?: string) => status === 'PUBLISHED' @@ -529,6 +536,40 @@ export function SkillDetailPage() { } } + const handleConfirmPublish = async () => { + if (!confirmPublishTarget) { + return + } + try { + await confirmPublishMutation.mutateAsync({ namespace, slug, version: confirmPublishTarget }) + toast.success( + t('skillDetail.confirmPublishSuccessTitle'), + t('skillDetail.confirmPublishSuccessDescription', { version: confirmPublishTarget }), + ) + setConfirmPublishTarget(null) + } catch (error) { + toast.error(t('skillDetail.confirmPublishErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + + const handleSubmitForReview = async () => { + if (!submitReviewTarget) { + return + } + try { + await submitForReviewMutation.mutateAsync({ namespace, slug, version: submitReviewTarget, targetVisibility: 'PUBLIC' }) + toast.success( + t('skillDetail.submitReviewSuccessTitle'), + t('skillDetail.submitReviewSuccessDescription', { version: submitReviewTarget }), + ) + setSubmitReviewTarget(null) + } catch (error) { + toast.error(t('skillDetail.submitReviewErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + const handleOpenRerelease = (version: string) => { setRereleaseTarget(version) setTargetVersionInput(suggestNextVersion(version)) @@ -884,6 +925,24 @@ export function SkillDetailPage() { {t('skillDetail.withdrawReview')} )} + {skill.canManageLifecycle && version.status === 'UPLOADED' && skill.visibility === 'PRIVATE' && ( + + )} + {skill.canManageLifecycle && version.status === 'UPLOADED' && skill.visibility === 'PRIVATE' && ( + + )} {version.changelog && ( @@ -1370,6 +1429,32 @@ export function SkillDetailPage() { + { + if (!open) { + setConfirmPublishTarget(null) + } + }} + title={t('skillDetail.confirmPublishDialogTitle')} + description={confirmPublishTarget ? t('skillDetail.confirmPublishDialogDescription', { version: confirmPublishTarget }) : ''} + confirmText={t('skillDetail.confirmPublish')} + onConfirm={handleConfirmPublish} + /> + + { + if (!open) { + setSubmitReviewTarget(null) + } + }} + title={t('skillDetail.submitReviewDialogTitle')} + description={submitReviewTarget ? t('skillDetail.submitReviewDialogDescription', { version: submitReviewTarget }) : ''} + confirmText={t('skillDetail.submitReview')} + onConfirm={handleSubmitForReview} + /> + { diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index c72e738c5..4784ada54 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -216,3 +216,41 @@ export function useRereleaseSkillVersion() { }, }) } + +/** + * Submit an UPLOADED version for review. + * Transitions version status from UPLOADED to PENDING_REVIEW. + */ +export function useSubmitForReview() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ namespace, slug, version, targetVisibility }: { namespace: string; slug: string; version: string; targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY' }) => + skillLifecycleApi.submitForReview(namespace, slug, version, targetVisibility), + 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'] }) + }, + }) +} + +/** + * Confirm publish for a PRIVATE skill version. + * Transitions version status from UPLOADED to PUBLISHED without review. + */ +export function useConfirmPublish() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ namespace, slug, version }: { namespace: string; slug: string; version: string }) => + skillLifecycleApi.confirmPublish(namespace, slug, version), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug, 'versions'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} From f70d09aac77fb9fef51101c6347d9376548777f8 Mon Sep 17 00:00:00 2001 From: xiose Date: Mon, 13 Apr 2026 09:55:41 +0800 Subject: [PATCH 05/26] feat(review): add backward compatibility for DRAFT status Support both DRAFT (legacy) and UPLOADED (new flow) status in: - SkillReviewSubmitService.submitForReview - SkillReviewSubmitService.confirmPublish - ReviewService.submitReview (both overloads) This ensures existing data with DRAFT status continues to work with the new visibility-based workflow introduced in OSS-02. --- .../skillhub/domain/review/ReviewService.java | 8 ++- .../service/SkillReviewSubmitService.java | 20 +++--- .../service/SkillReviewSubmitServiceTest.java | 62 +++++++++++++++++-- 3 files changed, 76 insertions(+), 14 deletions(-) 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 bd31218a4..56ea78845 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 @@ -101,7 +101,9 @@ public class ReviewService { throw new DomainForbiddenException("review.submit.no_permission"); } - if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) { + // Support both DRAFT (legacy) and UPLOADED (new flow) status + if (skillVersion.getStatus() != SkillVersionStatus.DRAFT + && skillVersion.getStatus() != SkillVersionStatus.UPLOADED) { throw new DomainBadRequestException("review.submit.not_draft", skillVersionId); } @@ -137,7 +139,9 @@ public class ReviewService { .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); assertNamespaceActive(namespace); - if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) { + // Support both DRAFT (legacy) and UPLOADED (new flow) status + if (skillVersion.getStatus() != SkillVersionStatus.DRAFT + && skillVersion.getStatus() != SkillVersionStatus.UPLOADED) { throw new DomainBadRequestException("review.submit.not_draft", skillVersionId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java index 1ce26fd38..df8e9fd10 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java @@ -55,8 +55,10 @@ public class SkillReviewSubmitService { } /** - * Submit an UPLOADED version for review. - * Transitions version status from UPLOADED to PENDING_REVIEW. + * Submit an UPLOADED or DRAFT version for review. + * Transitions version status from UPLOADED/DRAFT to PENDING_REVIEW. + * + *

Supports both UPLOADED (new flow) and DRAFT (legacy compatibility) status. * * @param skillId the skill ID * @param versionId the version ID @@ -75,8 +77,9 @@ public class SkillReviewSubmitService { // Validate ownership assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); - // Validate version status - if (version.getStatus() != SkillVersionStatus.UPLOADED) { + // Validate version status - support both UPLOADED (new) and DRAFT (legacy) + if (version.getStatus() != SkillVersionStatus.UPLOADED + && version.getStatus() != SkillVersionStatus.DRAFT) { throw new DomainBadRequestException("error.skill.version.submit.notUploaded", version.getVersion()); } @@ -97,7 +100,9 @@ public class SkillReviewSubmitService { /** * Confirm publish for a PRIVATE skill version. - * Transitions version status from UPLOADED to PUBLISHED without review. + * Transitions version status from UPLOADED/DRAFT to PUBLISHED without review. + * + *

Supports both UPLOADED (new flow) and DRAFT (legacy compatibility) status. * * @param skillId the skill ID * @param versionId the version ID @@ -120,8 +125,9 @@ public class SkillReviewSubmitService { throw new DomainBadRequestException("error.skill.confirm.notPrivate"); } - // Validate version status - if (version.getStatus() != SkillVersionStatus.UPLOADED) { + // Validate version status - support both UPLOADED (new) and DRAFT (legacy) + if (version.getStatus() != SkillVersionStatus.UPLOADED + && version.getStatus() != SkillVersionStatus.DRAFT) { throw new DomainBadRequestException("error.skill.version.confirm.notUploaded", version.getVersion()); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java index 1fc266b5c..4f8f9565c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java @@ -88,15 +88,42 @@ class SkillReviewSubmitServiceTest { } @Test - @DisplayName("should reject when version is not UPLOADED") - void shouldRejectWhenNotUploaded() { + @DisplayName("should accept DRAFT version (legacy compatibility)") + void shouldAcceptDraftForLegacyCompatibility() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + Long namespaceId = 10L; + + Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.DRAFT); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + when(reviewTaskRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Map roles = Map.of(); + + // When + service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, roles); + + // Then + assertEquals(SkillVersionStatus.PENDING_REVIEW, version.getStatus()); + assertEquals(SkillVisibility.PUBLIC, version.getRequestedVisibility()); + verify(reviewTaskRepository).save(any(ReviewTask.class)); + } + + @Test + @DisplayName("should reject when version is neither UPLOADED nor DRAFT") + void shouldRejectWhenNotUploadedOrDraft() { // Given Long skillId = 1L; Long versionId = 100L; String userId = "user-1"; Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE); - SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.DRAFT); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.PUBLISHED); when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); @@ -156,6 +183,31 @@ class SkillReviewSubmitServiceTest { verify(skillRepository).save(skill); } + @Test + @DisplayName("should transition DRAFT version to PUBLISHED for PRIVATE skill (legacy compatibility)") + void shouldTransitionDraftToPublished() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + Long namespaceId = 10L; + + Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.DRAFT); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When + service.confirmPublish(skillId, versionId, userId, Map.of()); + + // Then + assertEquals(SkillVersionStatus.PUBLISHED, version.getStatus()); + assertNotNull(version.getPublishedAt()); + assertEquals(versionId, skill.getLatestVersionId()); + verify(skillRepository).save(skill); + } + @Test @DisplayName("should reject when skill is not PRIVATE") void shouldRejectWhenNotPrivate() { @@ -176,8 +228,8 @@ class SkillReviewSubmitServiceTest { } @Test - @DisplayName("should reject when version is not UPLOADED") - void shouldRejectWhenNotUploaded() { + @DisplayName("should reject when version is neither UPLOADED nor DRAFT") + void shouldRejectWhenNotUploadedOrDraft() { // Given Long skillId = 1L; Long versionId = 100L; From 38757084ba10cd044e9981e8476abd58269a2ca4 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 13 Apr 2026 17:00:07 +0800 Subject: [PATCH 06/26] fix(review): restore namespace admin review access --- .../portal/SecurityAuditController.java | 4 + .../portal/SecurityAuditControllerTest.java | 28 +++++ ...ApprovalVisibilityFlowIntegrationTest.java | 57 ++++++++++ .../review/ReviewPermissionChecker.java | 19 +++- .../review/ReviewPermissionCheckerTest.java | 18 +++ .../domain/review/ReviewServiceTest.java | 77 +++++++++++++ .../namespace-review-detail-access.spec.ts | 73 ++++++++++++ web/src/app/router.tsx | 24 ++-- web/src/features/review/review-paths.test.ts | 107 ++++++++++++++++++ web/src/features/review/review-paths.ts | 48 ++++++++ web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 1 + .../pages/dashboard/namespace-reviews.test.ts | 17 +++ web/src/pages/dashboard/namespace-reviews.tsx | 22 +++- .../pages/dashboard/review-detail.test.tsx | 89 ++++++++++++++- web/src/pages/dashboard/review-detail.tsx | 89 +++++++++++++-- web/src/pages/dashboard/reviews.test.ts | 14 ++- web/src/pages/dashboard/reviews.tsx | 43 ++++++- web/src/shared/components/user-menu.tsx | 10 +- 19 files changed, 700 insertions(+), 41 deletions(-) create mode 100644 web/e2e/namespace-review-detail-access.spec.ts create mode 100644 web/src/features/review/review-paths.test.ts create mode 100644 web/src/features/review/review-paths.ts diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java index cf233a74c..f763424ff 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java @@ -103,6 +103,10 @@ public class SecurityAuditController extends BaseApiController { return true; } Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); + NamespaceRole namespaceRole = namespaceRoles.get(skill.getNamespaceId()); + if (namespaceRole == NamespaceRole.ADMIN || namespaceRole == NamespaceRole.OWNER) { + return true; + } return visibilityChecker.canAccess(skill, principal.userId(), namespaceRoles); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java index ab4ddb2bb..eb5f87d6a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.security.ScannerType; import com.iflytek.skillhub.domain.security.SecurityAudit; @@ -56,6 +58,9 @@ class SecurityAuditControllerTest { @MockBean private ScanTaskProducer scanTaskProducer; + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + @Test void getSecurityAudit_returnsAuditPayload() throws Exception { SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER); @@ -129,6 +134,29 @@ class SecurityAuditControllerTest { .andExpect(jsonPath("$.code").value(403)); } + @Test + void getSecurityAudit_allowsNamespaceAdminForPendingUnpublishedSkill() throws Exception { + SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER); + setField(audit, "id", 9L); + audit.setScanId("scan-team-admin"); + audit.setVerdict(SecurityVerdict.SAFE); + audit.setIsSafe(true); + audit.setMaxSeverity("LOW"); + audit.setFindingsCount(0); + given(skillVersionRepository.findById(42L)).willReturn(java.util.Optional.of(skillVersion(42L, 8L))); + given(skillRepository.findById(8L)).willReturn(java.util.Optional.of(skill(8L, "owner-1"))); + given(securityAuditRepository.findLatestActiveByVersionId(42L)).willReturn(List.of(audit)); + given(namespaceMemberRepository.findByUserId("team-admin")) + .willReturn(List.of(new NamespaceMember(5L, "team-admin", NamespaceRole.ADMIN))); + + mockMvc.perform(get("/api/v1/skills/8/versions/42/security-audit") + .with(auth("team-admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].id").value(9L)) + .andExpect(jsonPath("$.data[0].scanId").value("scan-team-admin")); + } + private RequestPostProcessor auth(String userId) { PlatformPrincipal principal = new PlatformPrincipal( userId, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java index 431223a84..99e437446 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.review.ReviewTask; @@ -128,6 +129,37 @@ class SkillApprovalVisibilityFlowIntegrationTest { assertThat(indexedDocument.getTitle()).isEqualTo(graph.skill().getDisplayName()); } + @Test + void namespaceAdminCanApproveOwnTeamReview() throws Exception { + PendingSkillGraph graph = createPendingTeamSkill("team-admin"); + when(namespaceMemberRepository.findByUserId("team-admin")) + .thenReturn(List.of(new com.iflytek.skillhub.domain.namespace.NamespaceMember( + graph.namespace().getId(), + "team-admin", + NamespaceRole.ADMIN + ))); + when(rbacService.getUserRoleCodes("team-admin")).thenReturn(Set.of()); + + mockMvc.perform(post("/api/v1/reviews/" + graph.reviewTask().getId() + "/approve") + .contentType("application/json") + .content("{\"comment\":\"approved by namespace admin\"}") + .with(authentication(apiAuth("team-admin"))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(graph.reviewTask().getId())) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + .andExpect(jsonPath("$.data.reviewedBy").value("team-admin")) + .andExpect(jsonPath("$.data.reviewComment").value("approved by namespace admin")); + + Skill savedSkill = skillRepository.findById(graph.skill().getId()).orElseThrow(); + SkillVersion savedVersion = skillVersionRepository.findById(graph.version().getId()).orElseThrow(); + + assertThat(savedSkill.getLatestVersionId()).isEqualTo(graph.version().getId()); + assertThat(savedVersion.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED); + assertThat(savedVersion.getPublishedAt()).isNotNull(); + } + private PendingSkillGraph createPendingGlobalSkill(String ownerId) { String suffix = UUID.randomUUID().toString().substring(0, 8); @@ -154,6 +186,31 @@ class SkillApprovalVisibilityFlowIntegrationTest { return new PendingSkillGraph(namespace, skill, version, reviewTask); } + private PendingSkillGraph createPendingTeamSkill(String ownerId) { + String suffix = UUID.randomUUID().toString().substring(0, 8); + + Namespace namespace = new Namespace("team-approval-" + suffix, "Team Approval " + suffix, ownerId); + namespace = namespaceRepository.save(namespace); + + Skill skill = new Skill(namespace.getId(), "approval-skill-" + suffix, ownerId, SkillVisibility.PUBLIC); + skill.setDisplayName("Approval Skill " + suffix); + skill.setSummary("Team namespace self-review should be allowed for namespace admins."); + skill.setCreatedBy(ownerId); + skill.setUpdatedBy(ownerId); + skill = skillRepository.save(skill); + skillRepository.flush(); + + SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", ownerId); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + version.setRequestedVisibility(SkillVisibility.PUBLIC); + version = skillVersionRepository.save(version); + skillVersionRepository.flush(); + + ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId)); + + return new PendingSkillGraph(namespace, skill, version, reviewTask); + } + private SkillSearchDocumentEntity awaitIndexedDocument(Long skillId) throws InterruptedException { Instant deadline = Instant.now().plus(Duration.ofSeconds(5)); Optional indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index 773992470..c3c125b13 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -28,12 +28,8 @@ public class ReviewPermissionChecker { Map userNamespaceRoles, Set platformRoles) { if (task.getSubmittedBy().equals(userId)) { - if (platformRoles.contains("SUPER_ADMIN")) { - return true; - } - NamespaceRole role = userNamespaceRoles.get(task.getNamespaceId()); - return hasPlatformReviewRole(platformRoles) - && (role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER); + return platformRoles.contains("SUPER_ADMIN") + || canSelfReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles); } return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles); } @@ -140,4 +136,15 @@ public class ReviewPermissionChecker { return platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); } + + private boolean canSelfReviewNamespace(Long namespaceId, + NamespaceType namespaceType, + Map userNamespaceRoles) { + if (namespaceType == NamespaceType.GLOBAL) { + return false; + } + + NamespaceRole role = userNamespaceRoles.get(namespaceId); + return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index 274d1747c..25ae2f704 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -81,6 +81,24 @@ class ReviewPermissionCheckerTest { NamespaceType.GLOBAL, Map.of(), Set.of("SUPER_ADMIN"))); } + @Test + void teamAdminCanReviewOwnTeamSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 10L, userId); + assertTrue(checker.canReview(task, userId, + NamespaceType.TEAM, + Map.of(10L, NamespaceRole.ADMIN), Set.of())); + } + + @Test + void teamOwnerCanReviewOwnTeamSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 10L, userId); + assertTrue(checker.canReview(task, userId, + NamespaceType.TEAM, + Map.of(10L, NamespaceRole.OWNER), Set.of())); + } + @Test void teamAdminCanReviewTeamSkill() { ReviewTask task = new ReviewTask(1L, 10L, "user-2"); 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 90246ed1b..f4fde3396 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 @@ -483,6 +483,46 @@ class ReviewServiceTest { assertEquals(USER_ID, skill.getUpdatedBy()); } + @Test + void namespaceAdminCanApproveOwnSubmission() { + ReviewTask task = createPendingReviewTask(); + Namespace ns = createTeamNamespace(); + SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); + + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); + when(permissionChecker.canReview( + eq(task), + eq(USER_ID), + eq(ns.getType()), + eq(Map.of(NAMESPACE_ID, NamespaceRole.ADMIN)), + eq(Set.of()))) + .thenReturn(true); + when(reviewTaskRepository.updateStatusWithVersion( + REVIEW_TASK_ID, + ReviewTaskStatus.APPROVED, + USER_ID, + "self approved as namespace admin", + task.getVersion())) + .thenReturn(1); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill)); + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + + ReviewTask result = reviewService.approveReview( + REVIEW_TASK_ID, + USER_ID, + "self approved as namespace admin", + Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), + Set.of()); + + assertNotNull(result); + assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus()); + assertEquals(USER_ID, skill.getUpdatedBy()); + } + @Test void shouldThrowOnConcurrentModification() { ReviewTask task = createPendingReviewTask(); @@ -602,6 +642,43 @@ class ReviewServiceTest { assertEquals(SkillVersionStatus.REJECTED, sv.getStatus()); } + @Test + void namespaceAdminCanRejectOwnSubmission() { + ReviewTask task = createPendingReviewTask(); + Namespace ns = createTeamNamespace(); + SkillVersion sv = createPendingReviewSkillVersion(); + + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); + when(permissionChecker.canReview( + eq(task), + eq(USER_ID), + eq(ns.getType()), + eq(Map.of(NAMESPACE_ID, NamespaceRole.ADMIN)), + eq(Set.of()))) + .thenReturn(true); + when(reviewTaskRepository.updateStatusWithVersion( + REVIEW_TASK_ID, + ReviewTaskStatus.REJECTED, + USER_ID, + "self rejected as namespace admin", + task.getVersion())) + .thenReturn(1); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill())); + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + + ReviewTask result = reviewService.rejectReview( + REVIEW_TASK_ID, + USER_ID, + "self rejected as namespace admin", + Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), + Set.of()); + + assertNotNull(result); + assertEquals(SkillVersionStatus.REJECTED, sv.getStatus()); + } + @Test void shouldThrowOnConcurrentModification() { ReviewTask task = createPendingReviewTask(); diff --git a/web/e2e/namespace-review-detail-access.spec.ts b/web/e2e/namespace-review-detail-access.spec.ts new file mode 100644 index 000000000..d3be17418 --- /dev/null +++ b/web/e2e/namespace-review-detail-access.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +test.describe('Namespace Review Detail Access (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('opens namespace review detail from the namespace review list', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const seeded = await builder.createReviewData() + + await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`) + + await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() + await expect(page.getByText(`${seeded.namespace.slug}/${seeded.skill.slug}`)).toBeVisible() + + await page.getByRole('link', { name: 'Open review' }).first().click() + + await expect(page).toHaveURL(new RegExp(`/dashboard/namespaces/${seeded.namespace.slug}/reviews/\\d+$`)) + await expect(page.getByRole('heading', { name: 'Review Detail' })).toBeVisible() + await expect(page.getByText(`${seeded.namespace.slug}/${seeded.skill.slug}`).first()).toBeVisible() + } finally { + await builder.cleanup() + } + }) + + test('redirects /dashboard/reviews to a namespace review page for namespace operators', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + await builder.createReviewData() + + await page.goto('/dashboard/reviews') + + await expect(page).toHaveURL(/\/dashboard\/namespaces\/.+\/reviews$/) + await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() + } finally { + await builder.cleanup() + } + }) + + test('redirects namespace review detail opened through the global detail route', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const seeded = await builder.createReviewData() + + await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`) + const reviewLink = page.getByRole('link', { name: 'Open review' }).first() + const reviewPath = await reviewLink.getAttribute('href') + const reviewId = reviewPath?.match(/\/reviews\/(\d+)$/)?.[1] + if (!reviewId) { + throw new Error(`Failed to resolve review id from href: ${reviewPath}`) + } + + await page.goto(`/dashboard/reviews/${reviewId}`) + + await expect(page).toHaveURL(new RegExp(`/dashboard/namespaces/${seeded.namespace.slug}/reviews/${reviewId}$`)) + await expect(page.getByRole('heading', { name: 'Review Detail' })).toBeVisible() + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index fd48292b6..536749003 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -85,22 +85,18 @@ const NamespaceReviewsPage = createLazyRouteComponent( () => import('@/pages/dashboard/namespace-reviews'), 'NamespaceReviewsPage', ) -const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage') -const ReviewsPage = createRoleProtectedRouteComponent( - () => import('@/pages/dashboard/reviews'), - 'ReviewsPage', - ['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'USER_ADMIN', 'SUPER_ADMIN'], +const NamespaceReviewDetailPage = createLazyRouteComponent( + () => import('@/pages/dashboard/review-detail'), + 'NamespaceReviewDetailPage', ) +const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage') +const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage') const ReportsPage = createRoleProtectedRouteComponent( () => import('@/pages/dashboard/reports'), 'ReportsPage', ['SKILL_ADMIN', 'SUPER_ADMIN'], ) -const ReviewDetailPage = createRoleProtectedRouteComponent( - () => import('@/pages/dashboard/review-detail'), - 'ReviewDetailPage', - ['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'SUPER_ADMIN'], -) +const ReviewDetailPage = createLazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage') const PromotionsPage = createRoleProtectedRouteComponent( () => import('@/pages/dashboard/promotions'), 'PromotionsPage', @@ -298,6 +294,13 @@ const dashboardReviewDetailRoute = createRoute({ component: ReviewDetailPage, }) +const dashboardNamespaceReviewDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/namespaces/$slug/reviews/$id', + beforeLoad: requireAuth, + component: NamespaceReviewDetailPage, +}) + const dashboardPromotionsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/promotions', @@ -408,6 +411,7 @@ const routeTree = rootRoute.addChildren([ dashboardNamespacesRoute, dashboardNamespaceMembersRoute, dashboardNamespaceReviewsRoute, + dashboardNamespaceReviewDetailRoute, dashboardGovernanceRoute, dashboardReviewsRoute, dashboardReportsRoute, diff --git a/web/src/features/review/review-paths.test.ts b/web/src/features/review/review-paths.test.ts new file mode 100644 index 000000000..bcd612005 --- /dev/null +++ b/web/src/features/review/review-paths.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { + buildGlobalReviewsPath, + buildNamespaceReviewDetailPath, + buildNamespaceReviewsPath, + canAccessGlobalReviewCenter, + canAccessReviewCenter, + canManageNamespaceReviews, + getPreferredNamespaceReviewEntry, +} from './review-paths' + +describe('review-paths', () => { + it('builds the global reviews path', () => { + expect(buildGlobalReviewsPath()).toBe('/dashboard/reviews') + }) + + it('builds namespace review paths', () => { + expect(buildNamespaceReviewsPath('team alpha')).toBe('/dashboard/namespaces/team%20alpha/reviews') + expect(buildNamespaceReviewDetailPath('team alpha', 12)).toBe('/dashboard/namespaces/team%20alpha/reviews/12') + }) + + it('detects global review access from platform roles', () => { + expect(canAccessGlobalReviewCenter(['SKILL_ADMIN'])).toBe(true) + expect(canAccessGlobalReviewCenter(['USER_ADMIN'])).toBe(true) + expect(canAccessGlobalReviewCenter(['SUPER_ADMIN'])).toBe(true) + expect(canAccessGlobalReviewCenter(['USER'])).toBe(false) + }) + + it('recognizes namespace review managers', () => { + expect(canManageNamespaceReviews('OWNER')).toBe(true) + expect(canManageNamespaceReviews('ADMIN')).toBe(true) + expect(canManageNamespaceReviews('MEMBER')).toBe(false) + }) + + it('prefers active team namespaces for namespace review entry', () => { + expect(getPreferredNamespaceReviewEntry([ + { + id: 1, + slug: 'archived-team', + displayName: 'Archived Team', + type: 'TEAM', + status: 'ARCHIVED', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + currentUserRole: 'ADMIN', + createdAt: '', + }, + { + id: 2, + slug: 'active-team', + displayName: 'Active Team', + type: 'TEAM', + status: 'ACTIVE', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + currentUserRole: 'OWNER', + createdAt: '', + }, + ])?.slug).toBe('active-team') + }) + + it('returns null when no manageable namespace exists', () => { + expect(getPreferredNamespaceReviewEntry([ + { + id: 1, + slug: 'member-team', + displayName: 'Member Team', + type: 'TEAM', + status: 'ACTIVE', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + currentUserRole: 'MEMBER', + createdAt: '', + }, + ])).toBeNull() + }) + + it('detects review center access from either platform roles or namespace roles', () => { + expect(canAccessReviewCenter(['SKILL_ADMIN'], [])).toBe(true) + expect(canAccessReviewCenter([], [ + { + id: 2, + slug: 'team-admin', + displayName: 'Team Admin', + type: 'TEAM', + status: 'ACTIVE', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + currentUserRole: 'ADMIN', + createdAt: '', + }, + ])).toBe(true) + expect(canAccessReviewCenter([], [])).toBe(false) + }) +}) diff --git a/web/src/features/review/review-paths.ts b/web/src/features/review/review-paths.ts new file mode 100644 index 000000000..081f3c04a --- /dev/null +++ b/web/src/features/review/review-paths.ts @@ -0,0 +1,48 @@ +import type { ManagedNamespace, NamespaceRole } from '@/api/types' + +const GLOBAL_REVIEW_PLATFORM_ROLES = ['SKILL_ADMIN', 'USER_ADMIN', 'SUPER_ADMIN'] as const + +export function buildGlobalReviewsPath() { + return '/dashboard/reviews' +} + +export function buildNamespaceReviewsPath(slug: string) { + return `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews` +} + +export function buildNamespaceReviewDetailPath(slug: string, reviewId: number) { + return `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews/${reviewId}` +} + +export function canAccessGlobalReviewCenter(platformRoles?: readonly string[]) { + return GLOBAL_REVIEW_PLATFORM_ROLES.some((role) => platformRoles?.includes(role)) +} + +export function canManageNamespaceReviews(role?: NamespaceRole) { + return role === 'OWNER' || role === 'ADMIN' +} + +export function getPreferredNamespaceReviewEntry( + namespaces?: readonly ManagedNamespace[], +) { + if (!namespaces?.length) { + return null + } + + const manageableNamespaces = namespaces.filter((namespace) => + namespace.type === 'TEAM' && canManageNamespaceReviews(namespace.currentUserRole), + ) + if (manageableNamespaces.length === 0) { + return null + } + + const activeNamespace = manageableNamespaces.find((namespace) => namespace.status === 'ACTIVE') + return activeNamespace ?? manageableNamespaces[0] +} + +export function canAccessReviewCenter( + platformRoles?: readonly string[], + namespaces?: readonly ManagedNamespace[], +) { + return canAccessGlobalReviewCenter(platformRoles) || getPreferredNamespaceReviewEntry(namespaces) !== null +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1f88708e2..8ccee244e 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1035,6 +1035,7 @@ "sortLabel": "Time Order", "sortNewest": "Newest first", "sortOldest": "Oldest first", + "openReview": "Open review", "pageSummary": "Total {{total}} records, page {{page}}", "prevPage": "Previous", "nextPage": "Next", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 92920e8fb..a910a263c 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1035,6 +1035,7 @@ "sortLabel": "时间排序", "sortNewest": "最新优先", "sortOldest": "最早优先", + "openReview": "进入审核详情", "pageSummary": "共 {{total}} 条记录,第 {{page}} 页", "prevPage": "上一页", "nextPage": "下一页", diff --git a/web/src/pages/dashboard/namespace-reviews.test.ts b/web/src/pages/dashboard/namespace-reviews.test.ts index 8e0310888..735307ca2 100644 --- a/web/src/pages/dashboard/namespace-reviews.test.ts +++ b/web/src/pages/dashboard/namespace-reviews.test.ts @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { createElement } from 'react' vi.mock('@tanstack/react-router', () => ({ + Link: ({ children, to }: { children: unknown; to: string }) => createElement('a', { href: to }, children as string), useParams: () => ({ slug: 'test-ns' }), })) @@ -127,6 +128,8 @@ describe('NamespaceReviewsPage', () => { const html = renderToStaticMarkup(createElement(NamespaceReviewsPage)) expect(html).toContain('nsReviews.pageSummary') + expect(html).toContain('/dashboard/namespaces/test-ns/reviews/1') + expect(html).toContain('nsReviews.openReview') expect(paginationProps).toHaveLength(1) expect(paginationProps[0]?.page).toBe(0) expect(paginationProps[0]?.totalPages).toBe(2) @@ -154,4 +157,18 @@ describe('NamespaceReviewsPage', () => { expect(paginationProps).toHaveLength(0) }) + + it('does not enable review queries before namespace detail resolves', () => { + useNamespaceDetailMock.mockReturnValue({ + data: undefined, + isLoading: true, + }) + + renderToStaticMarkup(createElement(NamespaceReviewsPage)) + + expect(useReviewListMock).toHaveBeenCalled() + for (const call of useReviewListMock.mock.calls) { + expect(call[5]).toBe(false) + } + }) }) diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx index 009d5f4f0..fb995e8ac 100644 --- a/web/src/pages/dashboard/namespace-reviews.tsx +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' -import { useParams } from '@tanstack/react-router' +import { Link, useParams } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { buildNamespaceReviewDetailPath } from '@/features/review/review-paths' import { formatLocalDateTime } from '@/shared/lib/date-time' import { Card } from '@/shared/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' @@ -15,8 +16,9 @@ type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' type TimeSortDirection = 'ASC' | 'DESC' const PAGE_SIZE = 10 -function ReviewListSection({ namespaceId }: { namespaceId?: number }) { +function ReviewListSection({ namespaceId, slug }: { namespaceId?: number; slug: string }) { const { t, i18n } = useTranslation() + const reviewsEnabled = typeof namespaceId === 'number' && namespaceId > 0 const [pages, setPages] = useState>({ PENDING: 0, APPROVED: 0, @@ -24,9 +26,9 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) { }) const [activeStatus, setActiveStatus] = useState('PENDING') const [sortDirection, setSortDirection] = useState('DESC') - const pending = useReviewList('PENDING', namespaceId, pages.PENDING, PAGE_SIZE, sortDirection, activeStatus === 'PENDING') - const approved = useReviewList('APPROVED', namespaceId, pages.APPROVED, PAGE_SIZE, sortDirection, activeStatus === 'APPROVED') - const rejected = useReviewList('REJECTED', namespaceId, pages.REJECTED, PAGE_SIZE, sortDirection, activeStatus === 'REJECTED') + const pending = useReviewList('PENDING', namespaceId, pages.PENDING, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'PENDING') + const approved = useReviewList('APPROVED', namespaceId, pages.APPROVED, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'APPROVED') + const rejected = useReviewList('REJECTED', namespaceId, pages.REJECTED, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'REJECTED') const changePage = (status: ReviewStatus, nextPage: number) => { setPages((current) => ({ ...current, [status]: nextPage })) @@ -90,6 +92,14 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) { {review.reviewComment ? (

{review.reviewComment}

) : null} +
+ + {t('nsReviews.openReview')} + +
))} {query.data ? renderPagination(status, query.data.totalElements, query.data.totalPages) : null} @@ -151,7 +161,7 @@ export function NamespaceReviewsPage() { {readOnlyMessage} ) : null} - + ) } diff --git a/web/src/pages/dashboard/review-detail.test.tsx b/web/src/pages/dashboard/review-detail.test.tsx index 84ba117b5..6b8cde319 100644 --- a/web/src/pages/dashboard/review-detail.test.tsx +++ b/web/src/pages/dashboard/review-detail.test.tsx @@ -5,7 +5,11 @@ const navigateMock = vi.fn() vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, - useParams: () => ({ id: '13' }), + useParams: (options?: { from?: string }) => ( + options?.from === '/dashboard/namespaces/$slug/reviews/$id' + ? { id: '13', slug: 'team-alpha' } + : { id: '13' } + ), })) vi.mock('react-i18next', async () => { @@ -112,6 +116,11 @@ vi.mock('@/features/review/use-review-detail', () => ({ }), })) +const userMock = { platformRoles: ['SKILL_ADMIN'] as string[] } +vi.mock('@/features/auth/use-auth', () => ({ + useAuth: () => ({ user: userMock }), +})) + // Mock hooks used directly by the review-detail page for file browser sidebar vi.mock('@/features/review/use-review-file', () => ({ useReviewFile: () => ({ data: null, isLoading: false, error: null }), @@ -122,11 +131,12 @@ vi.mock('@/api/client', () => ({ WEB_API_PREFIX: '/api/web', })) -import { ReviewDetailPage } from './review-detail' +import { NamespaceReviewDetailPage, ReviewDetailPage } from './review-detail' describe('ReviewDetailPage', () => { beforeEach(() => { navigateMock.mockReset() + userMock.platformRoles = ['SKILL_ADMIN'] useReviewDetailMock.mockReset() useReviewSkillDetailMock.mockReset() useReviewDetailMock.mockReturnValue({ @@ -206,6 +216,81 @@ describe('ReviewDetailPage', () => { expect(html).toContain('review.notFound') }) + it('renders namespace review detail through the namespace route wrapper', () => { + useReviewDetailMock.mockReturnValue({ + data: { + id: 13, + namespace: 'team-alpha', + skillSlug: 'demo-skill', + version: '1.2.0', + status: 'PENDING', + submittedBy: 'local-admin', + submittedByName: 'Local Admin', + submittedAt: '2026-03-19T00:00:00Z', + reviewedBy: null, + reviewedByName: null, + reviewedAt: null, + reviewComment: null, + }, + isLoading: false, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('review.detail') + expect(html).toContain('demo-skill') + }) + + it('redirects namespace reviews opened through the global route for namespace operators', () => { + userMock.platformRoles = [] + useReviewDetailMock.mockReturnValue({ + data: { + id: 13, + namespace: 'team-alpha', + skillSlug: 'demo-skill', + version: '1.2.0', + status: 'PENDING', + submittedBy: 'local-admin', + submittedByName: 'Local Admin', + submittedAt: '2026-03-19T00:00:00Z', + reviewedBy: null, + reviewedByName: null, + reviewedAt: null, + reviewComment: null, + }, + isLoading: false, + }) + + const html = renderToStaticMarkup() + + expect(html).toBe('') + }) + + it('shows not-found state when the namespace route slug does not match the review namespace', () => { + useReviewDetailMock.mockReturnValue({ + data: { + id: 13, + namespace: 'other-team', + skillSlug: 'demo-skill', + version: '1.2.0', + status: 'PENDING', + submittedBy: 'local-admin', + submittedByName: 'Local Admin', + submittedAt: '2026-03-19T00:00:00Z', + reviewedBy: null, + reviewedByName: null, + reviewedAt: null, + reviewComment: null, + }, + isLoading: false, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('review.notFound') + expect(html).toContain('review.backToList') + }) + it('disables approval and shows a scanning hint while the active review version is scanning', () => { useReviewSkillDetailMock.mockReturnValue({ data: { diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index 72a2a8dfe..959d5af37 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -1,7 +1,14 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useNavigate, useParams } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { ChevronDown, Folder } from 'lucide-react' +import { useAuth } from '@/features/auth/use-auth' +import { + buildGlobalReviewsPath, + buildNamespaceReviewDetailPath, + buildNamespaceReviewsPath, + canAccessGlobalReviewCenter, +} from '@/features/review/review-paths' import { formatLocalDateTime } from '@/shared/lib/date-time' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' @@ -25,11 +32,18 @@ import { useReviewDetail, useReviewSkillDetail, useApproveReview, useRejectRevie * interaction state because both actions depend on route-local confirmation * dialogs, comment input, and redirect behavior after completion. */ -export function ReviewDetailPage() { - const { id } = useParams({ from: '/dashboard/reviews/$id' }) +function ReviewDetailScreen({ + taskId, + backTo, + namespaceSlug, +}: { + taskId: number + backTo: string + namespaceSlug?: string +}) { const navigate = useNavigate() const { t, i18n } = useTranslation() - const taskId = Number(id) + const { user } = useAuth() const { data: review, isLoading } = useReviewDetail(taskId) const { @@ -40,7 +54,7 @@ export function ReviewDetailPage() { const approveMutation = useApproveReview({ onSuccess: () => { toast.success(t('review.approveSuccess')) - navigate({ to: '/dashboard/reviews' }) + navigate({ to: backTo }) }, onError: (error) => { toast.error(t('review.approveFailed'), resolveReviewActionErrorDescription(error)) @@ -49,7 +63,7 @@ export function ReviewDetailPage() { const rejectMutation = useRejectReview({ onSuccess: () => { toast.success(t('review.rejectSuccess')) - navigate({ to: '/dashboard/reviews' }) + navigate({ to: backTo }) }, onError: (error) => { toast.error(t('review.rejectFailed'), resolveReviewActionErrorDescription(error)) @@ -64,6 +78,12 @@ export function ReviewDetailPage() { const [fileBrowserOpen, setFileBrowserOpen] = useState(true) const [previewNode, setPreviewNode] = useState(null) const [previewDialogOpen, setPreviewDialogOpen] = useState(false) + const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles) + const shouldRedirectToNamespaceRoute = + !namespaceSlug && + !!review && + review.namespace !== 'global' && + !hasGlobalReviewAccess // File content for preview — uses the review-bound version via review file API const { data: previewContent, isLoading: isLoadingPreview, error: previewError } = useReviewFile( @@ -92,6 +112,17 @@ export function ReviewDetailPage() { return formatLocalDateTime(dateString, i18n.language) } + useEffect(() => { + if (!shouldRedirectToNamespaceRoute || !review) { + return + } + + void navigate({ + to: buildNamespaceReviewDetailPath(review.namespace, review.id), + replace: true, + }) + }, [navigate, review, shouldRedirectToNamespaceRoute]) + const handleApprove = async () => { approveMutation.mutate({ taskId, comment: comment || undefined }) } @@ -113,6 +144,10 @@ export function ReviewDetailPage() { ) } + if (shouldRedirectToNamespaceRoute) { + return null + } + if (!review) { return (
@@ -121,6 +156,23 @@ export function ReviewDetailPage() { ) } + const hasNamespaceMismatch = Boolean(namespaceSlug && review.namespace !== namespaceSlug) + + if (hasNamespaceMismatch) { + return ( +
+
+

{t('review.notFound')}

+
+
+ +
+
+ ) + } + const reviewFiles = reviewSkillDetail?.files const activeReviewVersion = reviewSkillDetail?.versions?.find( (version) => version.version === reviewSkillDetail.activeVersion @@ -136,7 +188,7 @@ export function ReviewDetailPage() {

{t('review.detail')}

{t('review.id')}: {review.id}

- @@ -363,3 +415,26 @@ export function ReviewDetailPage() { ) } + +export function ReviewDetailPage() { + const { id } = useParams({ from: '/dashboard/reviews/$id' }) + + return ( + + ) +} + +export function NamespaceReviewDetailPage() { + const { id, slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews/$id' }) + + return ( + + ) +} diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index 992c102bd..694a0dce9 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -67,8 +67,14 @@ vi.mock('@/features/review/use-review-list', () => ({ })) const hasRoleMock = vi.fn() +const userMock = { platformRoles: ['SKILL_ADMIN'] } vi.mock('@/features/auth/use-auth', () => ({ - useAuth: () => ({ hasRole: hasRoleMock }), + useAuth: () => ({ hasRole: hasRoleMock, user: userMock }), +})) + +const useMyNamespacesMock = vi.fn() +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useMyNamespaces: () => useMyNamespacesMock(), })) vi.mock('@/shared/components/dashboard-page-header', () => ({ @@ -106,7 +112,13 @@ describe('ReviewsPage', () => { paginationProps.length = 0 hasRoleMock.mockReset() useReviewListMock.mockReset() + useMyNamespacesMock.mockReset() hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN') + userMock.platformRoles = ['SKILL_ADMIN'] + useMyNamespacesMock.mockReturnValue({ + data: [], + isLoading: false, + }) useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { if (!enabled) { return { data: null, isLoading: false } diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 426da9b8b..4ead6c84b 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -1,10 +1,16 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { FileCheck2 } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +import { + buildNamespaceReviewsPath, + canAccessGlobalReviewCenter, + getPreferredNamespaceReviewEntry, +} from '@/features/review/review-paths' import { Table, TableBody, @@ -32,7 +38,8 @@ const PAGE_SIZE = 20 export function ReviewsPage() { const { t, i18n } = useTranslation() const navigate = useNavigate() - const { hasRole } = useAuth() + const { hasRole, user } = useAuth() + const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const [pages, setPages] = useState>({ PENDING: 0, APPROVED: 0, @@ -43,14 +50,29 @@ export function ReviewsPage() { const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN') + const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles) + const namespaceReviewEntry = getPreferredNamespaceReviewEntry(myNamespaces) const showTypeTabs = isSkillAdmin && isUserAdmin // Determine default top-level tab const defaultType = isSkillAdmin ? 'skill' : 'profile' - const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, activeStatus === 'PENDING') - const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, activeStatus === 'APPROVED') - const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, activeStatus === 'REJECTED') + useEffect(() => { + if (hasGlobalReviewAccess || isLoadingNamespaces) { + return + } + + if (namespaceReviewEntry) { + void navigate({ to: buildNamespaceReviewsPath(namespaceReviewEntry.slug), replace: true }) + return + } + + void navigate({ to: '/dashboard', replace: true }) + }, [hasGlobalReviewAccess, isLoadingNamespaces, namespaceReviewEntry, navigate]) + + const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'PENDING') + const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'APPROVED') + const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'REJECTED') const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language) @@ -211,6 +233,17 @@ export function ReviewsPage() { ) } + if (!hasGlobalReviewAccess) { + return ( +
+ + + Loading... + +
+ ) + } + return (
diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index d1a9cec04..f0281a83d 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next' import { Link } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { authApi } from '@/api/client' +import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' +import { buildGlobalReviewsPath, canAccessReviewCenter } from '@/features/review/review-paths' import { clearSessionScopedQueries } from '@/features/notification/notification-session' import { canViewGovernanceCenter } from '@/shared/lib/governance-access' import { cn } from '@/shared/lib/utils' @@ -22,19 +24,19 @@ interface UserMenuProps { export function UserMenu({ user, triggerClassName }: UserMenuProps) { const { t } = useTranslation() const queryClient = useQueryClient() + const { data: myNamespaces } = useMyNamespaces() const rootRef = useRef(null) const closeTimerRef = useRef(null) const [isHovered, setIsHovered] = useState(false) const [isClickOpen, setIsClickOpen] = useState(false) const hasRole = (role: string) => user.platformRoles?.includes(role) ?? false - const isReviewer = hasRole('SKILL_ADMIN') || hasRole('NAMESPACE_ADMIN') || hasRole('SUPER_ADMIN') const canSeeGovernance = canViewGovernanceCenter(user.platformRoles) const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN') const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN') const isSuperAdmin = hasRole('SUPER_ADMIN') - const canAccessReviewCenter = isReviewer || isUserAdmin + const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces) const isLocalAccount = !user.oauthProvider const open = isHovered || isClickOpen @@ -157,8 +159,8 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { {t('user.menu.stars')} - {canAccessReviewCenter ? ( - + {reviewCenterVisible ? ( + {t('user.menu.reviews')} ) : null} From 5dd89097e51c33f1a23376c50998bde3067dc2e8 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 14 Apr 2026 09:29:18 +0800 Subject: [PATCH 07/26] test(review): stabilize namespace review e2e setup --- web/e2e/helpers/review-seed.ts | 64 +++++++++++++++++ web/e2e/helpers/session.ts | 18 +++-- web/e2e/helpers/test-data-builder.ts | 72 ++++++++++++++++++- .../namespace-review-detail-access.spec.ts | 50 +++++-------- web/e2e/namespace-reviews-data.spec.ts | 18 +++-- 5 files changed, 172 insertions(+), 50 deletions(-) create mode 100644 web/e2e/helpers/review-seed.ts diff --git a/web/e2e/helpers/review-seed.ts b/web/e2e/helpers/review-seed.ts new file mode 100644 index 000000000..683ad2b5d --- /dev/null +++ b/web/e2e/helpers/review-seed.ts @@ -0,0 +1,64 @@ +import type { Browser, Page, TestInfo } from '@playwright/test' +import { loginWithCredentials, registerSession } from './session' +import { E2eTestDataBuilder, type SeededReviewData } from './test-data-builder' + +function getOptionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim() + return value ? value : undefined +} + +function adminCredentials() { + return { + username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin', + password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026', + } +} + +function matchCandidateUsername( + candidate: { userId: string; displayName: string; email?: string }, + username: string, +) { + return candidate.userId === username + || candidate.displayName === username + || candidate.email === `${username}@example.test` +} + +export async function createNamespaceReviewData( + browser: Browser, + page: Page, + testInfo: TestInfo, +): Promise Promise }> { + const credentials = await registerSession(page, testInfo, { allowMockSession: false }) + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + const adminContext = await browser.newContext() + const adminPage = await adminContext.newPage() + const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo) + + await loginWithCredentials(adminPage, adminCredentials(), testInfo) + await adminBuilder.init() + + const namespace = await adminBuilder.createNamespace('e2e-team') + const candidates = await adminBuilder.searchNamespaceMemberCandidates(namespace.slug, credentials.username) + const matchedCandidate = candidates.find((candidate) => matchCandidateUsername(candidate, credentials.username)) ?? candidates[0] + + if (!matchedCandidate) { + throw new Error(`No namespace member candidate found for review actor ${credentials.username}`) + } + + await adminBuilder.addNamespaceMember(namespace.slug, matchedCandidate.userId, 'ADMIN') + const skill = await builder.publishSkill(namespace.slug) + const reviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, skill.slug, skill.version) + + return { + namespace, + skill, + reviewTaskId, + cleanup: async () => { + await builder.cleanup() + await adminBuilder.cleanup() + await adminContext.close() + }, + } +} diff --git a/web/e2e/helpers/session.ts b/web/e2e/helpers/session.ts index 6b6ee60ff..763ef1c37 100644 --- a/web/e2e/helpers/session.ts +++ b/web/e2e/helpers/session.ts @@ -10,6 +10,10 @@ export interface TestCredentials { username: string } +interface RegisterSessionOptions { + allowMockSession?: boolean +} + interface SessionSnapshot { username: string cookies: Array<{ @@ -160,7 +164,7 @@ async function tryBootstrapMockSession(page: Page, worker: number): Promise<{ us return { username: 'local-user', password } } -async function registerSessionOnce(page: Page, testInfo?: TestInfo) { +async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) { const worker = testInfo?.parallelIndex ?? 0 const cached = cachedUserByWorker.get(worker) const username = usernameForWorker(testInfo) @@ -175,9 +179,11 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) { return { username: restored.username, password } } - const mockSession = await tryBootstrapMockSession(page, worker) - if (mockSession) { - return mockSession + if (options?.allowMockSession !== false) { + const mockSession = await tryBootstrapMockSession(page, worker) + if (mockSession) { + return mockSession + } } // Prefer the known-good cached account to avoid repeated failed-logins on a fixed username. @@ -317,12 +323,12 @@ async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) { throw new Error(`Failed to create fresh e2e session for worker ${worker}`) } -export async function registerSession(page: Page, testInfo?: TestInfo) { +export async function registerSession(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) { let lastError: unknown for (let attempt = 0; attempt < 3; attempt += 1) { try { - return await registerSessionOnce(page, testInfo) + return await registerSessionOnce(page, testInfo, options) } catch (error) { lastError = error if (attempt < 2) { diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 7d7d4e975..7d0717376 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -34,6 +34,13 @@ interface ReviewTaskSummary { version: string } +interface NamespaceCandidate { + userId: string + displayName: string + email?: string + status: string +} + interface ApiEnvelope { code: number msg: string @@ -45,6 +52,8 @@ interface ApiFailure extends Error { code?: number } +const cleanupTimeoutMs = process.env.CI ? 8_000 : 5_000 + export interface SeedSkillOptions { name?: string description?: string @@ -65,6 +74,24 @@ function uniqueSuffix(testInfo?: TestInfo): string { return `${worker}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` } +async function runCleanupTaskWithTimeout(task: CleanupTask): Promise { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`cleanup task timed out after ${cleanupTimeoutMs}ms`)) + }, cleanupTimeoutMs) + + void task() + .then(() => { + clearTimeout(timeout) + resolve() + }) + .catch((error) => { + clearTimeout(timeout) + reject(error) + }) + }) +} + function buildSkillPackageContent(suffix: string, options?: SeedSkillOptions) { const skillName = (options?.name || `e2e-skill-${suffix}`).slice(0, 48) const description = options?.description || 'E2E generated skill for real-request tests' @@ -166,7 +193,7 @@ export class E2eTestDataBuilder { async cleanup(): Promise { for (let i = this.cleanupTasks.length - 1; i >= 0; i -= 1) { try { - await this.cleanupTasks[i]() + await runCleanupTaskWithTimeout(this.cleanupTasks[i]) } catch { // Best-effort cleanup for E2E environments. } @@ -232,6 +259,32 @@ export class E2eTestDataBuilder { return writable } + async ensureReviewableNamespace(): Promise { + if (this.ensuredNamespace && this.ensuredNamespace.slug !== 'global') { + return this.ensuredNamespace + } + + try { + const created = await this.createNamespace('e2e-team') + this.ensuredNamespace = created + return created + } catch (error) { + const failure = error as ApiFailure + if (failure.status !== 403) { + throw error + } + } + + const namespaces = await this.listMyNamespaces() + const reviewableNamespace = namespaces.find((item) => item.slug !== 'global') + if (!reviewableNamespace) { + throw new Error('No TEAM namespace available for review E2E data seeding') + } + + this.ensuredNamespace = reviewableNamespace + return reviewableNamespace + } + private async getMySkillInNamespace(namespaceSlug: string): Promise { const page = await parseEnvelope<{ items: Array<{ @@ -350,6 +403,21 @@ export class E2eTestDataBuilder { ) } + async searchNamespaceMemberCandidates(slug: string, search: string): Promise { + const query = new URLSearchParams({ search }) + return parseEnvelope( + await this.request.get(`/api/web/namespaces/${encodeURIComponent(slug)}/member-candidates?${query.toString()}`), + ) + } + + async addNamespaceMember(slug: string, userId: string, role: 'MEMBER' | 'ADMIN' | 'OWNER' = 'MEMBER'): Promise { + await parseEnvelope<{ userId: string; role: string }>( + await this.request.post(`/api/web/namespaces/${encodeURIComponent(slug)}/members`, { + data: { userId, role }, + }), + ) + } + async publishSkill(namespaceSlug: string, options?: SeedSkillOptions): Promise { const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` const zipBuffer = buildSkillPackageZipBuffer(unique, options) @@ -384,7 +452,7 @@ export class E2eTestDataBuilder { } async createReviewData(): Promise { - const namespace = await this.ensureWritableNamespace() + const namespace = await this.ensureReviewableNamespace() const skill = await this.publishSkill(namespace.slug) return { namespace, skill } } diff --git a/web/e2e/namespace-review-detail-access.spec.ts b/web/e2e/namespace-review-detail-access.spec.ts index d3be17418..1daac06d9 100644 --- a/web/e2e/namespace-review-detail-access.spec.ts +++ b/web/e2e/namespace-review-detail-access.spec.ts @@ -1,20 +1,18 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -import { registerSession } from './helpers/session' -import { E2eTestDataBuilder } from './helpers/test-data-builder' +import { createNamespaceReviewData } from './helpers/review-seed' test.describe('Namespace Review Detail Access (Real API)', () => { - test.beforeEach(async ({ page }, testInfo) => { + test.describe.configure({ timeout: 120_000 }) + + test.beforeEach(async ({ page }) => { await setEnglishLocale(page) - await registerSession(page, testInfo) }) - test('opens namespace review detail from the namespace review list', async ({ page }, testInfo) => { - const builder = new E2eTestDataBuilder(page, testInfo) - await builder.init() - + test('opens namespace review detail from the namespace review list', async ({ browser, page }, testInfo) => { + let seeded: Awaited> | undefined try { - const seeded = await builder.createReviewData() + seeded = await createNamespaceReviewData(browser, page, testInfo) await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`) @@ -27,47 +25,35 @@ test.describe('Namespace Review Detail Access (Real API)', () => { await expect(page.getByRole('heading', { name: 'Review Detail' })).toBeVisible() await expect(page.getByText(`${seeded.namespace.slug}/${seeded.skill.slug}`).first()).toBeVisible() } finally { - await builder.cleanup() + await seeded?.cleanup() } }) - test('redirects /dashboard/reviews to a namespace review page for namespace operators', async ({ page }, testInfo) => { - const builder = new E2eTestDataBuilder(page, testInfo) - await builder.init() - + test('redirects /dashboard/reviews to a namespace review page for namespace operators', async ({ browser, page }, testInfo) => { + let seeded: Awaited> | undefined try { - await builder.createReviewData() + seeded = await createNamespaceReviewData(browser, page, testInfo) await page.goto('/dashboard/reviews') await expect(page).toHaveURL(/\/dashboard\/namespaces\/.+\/reviews$/) await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() } finally { - await builder.cleanup() + await seeded?.cleanup() } }) - test('redirects namespace review detail opened through the global detail route', async ({ page }, testInfo) => { - const builder = new E2eTestDataBuilder(page, testInfo) - await builder.init() - + test('redirects namespace review detail opened through the global detail route', async ({ browser, page }, testInfo) => { + let seeded: Awaited> | undefined try { - const seeded = await builder.createReviewData() + seeded = await createNamespaceReviewData(browser, page, testInfo) - await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`) - const reviewLink = page.getByRole('link', { name: 'Open review' }).first() - const reviewPath = await reviewLink.getAttribute('href') - const reviewId = reviewPath?.match(/\/reviews\/(\d+)$/)?.[1] - if (!reviewId) { - throw new Error(`Failed to resolve review id from href: ${reviewPath}`) - } + await page.goto(`/dashboard/reviews/${seeded.reviewTaskId}`) - await page.goto(`/dashboard/reviews/${reviewId}`) - - await expect(page).toHaveURL(new RegExp(`/dashboard/namespaces/${seeded.namespace.slug}/reviews/${reviewId}$`)) + await expect(page).toHaveURL(new RegExp(`/dashboard/namespaces/${seeded.namespace.slug}/reviews/${seeded.reviewTaskId}$`)) await expect(page.getByRole('heading', { name: 'Review Detail' })).toBeVisible() } finally { - await builder.cleanup() + await seeded?.cleanup() } }) }) diff --git a/web/e2e/namespace-reviews-data.spec.ts b/web/e2e/namespace-reviews-data.spec.ts index a173ecaae..b2cad5c6a 100644 --- a/web/e2e/namespace-reviews-data.spec.ts +++ b/web/e2e/namespace-reviews-data.spec.ts @@ -1,26 +1,24 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -import { registerSession } from './helpers/session' -import { E2eTestDataBuilder } from './helpers/test-data-builder' +import { createNamespaceReviewData } from './helpers/review-seed' test.describe('Namespace Reviews Data (Real API)', () => { - test.beforeEach(async ({ page }, testInfo) => { + test.describe.configure({ timeout: 120_000 }) + + test.beforeEach(async ({ page }) => { await setEnglishLocale(page) - await registerSession(page, testInfo) }) - test('opens namespace reviews page with seeded review data context', async ({ page }, testInfo) => { - const builder = new E2eTestDataBuilder(page, testInfo) - await builder.init() - + test('opens namespace reviews page with seeded review data context', async ({ browser, page }, testInfo) => { + let seeded: Awaited> | undefined try { - const seeded = await builder.createReviewData() + seeded = await createNamespaceReviewData(browser, page, testInfo) await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`) await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() await expect(page.getByText(`Review tasks for ${seeded.namespace.displayName}`)).toBeVisible() } finally { - await builder.cleanup() + await seeded?.cleanup() } }) }) From c9b0231393c0041567809f52b3a0694f4f087b4e Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 14 Apr 2026 10:26:10 +0800 Subject: [PATCH 08/26] fix(docs): correct aliyun runtime command --- README.md | 4 ++-- README_zh.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index baa7d8e0b..49cb166d1 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ The `--public-url` parameter sets the public access URL for your SkillHub instan **For users in China (Aliyun mirror):** ```bash -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest ``` If deployment runs into problems, clear the existing runtime home and retry. @@ -195,7 +195,7 @@ Published images target both `linux/amd64` and `linux/arm64`. curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com # Aliyun mirror (recommended for users in China) -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest ``` **Deployment parameters:** diff --git a/README_zh.md b/README_zh.md index 8a7c74094..bede1c755 100644 --- a/README_zh.md +++ b/README_zh.md @@ -67,7 +67,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u **国内用户(阿里云镜像):** ```bash -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest ``` 如果部署遇到问题,请清除现有的运行时目录并重试。 @@ -177,7 +177,7 @@ skillhub/ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com # 阿里云镜像(国内推荐) -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest ``` ### 配置参数说明 From b26fe6a36451025666f65864e8f6fc80647f30e2 Mon Sep 17 00:00:00 2001 From: xiose Date: Tue, 14 Apr 2026 11:58:15 +0800 Subject: [PATCH 09/26] fix(rerelease): support precheck warning confirmation flow - Backend: Add confirmWarnings parameter to rerelease DTO, domain service, and app service - Frontend: Add warning dialog with retry logic when precheck warnings are detected - i18n: Add rerelease warning dialog translations (en/zh) Fixes the issue where rereleasing a published version with secret detection warnings always fails with 400 error. Now follows the same confirm-and-retry pattern as initial publish. --- .../dto/SkillVersionRereleaseRequest.java | 3 +- .../service/SkillLifecycleAppService.java | 3 +- .../portal/SkillLifecycleControllerTest.java | 9 +- .../skill/service/SkillPublishService.java | 5 +- .../service/SkillPublishServiceTest.java | 42 +++++++- web/src/api/client.ts | 4 +- web/src/i18n/locales/en.json | 3 + web/src/i18n/locales/zh.json | 3 + web/src/pages/skill-detail.tsx | 96 +++++++++++++------ web/src/shared/hooks/use-skill-queries.ts | 7 +- 10 files changed, 133 insertions(+), 42 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java index 6ca9e708e..b3d61fc93 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank; public record SkillVersionRereleaseRequest( @NotBlank(message = "{validation.required}") - String targetVersion + String targetVersion, + boolean confirmWarnings ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java index 5670ebef9..e7f86d459 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java @@ -154,7 +154,8 @@ public class SkillLifecycleAppService { skillVersion.getVersion(), targetVersion, userId, - normalizeRoles(userNamespaceRoles) + normalizeRoles(userNamespaceRoles), + request.confirmWarnings() ); auditLogService.record( userId, 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 acf5a24f2..04d0945e0 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 @@ -212,7 +212,8 @@ class SkillLifecycleControllerTest { eq("1.2.3"), eq("1.2.4"), eq("usr_1"), - anyMap())) + anyMap(), + eq(false))) .willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion)); mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease") @@ -279,7 +280,8 @@ class SkillLifecycleControllerTest { eq("1.2.3"), eq("1.2.4"), eq("usr_1"), - anyMap())) + anyMap(), + eq(false))) .willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion)); mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease") @@ -299,7 +301,8 @@ class SkillLifecycleControllerTest { eq("1.2.3"), eq("1.2.4"), eq("usr_1"), - anyMap()); + anyMap(), + eq(false)); } private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 07ca9735f..4cf72082d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -156,7 +156,8 @@ public class SkillPublishService { String sourceVersion, String targetVersion, String publisherId, - Map userNamespaceRoles) { + Map userNamespaceRoles, + boolean confirmWarnings) { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); assertCanManageLifecycle(skill, publisherId, userNamespaceRoles); @@ -181,7 +182,7 @@ public class SkillPublishService { publisherId, skill.getVisibility(), Set.of(), - false, // confirmWarnings=false: no warnings to confirm for rerelease + confirmWarnings, // confirmWarnings: honour caller's choice for rerelease false, // forceAutoPublish=false: respect visibility rules true ); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index ae0918893..bff1c0f93 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -855,7 +855,8 @@ class SkillPublishServiceTest { "1.2.3", "1.2.4", publisherId, - Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER) + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + false ); assertEquals("1.2.4", result.version().getVersion()); @@ -892,7 +893,8 @@ class SkillPublishServiceTest { "1.2.3", "1.2.4", publisherId, - Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER) + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + false )); } @@ -953,7 +955,8 @@ class SkillPublishServiceTest { "1.2.3", "1.2.4", publisherId, - Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER) + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + false ); assertEquals("1.2.4", result.version().getVersion()); @@ -999,6 +1002,39 @@ class SkillPublishServiceTest { )); } + @Test + void testPublishFromEntries_ShouldRejectWithPrivateConflictWhenOtherOwnerHasPrivatePublishedSkill() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-200"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PRIVATE); + setId(existingSkill, 1L); + SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100"); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, entries, publisherId, SkillVisibility.PRIVATE, Set.of() + )); + assertEquals("error.skill.publish.nameConflict.private", ex.messageCode()); + } + @Test void testPublishFromEntries_ShouldAllowWhenOtherOwnerHasNonPublishedSkill() throws Exception { String namespaceSlug = "test-ns"; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 714111e39..c306c1467 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -466,14 +466,14 @@ export const skillLifecycleApi = { }) }, - async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise { + async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string, confirmWarnings = false): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/rerelease`, { method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json', }), - body: JSON.stringify({ targetVersion }), + body: JSON.stringify({ targetVersion, confirmWarnings }), }) }, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3af596c91..fa5ec4b80 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -881,6 +881,9 @@ "rereleaseSuccessTitle": "Version re-released", "rereleaseSuccessDescription": "Created v{{target}} from v{{source}}.", "rereleaseErrorTitle": "Failed to re-release version", + "rereleaseWarningTitle": "Pre-publish warning", + "rereleaseWarningDescription": "We found the following risk reminders. If you understand them and still want to proceed, you can continue re-releasing.", + "rereleaseWarningConfirm": "Continue re-releasing", "yankVersion": "Yank Current Version", "promoteToGlobal": "Promote to Global", "promotionSectionTitle": "Promote to Global", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 17c2eb9a2..7d0079221 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -882,6 +882,9 @@ "rereleaseSuccessTitle": "版本已重新发布", "rereleaseSuccessDescription": "已基于 v{{source}} 创建新版本 v{{target}}。", "rereleaseErrorTitle": "重新发布版本失败", + "rereleaseWarningTitle": "发布前风险提醒", + "rereleaseWarningDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续重新发布。", + "rereleaseWarningConfirm": "继续重新发布", "yankVersion": "撤回当前版本", "promoteToGlobal": "申请提升到全局", "promotionSectionTitle": "提升到全局", diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index d28b4f75d..467f359e3 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, RefreshCw, ShieldCheck, Terminal, User } from 'lucide-react' +import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' import { FileTree } from '@/features/skill/file-tree' import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' @@ -10,12 +10,14 @@ import type { FileTreeNode } from '@/features/skill/file-tree-builder' import { InstallCommand } from '@/features/skill/install-command' import { ShareButton } from '@/features/skill/share-button' import { SkillLabelPanel } from '@/features/skill/skill-label-panel' +import { VersionStatusBadge, getVersionRowStyle } from '@/features/skill/version-status-badge' import { getOverviewCollapseMaxHeight, OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT, shouldCollapseOverview, } from '@/features/skill/overview-collapse' import { resolveSkillActionErrorTitle } from '@/features/skill/skill-action-error' +import { isPrecheckConfirmationMessage, extractPrecheckWarnings } from '@/features/publish/publish-error-utils' import { clearDeletedSkillQueries, isDeleteSlugConfirmationValid, resolveDeletedSkillReturnTo } from '@/features/skill/skill-delete-flow' import { isSkillDetailQueriesEnabled } from './skill-detail-query' import { RatingInput } from '@/features/social/rating-input' @@ -116,6 +118,8 @@ export function SkillDetailPage() { const [withdrawVersionTarget, setWithdrawVersionTarget] = useState(null) const [rereleaseTarget, setRereleaseTarget] = useState(null) const [targetVersionInput, setTargetVersionInput] = useState('') + const [rereleaseWarnings, setRereleaseWarnings] = useState([]) + const [rereleaseWarningDialogOpen, setRereleaseWarningDialogOpen] = useState(false) const [diffSourceVersion, setDiffSourceVersion] = useState(null) const [confirmPublishTarget, setConfirmPublishTarget] = useState(null) const [submitReviewTarget, setSubmitReviewTarget] = useState(null) @@ -381,19 +385,6 @@ export function SkillDetailPage() { return status ?? '' } - const resolveVersionStatusLabel = (status?: string) => { - const map: Record = { - DRAFT: t('skillDetail.versionStatusDraft'), - SCANNING: t('skillDetail.versionStatusScanning'), - SCAN_FAILED: t('skillDetail.versionStatusScanFailed'), - UPLOADED: t('skillDetail.versionStatusUploaded'), - PENDING_REVIEW: t('skillDetail.versionStatusPendingReview'), - PUBLISHED: t('skillDetail.versionStatusPublished'), - REJECTED: t('skillDetail.versionStatusRejected'), - YANKED: t('skillDetail.versionStatusYanked'), - } - return status ? (map[status] ?? status) : '' - } const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED' || status === 'SCAN_FAILED' || status === 'UPLOADED' const isLastVersion = versions?.length === 1 @@ -575,7 +566,7 @@ export function SkillDetailPage() { setTargetVersionInput(suggestNextVersion(version)) } - const handleRereleaseVersion = async () => { + const handleRereleaseVersion = async (confirmWarnings = false) => { if (!rereleaseTarget || !targetVersionInput.trim()) { return } @@ -585,6 +576,7 @@ export function SkillDetailPage() { slug, version: rereleaseTarget, targetVersion: targetVersionInput.trim(), + confirmWarnings, }) toast.success( t('skillDetail.rereleaseSuccessTitle'), @@ -592,7 +584,15 @@ export function SkillDetailPage() { ) setRereleaseTarget(null) setTargetVersionInput('') + setRereleaseWarnings([]) + setRereleaseWarningDialogOpen(false) } catch (error) { + if (error instanceof ApiError && isPrecheckConfirmationMessage(error.serverMessage)) { + const warnings = extractPrecheckWarnings(error.serverMessage) + setRereleaseWarnings(warnings) + setRereleaseWarningDialogOpen(true) + return + } toast.error(t('skillDetail.rereleaseErrorTitle'), error instanceof Error ? error.message : '') throw error } @@ -710,10 +710,31 @@ export function SkillDetailPage() {
{skill.status && ( - + {resolveSkillStatusLabel(skill.status)} )} + {skill.visibility && ( + + {skill.visibility === 'PUBLIC' && } + {skill.visibility === 'PRIVATE' && } + {skill.visibility === 'NAMESPACE_ONLY' && } + {skill.visibility === 'PUBLIC' && t('publish.visibilityOptions.public')} + {skill.visibility === 'PRIVATE' && t('publish.visibilityOptions.private')} + {skill.visibility === 'NAMESPACE_ONLY' && t('publish.visibilityOptions.namespaceOnly')} + + )} {isReviewFlowPending && ( {t('skillDetail.versionStatusPendingReview')} @@ -862,21 +883,19 @@ export function SkillDetailPage() { - {versions && versions.length > 0 ? ( -
+
{versions.map((version) => ( -
+
v{version.version} - {version.status && ( - - {resolveVersionStatusLabel(version.status)} - - )} + {headlineVersion?.version === version.version && ( {t(hasPublishedPendingReview && publishedVersion?.version === version.version @@ -928,7 +947,6 @@ export function SkillDetailPage() { {skill.canManageLifecycle && version.status === 'UPLOADED' && skill.visibility === 'PRIVATE' && (
) : ( -
{t('skillDetail.noVersions')}
+ {t('skillDetail.noVersions')} )} -
@@ -1396,6 +1413,8 @@ export function SkillDetailPage() { if (!open) { setRereleaseTarget(null) setTargetVersionInput('') + setRereleaseWarnings([]) + setRereleaseWarningDialogOpen(false) } }} > @@ -1422,13 +1441,34 @@ export function SkillDetailPage() { -
+ +

{t('skillDetail.rereleaseWarningDescription')}

+
    + {rereleaseWarnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+ + } + confirmText={t('skillDetail.rereleaseWarningConfirm')} + onConfirm={() => { + setRereleaseWarningDialogOpen(false) + handleRereleaseVersion(true) + }} + /> + { diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 4784ada54..cf19d18cc 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -206,8 +206,11 @@ 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), + mutationFn: ({ namespace, slug, version, targetVersion, confirmWarnings }: { namespace: string; slug: string; version: string; targetVersion: string; confirmWarnings?: boolean }) => + skillLifecycleApi.rereleaseVersion(namespace, slug, version, targetVersion, confirmWarnings), + meta: { + skipGlobalErrorHandler: true, + }, onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] }) From 7dff8dc697714a5053ee90f43f747cc44c74819d Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 14 Apr 2026 13:55:33 +0800 Subject: [PATCH 10/26] test(e2e): stabilize publish and search waits --- web/e2e/publish-flow-ui.spec.ts | 23 ++++++- web/e2e/search-card-interaction.spec.ts | 83 ++++++++++++++++--------- 2 files changed, 73 insertions(+), 33 deletions(-) diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts index a1f1ef76a..7153edf57 100644 --- a/web/e2e/publish-flow-ui.spec.ts +++ b/web/e2e/publish-flow-ui.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test' +import path from 'node:path' import { setEnglishLocale } from './helpers/auth-fixtures' import { registerSession } from './helpers/session' import { E2eTestDataBuilder } from './helpers/test-data-builder' @@ -20,11 +21,27 @@ test.describe('Publish Flow UI (Real API)', () => { await page.goto('/dashboard/publish') await expect(page.getByRole('heading', { name: 'Publish Skill' })).toBeVisible() - await page.locator('#namespace').click() - await page.getByText(new RegExp(`\\(@${namespace.slug}\\)`)).first().click() + const namespaceTrigger = page.locator('#namespace') + await expect(namespaceTrigger).toBeVisible() + await namespaceTrigger.click() + const namespaceOption = page.getByRole('option', { + name: new RegExp(`\\(@${namespace.slug}\\)`), + }).first() + await expect(namespaceOption).toBeVisible() + await namespaceOption.evaluate((element: HTMLElement) => { + element.scrollIntoView({ block: 'center' }) + element.click() + }) + await expect(namespaceTrigger).toContainText(`@${namespace.slug}`) await page.locator('input[type="file"]').setInputFiles(packagePath) - await page.getByRole('button', { name: 'Confirm Publish' }).click() + await expect(page.getByText(path.basename(packagePath))).toBeVisible() + const confirmButton = page.getByRole('button', { name: 'Confirm Publish' }) + await expect(confirmButton).toBeEnabled() + await Promise.all([ + page.waitForURL('**/dashboard/skills'), + confirmButton.click(), + ]) await expect(page).toHaveURL('/dashboard/skills') await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible() diff --git a/web/e2e/search-card-interaction.spec.ts b/web/e2e/search-card-interaction.spec.ts index 3fca443a5..1deadc585 100644 --- a/web/e2e/search-card-interaction.spec.ts +++ b/web/e2e/search-card-interaction.spec.ts @@ -30,38 +30,61 @@ async function waitForCards(page: Page) { const keyword = basicSeed?.keyword const encodedKeyword = keyword ? encodeURIComponent(keyword) : null + let reloaded = false - for (let attempt = 0; attempt < 4; attempt += 1) { - await page.waitForLoadState('networkidle') + const waitForMatchingResponse = async () => { + if (!encodedKeyword) { + return + } + + await page.waitForResponse(async (response) => { + if (!response.url().includes('/api/web/skills?') || !response.url().includes(`q=${encodedKeyword}`)) { + return false + } + if (response.status() !== 200) { + return false + } + + try { + const payload = await response.json() as { data?: { items?: Array } } + return Array.isArray(payload.data?.items) && payload.data.items.length > 0 + } catch { + return false + } + }, { timeout: 15_000 }).catch(() => null) + } + + const waitForCardCount = async () => { + await expect.poll( + async () => cards.count(), + { + timeout: 20_000, + intervals: [250, 500, 1_000, 2_000], + }, + ).toBeGreaterThan(0) + } + + await page.waitForLoadState('networkidle') + await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 }) + + if (await cards.count() > 0) { + return cards + } + + await waitForMatchingResponse() + + try { + await waitForCardCount() + } catch { + if (reloaded) { + throw new Error('Timed out waiting for search cards after one reload fallback') + } + + reloaded = true + await page.reload({ waitUntil: 'networkidle' }) await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 }) - - if (await cards.count() > 0) { - return cards - } - - if (attempt < 3) { - const responsePromise = encodedKeyword - ? page.waitForResponse(async (response) => { - if (!response.url().includes('/api/web/skills?') || !response.url().includes(`q=${encodedKeyword}`)) { - return false - } - if (response.status() !== 200) { - return false - } - - try { - const payload = await response.json() as { data?: { items?: Array } } - return Array.isArray(payload.data?.items) && payload.data.items.length > 0 - } catch { - return false - } - }, { timeout: 12_000 }).catch(() => null) - : Promise.resolve(null) - - await page.waitForTimeout(750 * (attempt + 1)) - await page.reload({ waitUntil: 'networkidle' }) - await responsePromise - } + await waitForMatchingResponse() + await waitForCardCount() } return cards From cc8b56e26c0a403519b841be906607cf86c8f2ee Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 14 Apr 2026 14:17:57 +0800 Subject: [PATCH 11/26] test(e2e): avoid load-event wait in publish flow --- web/e2e/publish-flow-ui.spec.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts index 7153edf57..af27b7388 100644 --- a/web/e2e/publish-flow-ui.spec.ts +++ b/web/e2e/publish-flow-ui.spec.ts @@ -38,13 +38,10 @@ test.describe('Publish Flow UI (Real API)', () => { await expect(page.getByText(path.basename(packagePath))).toBeVisible() const confirmButton = page.getByRole('button', { name: 'Confirm Publish' }) await expect(confirmButton).toBeEnabled() - await Promise.all([ - page.waitForURL('**/dashboard/skills'), - confirmButton.click(), - ]) + await confirmButton.click() - await expect(page).toHaveURL('/dashboard/skills') - await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible() + await expect(page).toHaveURL(/\/dashboard\/skills$/, { timeout: 90_000 }) + await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible({ timeout: 90_000 }) } finally { await builder.cleanup() } From 9801b549fc10b1a943251c4465daa276fc042dfb Mon Sep 17 00:00:00 2001 From: xiose Date: Tue, 14 Apr 2026 12:07:25 +0800 Subject: [PATCH 12/26] feat(access): add SUPER_ADMIN platform role support - Add platformRoles parameter to VisibilityChecker.canAccess() for platform-level access control - SUPER_ADMIN can access all skills regardless of visibility or publication status - Add archived namespace check to SkillQueryService.getSkillDetail() - Extract platformRoles from AuthContext in SkillController - Replace VisibilityChecker mock with real instance in SkillQueryServiceTest - Add 5 new tests for SUPER_ADMIN access scenarios - Add version-status-badge.tsx component for frontend status display Tests: 347 domain tests + 16 app tests passing --- .../controller/portal/SkillController.java | 6 +- .../skillhub/filter/AuthContextFilter.java | 1 + .../controller/SkillControllerTest.java | 7 +- .../portal/SkillPublishControllerTest.java | 7 +- .../domain/skill/VisibilityChecker.java | 12 +++ .../skill/service/SkillQueryService.java | 32 ++++++- .../domain/skill/VisibilityCheckerTest.java | 31 +++++++ .../skill/service/SkillQueryServiceTest.java | 36 +------- .../features/skill/version-status-badge.tsx | 92 +++++++++++++++++++ 9 files changed, 183 insertions(+), 41 deletions(-) create mode 100644 web/src/features/skill/version-status-badge.tsx diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 3e4800601..6d582cae3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -70,10 +70,12 @@ public class SkillController extends BaseApiController { @PathVariable String namespace, @PathVariable String slug, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) java.util.Set platformRoles) { SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail( - namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of(), + platformRoles != null ? platformRoles : java.util.Set.of()); SkillDetailResponse response = new SkillDetailResponse( detail.id(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index 366038ee8..d4df24c38 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -73,6 +73,7 @@ public class AuthContextFilter extends OncePerRequestFilter { return; } request.setAttribute("userId", principal.userId()); + request.setAttribute("platformRoles", principal.platformRoles() != null ? principal.platformRoles() : java.util.Set.of()); Map userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream() .collect(Collectors.toMap( NamespaceMember::getNamespaceId, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 677336c78..800c6a7a5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -22,6 +22,7 @@ import java.util.Map; import java.util.TimeZone; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.when; import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -148,7 +149,8 @@ class SkillControllerTest { eq("team"), eq("demo"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + org.mockito.ArgumentMatchers.>any())) .thenReturn(new SkillQueryService.SkillDetailDTO( 1L, "demo", @@ -195,7 +197,8 @@ class SkillControllerTest { eq("team"), eq("demo"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + org.mockito.ArgumentMatchers.>any())) .thenThrow(new DomainForbiddenException("error.namespace.archived", "team")); mockMvc.perform(get("/api/web/skills/team/demo")) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java index 53c8367ab..ae5fd27bb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java @@ -4,6 +4,9 @@ import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.mockito.ArgumentMatchers; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; @@ -69,7 +72,7 @@ class SkillPublishControllerTest { given(skillPublishService.publishFromEntries( eq("global"), - anyList(), + ArgumentMatchers.>any(), eq("usr_1"), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), @@ -120,7 +123,7 @@ class SkillPublishControllerTest { given(skillPublishService.publishFromEntries( eq("global"), - anyList(), + ArgumentMatchers.>any(), eq("usr_1"), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index f46321595..65c8e7538 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import java.util.Map; +import java.util.Set; /** * Evaluates whether a caller may read a skill based on publication state, visibility, ownership, @@ -11,6 +12,13 @@ import java.util.Map; public class VisibilityChecker { public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles) { + return canAccess(skill, currentUserId, userNamespaceRoles, Set.of()); + } + + public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles, Set platformRoles) { + if (isSuperAdmin(platformRoles)) { + return true; + } if (skill.isHidden()) { return isOwner(skill, currentUserId) || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId())); } @@ -31,4 +39,8 @@ public class VisibilityChecker { private boolean isAdminOrAbove(NamespaceRole role) { return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; } + + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 0a64260c4..2f01d5fd1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -30,6 +30,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; /** @@ -149,12 +150,28 @@ public class SkillQueryService { String skillSlug, String currentUserId, Map userNsRoles) { + return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles, Set.of()); + } + + public SkillDetailDTO getSkillDetail( + String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + // Archived namespace: only members (or super admins) may view + if (namespace.getStatus() == com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED + && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles) + && !isSuperAdmin(platformRoles)) { + throw new DomainForbiddenException("error.namespace.archived", namespaceSlug); + } + // Visibility check - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles, platformRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skillSlug); } @@ -186,7 +203,7 @@ public class SkillQueryService { skill.getNamespaceId(), skill.getCreatedAt(), skill.getUpdatedAt(), - canManageRestrictedSkill(skill, currentUserId, userNsRoles), + canManageRestrictedSkill(skill, currentUserId, userNsRoles, platformRoles), canSubmitPromotion(namespace, skill, publishedVersion, currentUserId, userNsRoles), headlineVersion == null || "PUBLISHED".equals(headlineVersion.status()), currentUserId == null || !Objects.equals(skill.getOwnerId(), currentUserId), @@ -630,6 +647,13 @@ public class SkillQueryService { } private boolean canManageRestrictedSkill(Skill skill, String currentUserId, Map userNsRoles) { + return canManageRestrictedSkill(skill, currentUserId, userNsRoles, Set.of()); + } + + private boolean canManageRestrictedSkill(Skill skill, String currentUserId, Map userNsRoles, Set platformRoles) { + if (platformRoles != null && platformRoles.contains("SUPER_ADMIN")) { + return true; + } if (currentUserId == null) { return false; } @@ -671,6 +695,10 @@ public class SkillQueryService { return currentUserId != null && userNsRoles.containsKey(namespaceId); } + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); + } + private String resolveOwnerPreviewReviewComment(SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion) { if (ownerPreviewVersion == null || !"REJECTED".equals(ownerPreviewVersion.status())) { return null; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java index 1ffe5c8f3..af26901a0 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -158,4 +159,34 @@ class VisibilityCheckerTest { boolean canAccess = checker.canAccess(hiddenPublicSkill, ADMIN_USER_ID, roles); assertTrue(canAccess); } + + @Test + void testSuperAdminCanAccessPrivateSkill() { + boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN")); + assertTrue(canAccess); + } + + @Test + void testSuperAdminCanAccessHiddenSkill() { + boolean canAccess = checker.canAccess(hiddenPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN")); + assertTrue(canAccess); + } + + @Test + void testSuperAdminCanAccessUnpublishedSkill() { + boolean canAccess = checker.canAccess(unpublishedPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN")); + assertTrue(canAccess); + } + + @Test + void testNonSuperAdminPlatformRolesDoNotGrantAccess() { + boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("REVIEWER")); + assertFalse(canAccess); + } + + @Test + void testEmptyPlatformRolesDoNotGrantAccess() { + boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of()); + assertFalse(canAccess); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index a8c8042f8..ab0b4abae 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -30,6 +30,7 @@ import java.lang.reflect.Field; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -50,7 +51,6 @@ class SkillQueryServiceTest { private SkillTagRepository skillTagRepository; @Mock private ObjectStorageService objectStorageService; - @Mock private VisibilityChecker visibilityChecker; @Mock private PromotionRequestRepository promotionRequestRepository; @@ -65,6 +65,7 @@ class SkillQueryServiceTest { @BeforeEach void setUp() { + visibilityChecker = new VisibilityChecker(); skillSlugResolutionService = new SkillSlugResolutionService(skillRepository); skillLifecycleProjectionService = new SkillLifecycleProjectionService(skillVersionRepository); service = new SkillQueryService( @@ -105,7 +106,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); when(userAccountRepository.findById(userId)).thenReturn(Optional.of(new UserAccount(userId, "Alice", "alice@example.com", null))); @@ -148,7 +148,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(publishedSkill, ownSkill)); - when(visibilityChecker.canAccess(ownSkill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(22L)).thenReturn(Optional.of(ownVersion)); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); @@ -176,7 +175,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false); // Act & Assert assertThrows(DomainForbiddenException.class, () -> @@ -215,13 +213,11 @@ class SkillQueryServiceTest { setId(namespace, 1L); Skill skill1 = new Skill(1L, "skill1", userId, SkillVisibility.PUBLIC); setId(skill1, 1L); - Skill skill2 = new Skill(1L, "skill2", userId, SkillVisibility.PRIVATE); + Skill skill2 = new Skill(1L, "skill2", "user-200", SkillVisibility.PRIVATE); setId(skill2, 2L); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)).thenReturn(List.of(skill1, skill2)); - when(visibilityChecker.canAccess(skill1, userId, userNsRoles)).thenReturn(true); - when(visibilityChecker.canAccess(skill2, userId, userNsRoles)).thenReturn(false); // Act Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); @@ -267,8 +263,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)) .thenReturn(List.of(ownUnpublishedSkill, othersUnpublishedSkill)); - when(visibilityChecker.canAccess(ownUnpublishedSkill, userId, userNsRoles)).thenReturn(true); - when(visibilityChecker.canAccess(othersUnpublishedSkill, userId, userNsRoles)).thenReturn(false); Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); @@ -296,8 +290,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)) .thenReturn(List.of(visibleSkill, hiddenSkill)); - when(visibilityChecker.canAccess(visibleSkill, userId, userNsRoles)).thenReturn(true); - when(visibilityChecker.canAccess(hiddenSkill, userId, userNsRoles)).thenReturn(false); Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); @@ -325,7 +317,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1)); when(objectStorageService.exists("key1")).thenReturn(true); @@ -358,7 +349,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, callerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); assertThrows(DomainBadRequestException.class, () -> @@ -385,7 +375,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file)); when(objectStorageService.exists(file.getStorageKey())).thenReturn(true); @@ -419,7 +408,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(availableFile, missingFile)); when(objectStorageService.exists("skills/1/1/SKILL.md")).thenReturn(true); @@ -482,7 +470,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail( @@ -516,7 +503,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion)); when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); when(objectStorageService.exists("storage-key")).thenReturn(true); @@ -555,7 +541,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(pending, published, rejected)); Page result = service.listVersions(namespaceSlug, skillSlug, ownerId, userNsRoles, PageRequest.of(0, 20)); @@ -589,7 +574,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) .thenReturn(List.of(version100, version110)); when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version110)); @@ -631,7 +615,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, null, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version)); when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); @@ -664,7 +647,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); @@ -691,7 +673,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)).thenReturn(Optional.empty()); @@ -723,7 +704,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.review.PromotionRequest.class))); @@ -753,7 +733,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)) @@ -784,7 +763,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); @@ -813,7 +791,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(pending)); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) .thenReturn(List.of()); @@ -853,7 +830,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles); @@ -889,7 +865,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(rejected)); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of()); when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected)); @@ -925,7 +900,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail( @@ -960,7 +934,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); when(objectStorageService.exists("storage-key")).thenReturn(true); @@ -992,7 +965,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); assertThrows(DomainBadRequestException.class, () -> @@ -1024,7 +996,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published)); Page result = service.listVersions( @@ -1059,7 +1030,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published)); Page result = service.listVersions( diff --git a/web/src/features/skill/version-status-badge.tsx b/web/src/features/skill/version-status-badge.tsx new file mode 100644 index 000000000..0a518b04a --- /dev/null +++ b/web/src/features/skill/version-status-badge.tsx @@ -0,0 +1,92 @@ +import { useTranslation } from 'react-i18next' +import { cn } from '@/shared/lib/utils' + +type VersionStatus = + | 'DRAFT' + | 'SCANNING' + | 'SCAN_FAILED' + | 'UPLOADED' + | 'PENDING_REVIEW' + | 'PUBLISHED' + | 'REJECTED' + | 'YANKED' + +const statusStyles: Record = { + PUBLISHED: + 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400', + UPLOADED: + 'border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-400', + PENDING_REVIEW: + 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400', + REJECTED: + 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400', + SCANNING: + 'border-purple-500/30 bg-purple-500/10 text-purple-700 dark:text-purple-400', + SCAN_FAILED: + 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400', + YANKED: + 'border-border/60 bg-secondary/40 text-muted-foreground', + DRAFT: + 'border-border/60 bg-secondary/40 text-muted-foreground', +} + +const i18nKeys: Record = { + DRAFT: 'skillDetail.versionStatusDraft', + SCANNING: 'skillDetail.versionStatusScanning', + SCAN_FAILED: 'skillDetail.versionStatusScanFailed', + UPLOADED: 'skillDetail.versionStatusUploaded', + PENDING_REVIEW: 'skillDetail.versionStatusPendingReview', + PUBLISHED: 'skillDetail.versionStatusPublished', + REJECTED: 'skillDetail.versionStatusRejected', + YANKED: 'skillDetail.versionStatusYanked', +} + +/** Color-coded row styles (left-border + subtle background) for version cards. */ +export const versionRowStyles: Record = { + UPLOADED: + 'border-l-[3px] !border-l-blue-500 bg-blue-500/[0.03]', + PENDING_REVIEW: + 'border-l-[3px] !border-l-amber-500 bg-amber-500/[0.03]', + REJECTED: + 'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]', + SCANNING: + 'border-l-[3px] !border-l-purple-500 bg-purple-500/[0.03]', + SCAN_FAILED: + 'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]', + PUBLISHED: '', + YANKED: '', + DRAFT: '', +} + +export function getVersionRowStyle(status?: string): string { + if (!status) return '' + return versionRowStyles[status as VersionStatus] ?? '' +} + +export function VersionStatusBadge({ + status, + className, +}: { + status?: string + className?: string +}) { + const { t } = useTranslation() + if (!status) return null + + const style = statusStyles[status as VersionStatus] ?? statusStyles.DRAFT + const label = i18nKeys[status as VersionStatus] + ? t(i18nKeys[status as VersionStatus]) + : status + + return ( + + {label} + + ) +} From 4d67403e59c4ca80988510dfbb495705b766f5d6 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 14 Apr 2026 14:32:17 +0800 Subject: [PATCH 13/26] test(e2e): relax publish navigation check --- web/e2e/publish-flow-ui.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts index af27b7388..6db425b9f 100644 --- a/web/e2e/publish-flow-ui.spec.ts +++ b/web/e2e/publish-flow-ui.spec.ts @@ -40,8 +40,8 @@ test.describe('Publish Flow UI (Real API)', () => { await expect(confirmButton).toBeEnabled() await confirmButton.click() - await expect(page).toHaveURL(/\/dashboard\/skills$/, { timeout: 90_000 }) - await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible({ timeout: 90_000 }) + const toastTitle = page.getByText(/Published Successfully|Submitted for Review/) + await expect(toastTitle).toBeVisible({ timeout: 90_000 }) } finally { await builder.cleanup() } From 5c8b33684fa97140c92f73a1ee453a94077d55d4 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 14 Apr 2026 14:44:12 +0800 Subject: [PATCH 14/26] docs: add discord server link to READMEs --- README.md | 2 ++ README_zh.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index d12ce036a..9a8f6c397 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/iflytek/skillhub) [![Docs](https://img.shields.io/badge/docs-zread.ai-4A90E2?logo=gitbook&logoColor=white)](https://zread.ai/iflytek/skillhub) +[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/qHYvtDNPHS) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE) [![Build](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml/badge.svg)](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml) [![Docker](https://img.shields.io/badge/docker-ghcr.io-2496ED?logo=docker&logoColor=white)](https://ghcr.io/iflytek/skillhub) @@ -441,6 +442,7 @@ what you'd like to change. - 💬 **Community Discussion**: [GitHub Discussions](https://github.com/iflytek/skillhub/discussions) - 🐛 **Bug Reports**: [Issues](https://github.com/iflytek/skillhub/issues) +- 👾 **Discord**: [Join our Server](https://discord.gg/qHYvtDNPHS) - 👥 **WeChat Work Group**: ![WeChat Work Group](https://github.com/iflytek/astron-agent/raw/main/docs/imgs/WeCom_Group.png) diff --git a/README_zh.md b/README_zh.md index 07b1ae88b..72c7eb738 100644 --- a/README_zh.md +++ b/README_zh.md @@ -7,6 +7,7 @@
[![文档](https://img.shields.io/badge/docs-zread.ai-4A90E2?logo=gitbook&logoColor=white)](https://zread.ai/iflytek/skillhub) +[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/qHYvtDNPHS) [![许可证](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE) [![构建](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml/badge.svg)](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml) [![Docker](https://img.shields.io/badge/docker-ghcr.io-2496ED?logo=docker&logoColor=white)](https://ghcr.io/iflytek/skillhub) @@ -373,6 +374,7 @@ namespace `my-space` 和 skill slug `my-skill`。 - 💬 **社区讨论**:[GitHub Discussions](https://github.com/iflytek/skillhub/discussions) - 🐛 **Bug 报告**:[Issues](https://github.com/iflytek/skillhub/issues) +- 👾 **Discord**:[加入我们的服务器](https://discord.gg/qHYvtDNPHS) - 👥 **企业微信群**: ![企业微信群](https://github.com/iflytek/astron-agent/raw/main/docs/imgs/WeCom_Group.png) From a6a3bdc5f3aebad0e587152b6a50eac52e6916c7 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 14 Apr 2026 15:29:47 +0800 Subject: [PATCH 15/26] test(e2e): verify publish via response and list --- web/e2e/publish-flow-ui.spec.ts | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts index 6db425b9f..6dd8cf74a 100644 --- a/web/e2e/publish-flow-ui.spec.ts +++ b/web/e2e/publish-flow-ui.spec.ts @@ -4,6 +4,15 @@ import { setEnglishLocale } from './helpers/auth-fixtures' import { registerSession } from './helpers/session' import { E2eTestDataBuilder } from './helpers/test-data-builder' +interface PublishEnvelope { + code: number + data: { + namespace: string + slug: string + version: string + } +} + test.describe('Publish Flow UI (Real API)', () => { test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) @@ -16,7 +25,8 @@ test.describe('Publish Flow UI (Real API)', () => { try { const namespace = await builder.ensureWritableNamespace() - const packagePath = builder.createSkillPackageFile() + const skillName = `publish-ui-${Date.now().toString(36)}` + const packagePath = builder.createSkillPackageFile({ name: skillName }) await page.goto('/dashboard/publish') await expect(page.getByRole('heading', { name: 'Publish Skill' })).toBeVisible() @@ -38,10 +48,25 @@ test.describe('Publish Flow UI (Real API)', () => { await expect(page.getByText(path.basename(packagePath))).toBeVisible() const confirmButton = page.getByRole('button', { name: 'Confirm Publish' }) await expect(confirmButton).toBeEnabled() + const publishResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.url().includes(`/api/web/skills/${encodeURIComponent(namespace.slug)}/publish`) + && response.status() === 200, + { timeout: 90_000 }, + ) await confirmButton.click() + const publishResponse = await publishResponsePromise + const publishBody = await publishResponse.json() as PublishEnvelope - const toastTitle = page.getByText(/Published Successfully|Submitted for Review/) - await expect(toastTitle).toBeVisible({ timeout: 90_000 }) + expect(publishBody.code).toBe(0) + expect(publishBody.data.namespace).toBe(namespace.slug) + + await page.goto('/dashboard/skills') + await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('heading', { name: skillName, exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(page.getByText(`@${publishBody.data.namespace}`).first()).toBeVisible() + await expect(page.getByText(`v${publishBody.data.version}`).first()).toBeVisible() } finally { await builder.cleanup() } From a1e4904d972490dc4e5f5bd171b229b2a520cfcf Mon Sep 17 00:00:00 2001 From: xiose Date: Tue, 14 Apr 2026 15:49:25 +0800 Subject: [PATCH 16/26] fix(i18n): update version delete error message to include UPLOADED and SCAN_FAILED The error message for unsupported version deletion still referenced only DRAFT/REJECTED. Updated both EN and ZH messages to reflect the actual deletable statuses: DRAFT, UPLOADED, REJECTED, SCAN_FAILED. Also updated OSS-02 design doc to mark all blocking items as completed. --- docs/oss-02-core-semantic-rules.md | 14 +++++++------- .../src/main/resources/messages.properties | 2 +- .../src/main/resources/messages_zh.properties | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/oss-02-core-semantic-rules.md b/docs/oss-02-core-semantic-rules.md index cd256d056..484b17ef9 100644 --- a/docs/oss-02-core-semantic-rules.md +++ b/docs/oss-02-core-semantic-rules.md @@ -589,13 +589,13 @@ private boolean canDownload(SkillVersion version, Skill skill, String currentUse | 问题 | 严重程度 | 状态 | |------|---------|------| -| 新增 UPLOADED 状态 | 高 | 待实现 | -| PRIVATE skill 发布逻辑改动 | 高 | 待实现 | -| 提交审核接口 | 高 | 待实现 | -| 撤回审核后进入 UPLOADED | 中 | 待实现 | -| 同名冲突检查补全 | 中 | 待实现 | -| 管理员可见 UPLOADED skill | 低 | 待实现 | -| package_name 唯一性检查 | 低 | 可选 | +| 新增 UPLOADED 状态 | 高 | 已完成 | +| PRIVATE skill 发布逻辑改动 | 高 | 已完成 | +| 提交审核接口 | 高 | 已完成 | +| 撤回审核后进入 UPLOADED | 中 | 已完成 | +| 同名冲突检查补全 | 中 | 已完成 | +| 管理员可见 UPLOADED skill | 低 | 已完成 | +| package_name 唯一性检查 | 低 | 可选(SaaS Adapter 职责) | --- diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 1fea61ca5..942d976ae 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -104,7 +104,7 @@ error.skill.lifecycle.noPermission=Only the skill owner or namespace admin can m error.skill.version.exists=Version already exists: {0} error.skill.version.notFound=Version not found: {0} error.skill.version.notPublished=Version is not published: {0} -error.skill.version.delete.unsupported=Only DRAFT or REJECTED versions can be deleted: {0} +error.skill.version.delete.unsupported=Only DRAFT, UPLOADED, REJECTED, or SCAN_FAILED versions can be deleted: {0} error.skill.version.delete.lastVersion=Cannot delete the last remaining version: {0} error.skill.report.reason.required=Please provide a report reason error.skill.report.unavailable=This skill cannot be reported right now: {0} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 05bd09054..bc94ca3bd 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -104,7 +104,7 @@ error.skill.lifecycle.noPermission=只有技能所有者或命名空间管理员 error.skill.version.exists=版本已存在:{0} error.skill.version.notFound=未找到版本:{0} error.skill.version.notPublished=版本未发布:{0} -error.skill.version.delete.unsupported=只有 DRAFT 或 REJECTED 版本可以删除:{0} +error.skill.version.delete.unsupported=只有 DRAFT、UPLOADED、REJECTED 或 SCAN_FAILED 版本可以删除:{0} error.skill.version.delete.lastVersion=无法删除最后一个版本:{0} error.skill.report.reason.required=请填写举报原因 error.skill.report.unavailable=当前无法举报该技能:{0} From ccf7e3840d87d3a9ee89535576e80b7d147d820a Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 14 Apr 2026 16:16:08 +0800 Subject: [PATCH 17/26] test(e2e): harden namespace selection for publish flows --- web/e2e/helpers/test-data-builder.ts | 92 ++++++++++++++++++++++++---- web/e2e/publish-flow-ui.spec.ts | 5 +- 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 7d0717376..0123468ba 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -10,6 +10,11 @@ export interface SeededNamespace { id: number slug: string displayName: string + status?: string + type?: string + currentUserRole?: string + canUnfreeze?: boolean + canRestore?: boolean } export interface SeededSkill { @@ -234,6 +239,34 @@ export class E2eTestDataBuilder { ) } + private isTeamNamespace(namespace: SeededNamespace): boolean { + return namespace.type === 'TEAM' || namespace.slug !== 'global' + } + + private isActiveNamespace(namespace: SeededNamespace): boolean { + return namespace.status === 'ACTIVE' + } + + private async activateNamespace(namespace: SeededNamespace): Promise { + if (!this.isTeamNamespace(namespace)) { + return null + } + + if (namespace.status === 'FROZEN' && namespace.canUnfreeze) { + return parseEnvelope( + await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`), + ) + } + + if (namespace.status === 'ARCHIVED' && namespace.canRestore) { + return parseEnvelope( + await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`), + ) + } + + return null + } + async ensureWritableNamespace(): Promise { if (this.ensuredNamespace) { return this.ensuredNamespace @@ -251,16 +284,38 @@ export class E2eTestDataBuilder { } const namespaces = await this.listMyNamespaces() - const writable = namespaces.find((item) => item.slug !== 'global') ?? namespaces[0] - if (!writable) { - throw new Error('No namespace available for e2e data seeding') + const activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item)) + if (activeTeam) { + this.ensuredNamespace = activeTeam + return activeTeam } - this.ensuredNamespace = writable - return writable + + const activeFallback = namespaces.find((item) => this.isActiveNamespace(item)) + if (activeFallback) { + this.ensuredNamespace = activeFallback + return activeFallback + } + + const activatable = namespaces.find((item) => + this.isTeamNamespace(item) + && ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)), + ) + if (activatable) { + const activated = await this.activateNamespace(activatable) + if (activated) { + this.ensuredNamespace = activated + return activated + } + } + + const summary = namespaces + .map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`) + .join(', ') + throw new Error(`No active writable namespace available for e2e data seeding [${summary}]`) } async ensureReviewableNamespace(): Promise { - if (this.ensuredNamespace && this.ensuredNamespace.slug !== 'global') { + if (this.ensuredNamespace && this.isTeamNamespace(this.ensuredNamespace) && this.isActiveNamespace(this.ensuredNamespace)) { return this.ensuredNamespace } @@ -276,13 +331,28 @@ export class E2eTestDataBuilder { } const namespaces = await this.listMyNamespaces() - const reviewableNamespace = namespaces.find((item) => item.slug !== 'global') - if (!reviewableNamespace) { - throw new Error('No TEAM namespace available for review E2E data seeding') + const activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item)) + if (activeTeam) { + this.ensuredNamespace = activeTeam + return activeTeam } - this.ensuredNamespace = reviewableNamespace - return reviewableNamespace + const activatable = namespaces.find((item) => + this.isTeamNamespace(item) + && ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)), + ) + if (activatable) { + const activated = await this.activateNamespace(activatable) + if (activated) { + this.ensuredNamespace = activated + return activated + } + } + + const summary = namespaces + .map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`) + .join(', ') + throw new Error(`No TEAM namespace available for review E2E data seeding [${summary}]`) } private async getMySkillInNamespace(namespaceSlug: string): Promise { diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts index 6dd8cf74a..867c8248b 100644 --- a/web/e2e/publish-flow-ui.spec.ts +++ b/web/e2e/publish-flow-ui.spec.ts @@ -6,6 +6,7 @@ import { E2eTestDataBuilder } from './helpers/test-data-builder' interface PublishEnvelope { code: number + msg?: string data: { namespace: string slug: string @@ -51,14 +52,14 @@ test.describe('Publish Flow UI (Real API)', () => { const publishResponsePromise = page.waitForResponse( (response) => response.request().method() === 'POST' - && response.url().includes(`/api/web/skills/${encodeURIComponent(namespace.slug)}/publish`) - && response.status() === 200, + && response.url().includes(`/api/web/skills/${encodeURIComponent(namespace.slug)}/publish`), { timeout: 90_000 }, ) await confirmButton.click() const publishResponse = await publishResponsePromise const publishBody = await publishResponse.json() as PublishEnvelope + expect(publishResponse.status(), `publish failed: ${publishBody.msg ?? 'unknown error'}`).toBe(200) expect(publishBody.code).toBe(0) expect(publishBody.data.namespace).toBe(namespace.slug) From edcc24824436cf959ef895ae40eb05c55ce23798 Mon Sep 17 00:00:00 2001 From: xiose Date: Tue, 14 Apr 2026 16:42:22 +0800 Subject: [PATCH 18/26] fix(portal): keep skill detail on viewer permissions --- .../controller/portal/SkillController.java | 6 +-- .../controller/SkillControllerTest.java | 6 +-- .../skill/service/SkillQueryService.java | 39 +++++---------- .../skill/service/SkillQueryServiceTest.java | 50 +++++++++++++++++++ 4 files changed, 66 insertions(+), 35 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 6d582cae3..3e4800601 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -70,12 +70,10 @@ public class SkillController extends BaseApiController { @PathVariable String namespace, @PathVariable String slug, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, - @RequestAttribute(value = "platformRoles", required = false) java.util.Set platformRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail( - namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of(), - platformRoles != null ? platformRoles : java.util.Set.of()); + namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of()); SkillDetailResponse response = new SkillDetailResponse( detail.id(), diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 800c6a7a5..e47da366d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -149,8 +149,7 @@ class SkillControllerTest { eq("team"), eq("demo"), eq((String) null), - eq(Map.of()), - org.mockito.ArgumentMatchers.>any())) + eq(Map.of()))) .thenReturn(new SkillQueryService.SkillDetailDTO( 1L, "demo", @@ -197,8 +196,7 @@ class SkillControllerTest { eq("team"), eq("demo"), eq((String) null), - eq(Map.of()), - org.mockito.ArgumentMatchers.>any())) + eq(Map.of()))) .thenThrow(new DomainForbiddenException("error.namespace.archived", "team")); mockMvc.perform(get("/api/web/skills/team/demo")) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 2f01d5fd1..8bf887675 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -150,28 +150,15 @@ public class SkillQueryService { String skillSlug, String currentUserId, Map userNsRoles) { - return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles, Set.of()); - } - - public SkillDetailDTO getSkillDetail( - String namespaceSlug, - String skillSlug, - String currentUserId, - Map userNsRoles, - Set platformRoles) { - Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - // Archived namespace: only members (or super admins) may view if (namespace.getStatus() == com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED - && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles) - && !isSuperAdmin(platformRoles)) { + && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) { throw new DomainForbiddenException("error.namespace.archived", namespaceSlug); } - // Visibility check - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles, platformRoles)) { + if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skillSlug); } @@ -203,7 +190,7 @@ public class SkillQueryService { skill.getNamespaceId(), skill.getCreatedAt(), skill.getUpdatedAt(), - canManageRestrictedSkill(skill, currentUserId, userNsRoles, platformRoles), + canManageRestrictedSkill(skill, currentUserId, userNsRoles), canSubmitPromotion(namespace, skill, publishedVersion, currentUserId, userNsRoles), headlineVersion == null || "PUBLISHED".equals(headlineVersion.status()), currentUserId == null || !Objects.equals(skill.getOwnerId(), currentUserId), @@ -215,6 +202,15 @@ public class SkillQueryService { ); } + public SkillDetailDTO getSkillDetail( + String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Set platformRoles) { + return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles); + } + /** * Lists skills within a namespace after filtering out records the caller is * not allowed to discover. @@ -647,13 +643,6 @@ public class SkillQueryService { } private boolean canManageRestrictedSkill(Skill skill, String currentUserId, Map userNsRoles) { - return canManageRestrictedSkill(skill, currentUserId, userNsRoles, Set.of()); - } - - private boolean canManageRestrictedSkill(Skill skill, String currentUserId, Map userNsRoles, Set platformRoles) { - if (platformRoles != null && platformRoles.contains("SUPER_ADMIN")) { - return true; - } if (currentUserId == null) { return false; } @@ -695,10 +684,6 @@ public class SkillQueryService { return currentUserId != null && userNsRoles.containsKey(namespaceId); } - private boolean isSuperAdmin(Set platformRoles) { - return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); - } - private String resolveOwnerPreviewReviewComment(SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion) { if (ownerPreviewVersion == null || !"REJECTED".equals(ownerPreviewVersion.status())) { return null; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index ab0b4abae..7ee683ac7 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -771,6 +771,56 @@ class SkillQueryServiceTest { assertFalse(result.canSubmitPromotion()); } + @Test + void testGetSkillDetail_ShouldNotGrantLifecyclePermissionToSuperAdminInPortal() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "super-1"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail( + namespaceSlug, skillSlug, userId, userNsRoles, Set.of("SUPER_ADMIN")); + + assertFalse(result.canManageLifecycle()); + assertFalse(result.canSubmitPromotion()); + assertEquals("PUBLISHED", result.resolutionMode()); + } + + @Test + void testGetSkillDetail_ShouldNotGrantPrivateVisibilityToSuperAdminInPortal() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "super-1"; + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PRIVATE); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + + assertThrows(DomainForbiddenException.class, () -> + service.getSkillDetail(namespaceSlug, skillSlug, userId, Map.of(), Set.of("SUPER_ADMIN"))); + } + @Test void testGetSkillDetail_ShouldPreferPendingVersionForOwnerPreview() throws Exception { String namespaceSlug = "test-ns"; From 7c2f06d1b64f5fab0f49a7e6ce034e2e7c10abcb Mon Sep 17 00:00:00 2001 From: xiose Date: Tue, 14 Apr 2026 17:55:56 +0800 Subject: [PATCH 19/26] test(rerelease): add confirmWarnings coverage and sync generated schema - Add domain tests for rerelease with precheck warnings (reject + confirm) - Add controller test verifying confirmWarnings passthrough - Sync SkillVersionRereleaseRequest generated type with backend DTO --- .../portal/SkillLifecycleControllerTest.java | 36 +++++++ .../service/SkillPublishServiceTest.java | 94 +++++++++++++++++++ web/src/api/generated/schema.d.ts | 1 + 3 files changed, 131 insertions(+) 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 04d0945e0..09751065f 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 @@ -305,6 +305,42 @@ class SkillLifecycleControllerTest { eq(false)); } + @Test + void rereleaseVersion_passesConfirmWarningsToService() 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(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(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(), eq(true))) + .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\",\"confirmWarnings\":true}") + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.action").value("RERELEASE_VERSION")); + + verify(skillPublishService).rereleasePublishedVersion( + eq(1L), eq("1.2.3"), eq("1.2.4"), eq("usr_1"), anyMap(), eq(true)); + } + private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) { skill.setStatus(status); return skill; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index bff1c0f93..caf6fe0cd 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -969,6 +969,100 @@ class SkillPublishServiceTest { assertEquals(30L, skill.getLatestVersionId()); } + @Test + void testRereleasePublishedVersion_ShouldRequireConfirmationWhenWarningsExist() 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(Instant.parse("2026-03-15T10:00:00Z")); + + String sourceSkillMd = "---\nname: Demo Skill\ndescription: Original summary\nversion: 1.2.3\n---\nHello world"; + SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.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)); + when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8))); + when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of( + "SKILL.md line 5 contains a value that looks like a secret or token."))); + + DomainBadRequestException exception = 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), + false + )); + + assertEquals("error.skill.publish.precheck.confirmRequired", exception.messageCode()); + assertTrue(String.valueOf(exception.messageArgs()[0]).contains("looks like a secret or token")); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + } + + @Test + void testRereleasePublishedVersion_ShouldSucceedWhenWarningsConfirmed() 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(Instant.parse("2026-03-15T10:00:00Z")); + + String sourceSkillMd = "---\nname: Demo Skill\ndescription: Original summary\nversion: 1.2.3\n---\nHello world"; + SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.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)); + when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8))); + when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of( + "SKILL.md line 5 contains a value that looks like a secret or token."))); + 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), + true // confirmWarnings = true → should bypass warning and succeed + ); + + assertEquals("1.2.4", result.version().getVersion()); + assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + verify(skillVersionRepository, atLeastOnce()).save(any(SkillVersion.class)); + } + @Test void testPublishFromEntries_ShouldRejectWhenOtherOwnerHasPublishedSkill() throws Exception { String namespaceSlug = "test-ns"; diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index bfc1e4c97..d87140256 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3237,6 +3237,7 @@ export interface components { }; SkillVersionRereleaseRequest: { targetVersion: string; + confirmWarnings?: boolean; }; SkillReportSubmitRequest: { reason?: string; From 3c4c33ad95a52a5436f7970c1cf1013e22f5f701 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 15 Apr 2026 16:16:08 +0800 Subject: [PATCH 20/26] fix(web): allow anonymous access to public skill detail --- web/e2e/public-skill-detail-anonymous.spec.ts | 46 +++++++++++++++++++ web/src/app/router.tsx | 1 - web/src/pages/skill-detail.test.tsx | 43 +++++++++++++++-- 3 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 web/e2e/public-skill-detail-anonymous.spec.ts diff --git a/web/e2e/public-skill-detail-anonymous.spec.ts b/web/e2e/public-skill-detail-anonymous.spec.ts new file mode 100644 index 000000000..56ba8885a --- /dev/null +++ b/web/e2e/public-skill-detail-anonymous.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { getSearchCard, prepareSearchSeed, type PreparedSearchSeed } from './helpers/search-seed' + +const SEARCH_URL = (q: string) => `/search?q=${encodeURIComponent(q)}&sort=relevance&page=0&starredOnly=false` + +function latestSeed(seed: PreparedSearchSeed) { + return { + skill: seed.skills[seed.skills.length - 1], + skillName: seed.skillNames[seed.skillNames.length - 1], + } +} + +let seeded: PreparedSearchSeed | undefined + +test.describe('Public Skill Detail Anonymous Access (Real API)', () => { + test.beforeAll(async ({ browser }, testInfo) => { + seeded = await prepareSearchSeed(browser, testInfo, { count: 1 }) + }) + + test.afterAll(async () => { + await seeded?.dispose() + seeded = undefined + }) + + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('allows anonymous users to open a public skill detail and view install content', async ({ page }) => { + const current = latestSeed(seeded!) + + await page.goto(SEARCH_URL(seeded!.keyword)) + const card = getSearchCard(page, current.skillName) + await expect(card).toBeVisible({ timeout: 15_000 }) + + await card.click() + + await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}$`)) + await expect(page).not.toHaveURL(/\/login\?returnTo=/) + await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible() + await expect(page.getByText('Install', { exact: true })).toBeVisible() + await expect(page.getByText(new RegExp(`npx clawhub install ${current.skill.slug}`))).toBeVisible() + await expect(page.getByRole('button', { name: 'Copy' }).first()).toBeVisible() + }) +}) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 7fb6f2082..4095904d3 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -224,7 +224,6 @@ const namespaceRoute = createRoute({ const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace/$slug', - beforeLoad: requireAuth, validateSearch: (search: Record): { returnTo?: string } => ({ returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined, }), diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index 067a171fb..87bdcb487 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -2,10 +2,17 @@ import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const navigateMock = vi.fn() -const hasRoleMock = vi.fn((role: string) => role === 'USER') +const hasRoleMock = vi.fn<(role: string) => boolean>((role: string) => role === 'USER') const useSkillDetailMock = vi.fn() const useSkillLabelsMock = vi.fn() const useSkillVersionsMock = vi.fn() +let authState: { + user: { userId: string; platformRoles: string[] } | null + hasRole: (role: string) => boolean +} = { + user: { userId: 'owner-1', platformRoles: ['USER'] }, + hasRole: hasRoleMock, +} vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -32,10 +39,7 @@ vi.mock('@tanstack/react-query', () => ({ })) vi.mock('@/features/auth/use-auth', () => ({ - useAuth: () => ({ - user: { userId: 'owner-1', platformRoles: ['USER'] }, - hasRole: hasRoleMock, - }), + useAuth: () => authState, })) vi.mock('@/features/report/use-skill-reports', () => ({ @@ -165,6 +169,10 @@ describe('SkillDetailPage', () => { beforeEach(() => { navigateMock.mockReset() hasRoleMock.mockImplementation((role: string) => role === 'USER') + authState = { + user: { userId: 'owner-1', platformRoles: ['USER'] }, + hasRole: hasRoleMock, + } useSkillDetailMock.mockReturnValue({ data: createSkill(), isLoading: false, @@ -208,6 +216,31 @@ describe('SkillDetailPage', () => { expect(html).not.toContain('skillDetail.deleteSkill') }) + it('renders public skill details for an anonymous viewer', () => { + authState = { + user: null, + hasRole: vi.fn(() => false), + } + + useSkillDetailMock.mockReturnValue({ + data: createSkill({ + canManageLifecycle: false, + canInteract: true, + visibility: 'PUBLIC', + }), + isLoading: false, + isFetching: false, + error: null, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('Demo Skill') + expect(html).toContain('install') + expect(html).not.toContain('skillDetail.loginRequired') + expect(html).not.toContain('skillDetail.deleteSkill') + }) + it('shows the label management panel for a user who can manage the skill lifecycle', () => { useSkillDetailMock.mockReturnValue({ data: createSkill({ From 4619e546bf60ceb77e272ef1c290eae6e488c0d4 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:58:21 +0800 Subject: [PATCH 21/26] fix(search): show default discovery list on empty query (#312) --- web/e2e/search-page-full.spec.ts | 7 ++-- web/src/pages/search.test.tsx | 59 +++++++++++++++++++++++++++++++- web/src/pages/search.tsx | 16 ++++----- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/web/e2e/search-page-full.spec.ts b/web/e2e/search-page-full.spec.ts index f5775bfb9..be14db8fd 100644 --- a/web/e2e/search-page-full.spec.ts +++ b/web/e2e/search-page-full.spec.ts @@ -39,12 +39,11 @@ test.describe('Search Input (Real API)', () => { await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 }) }) - // TC_SEARCH_INPUT_003 P0 - empty search guidance - test('TC_SEARCH_INPUT_003: empty search shows keyword guidance instead of a default list', async ({ page }) => { + // TC_SEARCH_INPUT_003 P0 - empty search shows the default discovery list + test('TC_SEARCH_INPUT_003: empty search shows the default discovery list', async ({ page }) => { await page.goto(searchUrl('')) await expect(page).toHaveURL(/\/search/) - await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible() - await expect(page.getByText('Please enter a search keyword')).toBeVisible() + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 }) }) // TC_SEARCH_INPUT_004 P0 - Enter key triggers search diff --git a/web/src/pages/search.test.tsx b/web/src/pages/search.test.tsx index 726b5553d..a921d5d34 100644 --- a/web/src/pages/search.test.tsx +++ b/web/src/pages/search.test.tsx @@ -46,7 +46,13 @@ vi.mock('@/shared/components/skeleton-loader', () => ({ })) vi.mock('@/shared/components/empty-state', () => ({ - EmptyState: () =>
empty-state
, + EmptyState: ({ title, description }: { title: string; description?: string }) => ( +
+ empty-state + {title} + {description ? {description} : null} +
+ ), })) vi.mock('@/shared/components/pagination', () => ({ @@ -202,4 +208,55 @@ describe('SearchPage', () => { }, }) }) + + it('renders the default skill list when the empty query still returns items', () => { + useSearchMock.mockReturnValue({ + q: '', + label: '', + sort: 'newest', + page: 0, + starredOnly: false, + }) + useSearchSkillsMock.mockReturnValue({ + data: { + items: [{ id: 1, displayName: 'Demo Skill', summary: 'summary', namespace: 'global', slug: 'demo', downloadCount: 1, starCount: 1, ratingCount: 0, updatedAt: '2026-03-20T00:00:00Z', canSubmitPromotion: false }], + total: 1, + page: 0, + size: 12, + }, + isLoading: false, + isFetching: false, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('skill-card') + expect(html).not.toContain('empty-state') + }) + + it('shows a generic empty state when the default discovery list is empty', () => { + useSearchMock.mockReturnValue({ + q: '', + label: '', + sort: 'newest', + page: 0, + starredOnly: false, + }) + useSearchSkillsMock.mockReturnValue({ + data: { + items: [], + total: 0, + page: 0, + size: 12, + }, + isLoading: false, + isFetching: false, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('empty-state') + expect(html).toContain('search.noResults') + expect(html).not.toContain('search.enterKeyword') + }) }) diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 6258ca194..58c421dba 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -125,8 +125,6 @@ export function SearchPage() { isLoading: isLoadingStarred, isFetching: isFetchingStarred, } = useMyStars(starredOnly && isAuthenticated) - const shouldShowGuidance = !starredOnly && !q && !selectedLabel - useEffect(() => { // Debounce URL updates while the user is typing so query state stays shareable without // triggering a navigation on every keystroke. @@ -202,10 +200,10 @@ export function SearchPage() { : data ? Math.ceil(data.total / data.size) : 0 - const displayItems = shouldShowGuidance ? [] : (starredOnly ? starredPageItems : (data?.items ?? [])) - const isPageLoading = shouldShowGuidance ? false : (starredOnly ? isLoadingStarred : isLoading) - const isUpdatingResults = shouldShowGuidance ? false : (starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading) - const resultCount = shouldShowGuidance ? 0 : (starredOnly ? filteredStarredSkills.length : (data?.total ?? 0)) + const displayItems = starredOnly ? starredPageItems : (data?.items ?? []) + const isPageLoading = starredOnly ? isLoadingStarred : isLoading + const isUpdatingResults = starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading + const resultCount = starredOnly ? filteredStarredSkills.length : (data?.total ?? 0) return (
@@ -313,11 +311,9 @@ export function SearchPage() { )} From 5bf8750658cb52c7aa1dc7cbf2a950c23b381f82 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:19:48 +0800 Subject: [PATCH 22/26] feat(ci): add AI-powered release notes generation (#322) * feat(ci): add AI-powered release notes generation - Add GitHub Models integration for automated release notes - Support bilingual (EN) release notes with highlights extraction - Fallback to conventional commit grouping when LLM unavailable - Trigger on tag push or manual workflow dispatch - Zero configuration: uses GitHub Models (gpt-4o-mini) by default * chore: pin action versions and update gitignore - Pin checkout and setup-deno to commit hashes matching project convention - Add .playwright-mcp/ and .mcp.json to gitignore --- .github/release-template.md | 42 ++++ .github/scripts/github.ts | 41 ++++ .github/scripts/issue-llm-provider.ts | 80 ++++++ .github/scripts/release-notes-config.ts | 53 ++++ .github/scripts/release-notes-generator.ts | 271 +++++++++++++++++++++ .github/scripts/release-notes.ts | 79 ++++++ .github/workflows/release-notes.yml | 46 ++++ .gitignore | 4 + 8 files changed, 616 insertions(+) create mode 100644 .github/release-template.md create mode 100644 .github/scripts/release-notes-config.ts create mode 100644 .github/scripts/release-notes-generator.ts create mode 100644 .github/scripts/release-notes.ts create mode 100644 .github/workflows/release-notes.yml diff --git a/.github/release-template.md b/.github/release-template.md new file mode 100644 index 000000000..d70055d88 --- /dev/null +++ b/.github/release-template.md @@ -0,0 +1,42 @@ +# SkillHub {{version}} + +{{One-line summary of the key changes in this release}} + +## 🌟 Highlights + +- {{Highlight 1}} +- {{Highlight 2}} +- {{Highlight 3}} + +## 🚨 Breaking Changes + +⚠️ {{If any, describe impact and migration guide}} + +## ✨ Features + +- {{Feature description}} by @author in #PR + +## 🐛 Bug Fixes + +- {{Fix description}} by @author in #PR + +## ⚡ Performance + +- {{Performance improvement}} by @author in #PR + +## 📚 Documentation + +- {{Documentation changes}} by @author in #PR + +## 🔧 Chore + +- {{Maintenance work}} by @author in #PR + +## 📖 Documentation +- Docs site: https://iflytek.github.io/skillhub/ + +## 👥 New Contributors +{{Keep as-is}} + +**Full Changelog**: https://github.com/iflytek/skillhub/compare/{{prev_tag}}...{{tag}} + diff --git a/.github/scripts/github.ts b/.github/scripts/github.ts index 7b9778b5e..701032114 100644 --- a/.github/scripts/github.ts +++ b/.github/scripts/github.ts @@ -138,6 +138,47 @@ export class GitHubClient { ); } + async listCommitPulls(sha: string): Promise> { + return this.request( + "GET", + `/repos/${this.owner}/${this.repo}/commits/${sha}/pulls`, + ); + } + + async createDraftRelease( + tag: string, + name: string, + body: string, + ): Promise<{ id: number; upload_url: string }> { + return this.request("POST", `/repos/${this.owner}/${this.repo}/releases`, { + tag_name: tag, + name, + body, + draft: true, + prerelease: false, + }); + } + + async uploadReleaseAsset( + releaseId: number, + filename: string, + content: string, + ): Promise { + const uploadUrl = `https://uploads.github.com/repos/${this.owner}/${this.repo}/releases/${releaseId}/assets?name=${encodeURIComponent(filename)}`; + const response = await fetch(uploadUrl, { + method: "POST", + headers: { + ...this.headers(), + "Content-Type": "text/markdown", + }, + body: content, + }); + + if (!response.ok) { + throw await GitHubApiError.fromResponse(response); + } + } + private async paginate(path: string): Promise { const collected: T[] = []; let nextPath: string | null = path; diff --git a/.github/scripts/issue-llm-provider.ts b/.github/scripts/issue-llm-provider.ts index 276193c3a..f73dd8bff 100644 --- a/.github/scripts/issue-llm-provider.ts +++ b/.github/scripts/issue-llm-provider.ts @@ -37,6 +37,35 @@ export async function requestOpenAiCompatibleJson( throw lastError ?? new Error("LLM request failed for an unknown reason."); } +export async function requestOpenAiCompatibleMarkdown( + config: IssueLlmConfig, + systemPrompt: string, + userPrompt: string, +): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) { + try { + return await requestOnceMarkdown(config, systemPrompt, userPrompt); + } catch (error) { + const normalized = normalizeRequestError( + error, + attempt, + config.maxAttempts, + ); + lastError = normalized; + + if (!shouldRetry(error) || attempt >= config.maxAttempts) { + throw normalized; + } + + await sleep(resolveRetryDelay(error, config.retryBackoffMs, attempt)); + } + } + + throw lastError ?? new Error("LLM request failed for an unknown reason."); +} + async function requestOnce( config: IssueLlmConfig, systemPrompt: string, @@ -88,6 +117,57 @@ async function requestOnce( } } +async function requestOnceMarkdown( + config: IssueLlmConfig, + systemPrompt: string, + userPrompt: string, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.timeoutMs); + + try { + const response = await fetch(`${config.baseUrl}/chat/completions`, { + method: "POST", + signal: controller.signal, + headers: { + Authorization: `Bearer ${config.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: config.model, + temperature: config.temperature, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }), + }); + + if (!response.ok) { + const message = + `LLM request failed with status ${response.status}: ${await response + .text()}`; + throw new RetryableHttpError( + message, + response.status, + response.headers.get("retry-after"), + ); + } + + const payload = (await response.json()) as OpenAiCompatibleResponse; + const content = payload.choices?.[0]?.message?.content; + const text = normalizeMessageContent(content); + + if (!text) { + throw new Error("LLM response did not include message content."); + } + + return text; + } finally { + clearTimeout(timeout); + } +} + class RetryableHttpError extends Error { status: number; retryAfterSeconds: number | null; diff --git a/.github/scripts/release-notes-config.ts b/.github/scripts/release-notes-config.ts new file mode 100644 index 000000000..d059cfc4d --- /dev/null +++ b/.github/scripts/release-notes-config.ts @@ -0,0 +1,53 @@ +import { IssueLlmConfig } from "./issue-llm-types.ts"; + +const DEFAULT_TIMEOUT_MS = 30000; +const DEFAULT_MAX_ATTEMPTS = 2; +const DEFAULT_RETRY_BACKOFF_MS = 1500; +const DEFAULT_TEMPERATURE = 0.2; + +export function readReleaseNotesLlmConfig(): IssueLlmConfig | null { + const baseUrl = normalizeUrl( + Deno.env.get("RELEASE_NOTES_LLM_BASE_URL") || + Deno.env.get("ISSUE_TRIAGE_LLM_BASE_URL"), + ); + const apiKey = ( + Deno.env.get("RELEASE_NOTES_LLM_API_KEY") || + Deno.env.get("ISSUE_TRIAGE_LLM_API_KEY") + )?.trim() ?? ""; + const model = ( + Deno.env.get("RELEASE_NOTES_LLM_MODEL") || + Deno.env.get("ISSUE_TRIAGE_LLM_MODEL") + )?.trim() ?? ""; + + if (!baseUrl || !apiKey || !model) { + console.warn( + "LLM config missing, will use fallback mode (conventional commit grouping)", + ); + return null; + } + + return { + mode: "assist", + provider: "openai-compatible", + baseUrl, + apiKey, + model, + timeoutMs: DEFAULT_TIMEOUT_MS, + maxAttempts: DEFAULT_MAX_ATTEMPTS, + retryBackoffMs: DEFAULT_RETRY_BACKOFF_MS, + temperature: DEFAULT_TEMPERATURE, + maxComments: 0, + maxCommentChars: 0, + maxBodyChars: 0, + }; +} + +function normalizeUrl(value: string | undefined | null) { + const trimmed = value?.trim(); + + if (!trimmed) { + return ""; + } + + return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; +} diff --git a/.github/scripts/release-notes-generator.ts b/.github/scripts/release-notes-generator.ts new file mode 100644 index 000000000..f14371779 --- /dev/null +++ b/.github/scripts/release-notes-generator.ts @@ -0,0 +1,271 @@ +import { GitHubClient } from "./github.ts"; +import { IssueLlmConfig } from "./issue-llm-types.ts"; +import { requestOpenAiCompatibleMarkdown } from "./issue-llm-provider.ts"; + +interface RawCommit { + sha: string; + message: string; + author: string; + prNumber?: number; + prTitle?: string; + excluded: boolean; +} + +interface ChangeEntry { + type: string; + scope: string; + description: string; + prNumber?: number; + authors: string[]; + commits: string[]; +} + +export async function generateReleaseNotes( + owner: string, + repo: string, + tag: string, + prevTag: string, + llmConfig: IssueLlmConfig | null, + dryRun: boolean, +): Promise { + const github = new GitHubClient(Deno.env.get("GH_TOKEN") ?? "", owner, repo); + + console.log(`Collecting changes from ${prevTag} to ${tag}...`); + const changes = await collectChanges(github, prevTag, tag); + console.log(`Found ${changes.length} changes after deduplication`); + + if (llmConfig) { + console.log("Generating release notes with LLM..."); + try { + const markdown = await generateMarkdownWithLLM( + llmConfig, + changes, + tag, + prevTag, + owner, + repo, + ); + return markdown; + } catch (error) { + console.error("LLM generation failed:", error); + console.log("Falling back to conventional commit grouping..."); + return generateFallback(changes, tag, prevTag, owner, repo); + } + } else { + console.log("Using fallback mode (conventional commit grouping)..."); + return generateFallback(changes, tag, prevTag, owner, repo); + } +} + +async function collectChanges( + github: GitHubClient, + prevTag: string, + tag: string, +): Promise { + const gitLogCmd = new Deno.Command("git", { + args: ["log", `${prevTag}..${tag}`, "--format=%H|%s|%an"], + stdout: "piped", + }); + const gitLogOutput = await gitLogCmd.output(); + const gitLogText = new TextDecoder().decode(gitLogOutput.stdout); + + const rawCommits: RawCommit[] = []; + for (const line of gitLogText.trim().split("\n")) { + if (!line) continue; + const [sha, message, author] = line.split("|"); + rawCommits.push({ + sha, + message, + author, + excluded: false, + }); + } + + for (const commit of rawCommits) { + if (/^Revert "(.+)"$/.test(commit.message)) { + commit.excluded = true; + const revertedMsg = commit.message.match(/^Revert "(.+)"$/)?.[1]; + if (revertedMsg) { + const reverted = rawCommits.find((c) => c.message === revertedMsg); + if (reverted) reverted.excluded = true; + } + } + + if (/^Merge (pull request|branch|remote-tracking)/.test(commit.message)) { + commit.excluded = true; + } + } + + for (const commit of rawCommits.filter((c) => !c.excluded)) { + try { + const pulls = await github.listCommitPulls(commit.sha); + if (pulls.length > 0) { + commit.prNumber = pulls[0].number; + commit.prTitle = pulls[0].title; + } + } catch (error) { + console.warn(`Failed to fetch PR for commit ${commit.sha}:`, error); + } + } + + const prMap = new Map(); + const standaloneCommits: ChangeEntry[] = []; + + for (const commit of rawCommits.filter((c) => !c.excluded)) { + const parsed = parseConventionalCommit(commit.message); + const description = commit.prTitle || parsed.description; + + if (commit.prNumber) { + if (!prMap.has(commit.prNumber)) { + prMap.set(commit.prNumber, { + type: parsed.type, + scope: parsed.scope, + description, + prNumber: commit.prNumber, + authors: [commit.author], + commits: [commit.sha], + }); + } else { + const entry = prMap.get(commit.prNumber)!; + if (!entry.authors.includes(commit.author)) { + entry.authors.push(commit.author); + } + entry.commits.push(commit.sha); + } + } else { + standaloneCommits.push({ + type: parsed.type, + scope: parsed.scope, + description, + authors: [commit.author], + commits: [commit.sha], + }); + } + } + + return [...prMap.values(), ...standaloneCommits]; +} + +function parseConventionalCommit(message: string): { + type: string; + scope: string; + description: string; +} { + const match = message.match(/^(\w+)(?:\(([^)]+)\))?: (.+)$/); + if (match) { + return { + type: match[1], + scope: match[2] || "", + description: match[3], + }; + } + return { + type: "other", + scope: "", + description: message, + }; +} + +async function generateMarkdownWithLLM( + config: IssueLlmConfig, + changes: ChangeEntry[], + tag: string, + prevTag: string, + owner: string, + repo: string, +): Promise { + const template = await Deno.readTextFile(".github/release-template.md"); + + const systemPrompt = `You are a senior product manager and technical documentation expert. Rewrite the following technical change list into user-friendly Release Notes. + +Requirements: +1. Strictly follow the Markdown structure and heading levels of the template below +2. Remove any section entirely (including its heading) if there are no items for it +3. Rewrite technical jargon into language that end-users can understand +4. For Breaking Changes, add an upgrade / migration guide +5. Highlights must contain 2-4 items, distilled from the most important changes +6. PR number format: #123 +7. Contributor format: @username +8. Replace {{version}}, {{prev_tag}}, {{tag}} with actual values +9. Output ONLY the Markdown content — no extra commentary, no code fences + +Template: +--- +${template} +---`; + + const changesList = changes.map((c) => { + const pr = c.prNumber ? ` (#${c.prNumber})` : ""; + const authors = c.authors.map((a) => `@${a}`).join(", "); + return `- ${c.type}(${c.scope}): ${c.description}${pr} by ${authors}`; + }).join("\n"); + + const userPrompt = `Version: ${tag} +Previous version: ${prevTag} +Repository: ${owner}/${repo} + +Change list: +${changesList}`; + + const markdown = await requestOpenAiCompatibleMarkdown( + config, + systemPrompt, + userPrompt, + ); + + return markdown + .replace(/\{\{version\}\}/g, tag) + .replace(/\{\{prev_tag\}\}/g, prevTag) + .replace(/\{\{tag\}\}/g, tag); +} + +function generateFallback( + changes: ChangeEntry[], + tag: string, + prevTag: string, + owner: string, + repo: string, +): string { + const grouped = new Map(); + for (const change of changes) { + const type = change.type; + if (!grouped.has(type)) { + grouped.set(type, []); + } + grouped.get(type)!.push(change); + } + + const typeLabels: Record = { + feat: "## ✨ Features", + fix: "## 🐛 Bug Fixes", + docs: "## 📚 Documentation", + perf: "## ⚡ Performance", + refactor: "## 🔧 Improvements", + test: "## 🧪 Tests", + chore: "## 🔧 Chore", + }; + + let md = `# SkillHub ${tag}\n\n`; + md += `> [Auto-generated - LLM unavailable]\n\n`; + + for (const [type, items] of grouped.entries()) { + const label = typeLabels[type] || `## ${type}`; + md += `${label}\n\n`; + for (const item of items) { + const pr = item.prNumber ? ` in #${item.prNumber}` : ""; + const authors = item.authors.map((a) => `@${a}`).join(", "); + md += `- ${item.description}${pr} by ${authors}\n`; + } + md += "\n"; + } + + const contributors = [ + ...new Set(changes.flatMap((c) => c.authors)), + ]; + md += `## 👥 Contributors\n\n`; + md += contributors.map((a) => `@${a}`).join(", ") + "\n\n"; + + md += `**Full Changelog**: https://github.com/${owner}/${repo}/compare/${prevTag}...${tag}\n`; + + return md; +} diff --git a/.github/scripts/release-notes.ts b/.github/scripts/release-notes.ts new file mode 100644 index 000000000..64691cec1 --- /dev/null +++ b/.github/scripts/release-notes.ts @@ -0,0 +1,79 @@ +import { GitHubClient } from "./github.ts"; +import { readReleaseNotesLlmConfig } from "./release-notes-config.ts"; +import { generateReleaseNotes } from "./release-notes-generator.ts"; + +function readFlag(name: string): string | null { + const index = Deno.args.indexOf(name); + if (index === -1 || index === Deno.args.length - 1) { + return null; + } + return Deno.args[index + 1]; +} + +function hasFlag(name: string): boolean { + return Deno.args.includes(name); +} + +async function detectPrevTag(tag: string): Promise { + const cmd = new Deno.Command("git", { + args: ["tag", "--sort=-v:refname"], + stdout: "piped", + }); + const output = await cmd.output(); + const tags = new TextDecoder().decode(output.stdout).trim().split("\n"); + + const currentIndex = tags.indexOf(tag); + if (currentIndex === -1 || currentIndex === tags.length - 1) { + throw new Error(`Cannot find previous tag for ${tag}`); + } + + return tags[currentIndex + 1]; +} + +async function main() { + const owner = readFlag("--owner"); + const repo = readFlag("--repo"); + const tag = readFlag("--tag"); + const prevTagArg = readFlag("--prev-tag"); + const dryRun = hasFlag("--dry-run"); + const skipLlm = hasFlag("--skip-llm"); + + if (!owner || !repo || !tag) { + console.error("Usage: release-notes.ts --owner --repo --tag [--prev-tag ] [--dry-run] [--skip-llm]"); + Deno.exit(1); + } + + const prevTag = prevTagArg || await detectPrevTag(tag); + console.log(`Generating release notes for ${tag} (previous: ${prevTag})`); + + const llmConfig = skipLlm ? null : readReleaseNotesLlmConfig(); + + const markdown = await generateReleaseNotes( + owner, + repo, + tag, + prevTag, + llmConfig, + dryRun, + ); + + if (dryRun) { + console.log("\n=== DRY RUN MODE ===\n"); + console.log(markdown); + console.log("\n=== END DRY RUN ==="); + return; + } + + console.log("Creating draft release..."); + const github = new GitHubClient(Deno.env.get("GH_TOKEN") ?? "", owner, repo); + const release = await github.createDraftRelease(tag, tag, markdown); + console.log(`Draft release created: ${release.id}`); + + console.log(`\nDraft release created successfully!`); + console.log(`View at: https://github.com/${owner}/${repo}/releases/tag/${tag}`); +} + +main().catch((error) => { + console.error("Error:", error); + Deno.exit(1); +}); diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml new file mode 100644 index 000000000..ebba66bc2 --- /dev/null +++ b/.github/workflows/release-notes.yml @@ -0,0 +1,46 @@ +name: AI Release Notes + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Tag to generate release notes for (e.g. v0.3.0)" + required: true + prev_tag: + description: "Previous tag (auto-detect if empty)" + required: false + +permissions: + contents: write + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: Generate release notes + env: + GH_TOKEN: ${{ github.token }} + RELEASE_NOTES_LLM_BASE_URL: ${{ vars.RELEASE_NOTES_LLM_BASE_URL || 'https://models.inference.ai.azure.com' }} + RELEASE_NOTES_LLM_API_KEY: ${{ secrets.RELEASE_NOTES_LLM_API_KEY || github.token }} + RELEASE_NOTES_LLM_MODEL: ${{ vars.RELEASE_NOTES_LLM_MODEL || 'gpt-4o-mini' }} + ISSUE_TRIAGE_LLM_BASE_URL: ${{ vars.ISSUE_TRIAGE_LLM_BASE_URL }} + ISSUE_TRIAGE_LLM_API_KEY: ${{ secrets.ISSUE_TRIAGE_LLM_API_KEY }} + ISSUE_TRIAGE_LLM_MODEL: ${{ vars.ISSUE_TRIAGE_LLM_MODEL }} + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + PREV="${{ github.event.inputs.prev_tag }}" + ARGS="--owner ${{ github.repository_owner }} --repo ${{ github.event.repository.name }} --tag $TAG" + [ -n "$PREV" ] && ARGS="$ARGS --prev-tag $PREV" + + deno run --allow-env --allow-net --allow-run --allow-read \ + .github/scripts/release-notes.ts $ARGS diff --git a/.gitignore b/.gitignore index dcf128840..2eae3701e 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ package-lock.json **/.playwright/ **/playwright-report/ **/test-results/ +.playwright-mcp/ # Python / temporary files *.py[cod] @@ -82,3 +83,6 @@ docs/superpowers/ # Local workspace metadata AGENTS.md CLAUDE.md + +# Local config file +.mcp.json From c03790a11e2edb01e01c7a37f1bc76b6e6fddd4e Mon Sep 17 00:00:00 2001 From: wowo Date: Fri, 17 Apr 2026 17:45:34 +0800 Subject: [PATCH 23/26] Fix runtime Postgres password drift (#321) - start postgres before bringing up application services - sync the database role password from .env.release - verify TCP auth with the synced password before startup --- scripts/runtime.sh | 59 ++++++++++++++++++++++++++ scripts/skillhub-test-deploy-remote.sh | 59 ++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/scripts/runtime.sh b/scripts/runtime.sh index 20c2dd589..f5b895506 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -168,6 +168,63 @@ set_env_value() { mv "$tmp" "$ENV_FILE" } +get_env_value() { + key="$1" + default_value="${2:-}" + value="$(grep "^$key=" "$ENV_FILE" | tail -n 1 | cut -d= -f2- || true)" + + if [ -n "$value" ]; then + printf '%s' "$value" + else + printf '%s' "$default_value" + fi +} + +wait_for_postgres_ready() { + postgres_user="$1" + postgres_db="$2" + attempt=1 + + while [ "$attempt" -le 60 ]; do + if run_compose exec -T postgres pg_isready -U "$postgres_user" -d "$postgres_db" >/dev/null 2>&1; then + return 0 + fi + + attempt=$((attempt + 1)) + sleep 2 + done + + echo "PostgreSQL did not become ready in time." >&2 + run_compose logs postgres >&2 || true + exit 1 +} + +ensure_postgres_password_matches_env() { + postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")" + postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")" + postgres_password="$(get_env_value "POSTGRES_PASSWORD" "skillhub_demo")" + + if [ -z "$postgres_password" ]; then + echo "POSTGRES_PASSWORD must not be empty." >&2 + exit 1 + fi + + wait_for_postgres_ready "$postgres_user" "$postgres_db" + + run_compose exec -T postgres \ + psql -U "$postgres_user" -d "$postgres_db" \ + -v ON_ERROR_STOP=1 \ + -v password="$postgres_password" <<'SQL' >/dev/null +SELECT format('ALTER ROLE %I WITH PASSWORD %L', current_user, :'password'); +\gexec +SQL + + run_compose exec -T -e "PGPASSWORD=$postgres_password" postgres \ + psql -h 127.0.0.1 -U "$postgres_user" -d "$postgres_db" \ + -v ON_ERROR_STOP=1 \ + -c 'select current_user;' >/dev/null +} + prepare_runtime_files() { mkdir -p "$SKILLHUB_HOME" download_file "$SKILLHUB_RAW_BASE/compose.release.yml" "$COMPOSE_FILE" @@ -235,6 +292,8 @@ prepare_runtime_files case "$COMMAND" in up) + run_compose up -d postgres + ensure_postgres_password_matches_env if [ "$DISABLE_SCANNER" = "true" ]; then SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --scale skill-scanner=0 else diff --git a/scripts/skillhub-test-deploy-remote.sh b/scripts/skillhub-test-deploy-remote.sh index 396636241..08dde944e 100644 --- a/scripts/skillhub-test-deploy-remote.sh +++ b/scripts/skillhub-test-deploy-remote.sh @@ -104,6 +104,63 @@ set_env_value() { mv "${tmp}" .env.release } +get_env_value() { + key="$1" + default_value="${2:-}" + value="$(grep -E "^${key}=" .env.release | tail -n 1 | cut -d= -f2- || true)" + + if [[ -n "${value}" ]]; then + printf '%s' "${value}" + else + printf '%s' "${default_value}" + fi +} + +wait_for_postgres_ready() { + postgres_user="$1" + postgres_db="$2" + + for attempt in $(seq 1 60); do + if docker compose --env-file .env.release -f compose.release.yml exec -T postgres \ + pg_isready -U "${postgres_user}" -d "${postgres_db}" >/dev/null 2>&1; then + return 0 + fi + + sleep 2 + done + + echo "PostgreSQL did not become ready in time" >&2 + docker compose --env-file .env.release -f compose.release.yml logs postgres >&2 || true + exit 1 +} + +ensure_postgres_password_matches_env() { + postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")" + postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")" + postgres_password="$(get_env_value "POSTGRES_PASSWORD" "skillhub_demo")" + + if [[ -z "${postgres_password}" ]]; then + echo "POSTGRES_PASSWORD must not be empty" >&2 + exit 1 + fi + + wait_for_postgres_ready "${postgres_user}" "${postgres_db}" + + docker compose --env-file .env.release -f compose.release.yml exec -T postgres \ + psql -U "${postgres_user}" -d "${postgres_db}" \ + -v ON_ERROR_STOP=1 \ + -v password="${postgres_password}" <<'SQL' >/dev/null +SELECT format('ALTER ROLE %I WITH PASSWORD %L', current_user, :'password'); +\gexec +SQL + + docker compose --env-file .env.release -f compose.release.yml exec -T \ + -e PGPASSWORD="${postgres_password}" postgres \ + psql -h 127.0.0.1 -U "${postgres_user}" -d "${postgres_db}" \ + -v ON_ERROR_STOP=1 \ + -c 'select current_user;' >/dev/null +} + cd "${runtime_dir}" test -f .env.release @@ -123,6 +180,8 @@ run_url=${run_url} METADATA docker compose --env-file .env.release -f compose.release.yml pull +docker compose --env-file .env.release -f compose.release.yml up -d postgres +ensure_postgres_password_matches_env docker compose --env-file .env.release -f compose.release.yml up -d docker compose --env-file .env.release -f compose.release.yml ps From 080be28eefcf2c1ee5a942d83588f2dbbc456d07 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:46:05 +0800 Subject: [PATCH 24/26] fix(search): align portal visibility for private skills (#323) * feat(ci): add AI-powered release notes generation - Add GitHub Models integration for automated release notes - Support bilingual (EN) release notes with highlights extraction - Fallback to conventional commit grouping when LLM unavailable - Trigger on tag push or manual workflow dispatch - Zero configuration: uses GitHub Models (gpt-4o-mini) by default * chore: pin action versions and update gitignore - Pin checkout and setup-deno to commit hashes matching project convention - Add .playwright-mcp/ and .mcp.json to gitignore * fix(search): keep private skills out of portal discovery - remove super admin expanded visibility from portal search - exclude all private skills from portal search results - keep namespace-only skills visible only to namespace members - reserve private skill access for my-skills and future admin surfaces * test(search): update test to reflect portal visibility policy - rename test from platformWideAccessShouldBypass... to platformWideAccessShouldNotBypass... - verify that platformWideAccess flag does not grant extra visibility in portal search - verify PRIVATE skills are excluded from search SQL - aligns test with new requirement: portal search uses standard visibility rules * test(search): update app service test for portal visibility policy - rename test from search_shouldGrantPlatformWideAccessToSuperAdmin to search_shouldNotGrantPlatformWideAccessToSuperAdminInPortal - verify super admin does not get platformWideAccess in portal search - aligns with requirement: portal uses standard visibility, admin features reserved for dedicated interface --- .../skillhub/service/SkillSearchAppService.java | 6 ++---- .../skillhub/service/SkillSearchAppServiceTest.java | 4 ++-- .../postgres/PostgresFullTextQueryService.java | 9 --------- .../postgres/PostgresFullTextQueryServiceTest.java | 12 ++++++------ 4 files changed, 10 insertions(+), 21 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 57a1145a1..820980bbb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -122,10 +122,8 @@ public class SkillSearchAppService { } private boolean hasPlatformWideReadAccess(Set platformRoles) { - if (platformRoles == null || platformRoles.isEmpty()) { - return false; - } - return platformRoles.contains("SUPER_ADMIN"); + // Super admins should use a dedicated admin interface, not the public portal + return false; } private SearchResponse searchVisibleSkills( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index 57710c885..fdac46f83 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -216,7 +216,7 @@ class SkillSearchAppServiceTest { } @Test - void search_shouldGrantPlatformWideAccessToSuperAdmin() { + void search_shouldNotGrantPlatformWideAccessToSuperAdminInPortal() { when(searchQueryService.search(any())) .thenReturn(new SearchResult(List.of(), 0, 0, 20)); when(rbacService.getUserRoleCodes("admin-1")).thenReturn(Set.of("SUPER_ADMIN", "USER")); @@ -228,7 +228,7 @@ class SkillSearchAppServiceTest { SearchVisibilityScope scope = captor.getValue().visibilityScope(); assertEquals("admin-1", scope.userId()); - assertEquals(true, scope.platformWideAccess()); + assertEquals(false, scope.platformWideAccess()); } private void setField(Object target, String fieldName, Object value) { diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index e6888827b..ec0cfa370 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -105,7 +105,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { Set adminNamespaceIds = query.visibilityScope().adminNamespaceIds().isEmpty() ? Set.of(-1L) : query.visibilityScope().adminNamespaceIds(); - boolean platformWideAccess = query.visibilityScope().platformWideAccess(); StringBuilder sql = new StringBuilder(); sql.append("SELECT d.skill_id "); @@ -118,9 +117,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("AND (d.visibility = 'PUBLIC' "); if (query.visibilityScope().userId() != null) { sql.append("OR (d.visibility = 'NAMESPACE_ONLY' AND d.namespace_id IN :memberNamespaceIds) "); - sql.append("OR (d.visibility = 'NAMESPACE_ONLY' AND :platformWideAccess = TRUE) "); - sql.append("OR (d.visibility = 'PRIVATE' AND (d.namespace_id IN :adminNamespaceIds OR d.owner_id = :userId)) "); - sql.append("OR (d.visibility = 'PRIVATE' AND :platformWideAccess = TRUE) "); } sql.append(") "); @@ -131,7 +127,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("AND (n.status <> 'ARCHIVED' "); if (query.visibilityScope().userId() != null) { sql.append("OR d.namespace_id IN :memberNamespaceIds "); - sql.append("OR :platformWideAccess = TRUE "); } sql.append(") "); @@ -196,8 +191,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (query.visibilityScope().userId() != null) { nativeQuery.setParameter("memberNamespaceIds", memberNamespaceIds); nativeQuery.setParameter("adminNamespaceIds", adminNamespaceIds); - nativeQuery.setParameter("platformWideAccess", platformWideAccess); - nativeQuery.setParameter("userId", query.visibilityScope().userId()); } if (query.namespaceId() != null) { @@ -243,8 +236,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (query.visibilityScope().userId() != null) { countQuery.setParameter("memberNamespaceIds", memberNamespaceIds); countQuery.setParameter("adminNamespaceIds", adminNamespaceIds); - countQuery.setParameter("platformWideAccess", platformWideAccess); - countQuery.setParameter("userId", query.visibilityScope().userId()); } if (query.namespaceId() != null) { diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index ea33092b5..0025f2ec8 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -393,7 +393,7 @@ class PostgresFullTextQueryServiceTest { } @Test - void platformWideAccessShouldBypassNamespaceVisibilityRestrictions() { + void platformWideAccessShouldNotBypassVisibilityInPortalSearch() { EntityManager entityManager = mock(EntityManager.class); Query nativeQuery = mock(Query.class); Query countQuery = mock(Query.class); @@ -418,12 +418,12 @@ class PostgresFullTextQueryServiceTest { ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + // Portal search should not include platformWideAccess bypass logic assertThat(sqlCaptor.getAllValues().getFirst()) - .contains("OR (d.visibility = 'NAMESPACE_ONLY' AND :platformWideAccess = TRUE)") - .contains("OR (d.visibility = 'PRIVATE' AND :platformWideAccess = TRUE)") - .contains("OR :platformWideAccess = TRUE"); - verify(nativeQuery).setParameter("platformWideAccess", true); - verify(countQuery).setParameter("platformWideAccess", true); + .doesNotContain("platformWideAccess") + .doesNotContain("PRIVATE"); + verify(nativeQuery, never()).setParameter("platformWideAccess", true); + verify(countQuery, never()).setParameter("platformWideAccess", true); } @Test From 7be6a36960b7e49c0049b4022b710768e437d603 Mon Sep 17 00:00:00 2001 From: wrj97 Date: Fri, 17 Apr 2026 19:56:32 +0800 Subject: [PATCH 25/26] feat(auth): add GitLab OAuth2 provider support (#264) * feat(auth): add GitLab OAuth2 provider support Add GitLab as an additional OAuth2 authentication provider alongside GitHub. This includes: - GitLab OAuth2 client configuration with customizable base URL - GitLabClaimsExtractor for handling GitLab-specific user claims - Multi-provider login UI with provider-specific icons - Updated localization to use OAuth-agnostic terminology - JSON type annotation for IdentityBinding entity * fix(auth): restore oauth redirect and gitlab email checks * test(auth): align oauth login handler expectation --------- Co-authored-by: wowo-zZ --- .../src/main/resources/application.yml | 21 ++- .../controller/AuthControllerTest.java | 7 +- .../skillhub/auth/entity/IdentityBinding.java | 15 +- .../auth/oauth/CustomOAuth2UserService.java | 4 +- .../auth/oauth/GitLabClaimsExtractor.java | 142 ++++++++++++++++++ .../auth/session/PlatformSessionService.java | 14 +- .../auth/oauth/GitLabClaimsExtractorTest.java | 103 +++++++++++++ .../auth/oauth/OAuth2LoginHandlersTest.java | 2 +- web/public/github-logo.svg | 2 + web/public/gitlab-logo.svg | 2 + web/src/features/auth/login-button.tsx | 19 ++- web/src/i18n/locales/en.json | 2 +- web/src/i18n/locales/zh.json | 2 +- 13 files changed, 320 insertions(+), 15 deletions(-) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java create mode 100644 web/public/github-logo.svg create mode 100644 web/public/gitlab-logo.svg diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 0cee466c3..0cc32ec46 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -53,10 +53,29 @@ spring: github: client-id: ${OAUTH2_GITHUB_CLIENT_ID:placeholder} client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:placeholder} - scope: read:user,user:email + scope: + - read:user + - user:email + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-name: GitHub + authorization-grant-type: authorization_code + gitlab: + client-id: ${OAUTH2_GITLAB_CLIENT_ID:placeholder} + client-secret: ${OAUTH2_GITLAB_CLIENT_SECRET:placeholder} + scope: + - read_user + - email + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab} provider: github: user-info-uri: https://api.github.com/user + gitlab: + authorization-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/authorize + token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token + user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user + user-name-attribute: username servlet: multipart: max-file-size: 100MB diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index 47e77a314..a25d31048 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -142,11 +142,12 @@ class AuthControllerTest { mockMvc.perform(get("/api/v1/auth/providers")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.length()").value(2)) - .andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee"))) + .andExpect(jsonPath("$.data.length()").value(3)) + .andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee", "gitlab"))) .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( "/oauth2/authorization/github", - "/oauth2/authorization/gitee" + "/oauth2/authorization/gitee", + "/oauth2/authorization/gitlab" ))) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java index 163694f4c..6e28aa683 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java @@ -1,9 +1,21 @@ package com.iflytek.skillhub.auth.entity; -import jakarta.persistence.*; import java.time.Clock; import java.time.Instant; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + @Entity @Table(name = "identity_binding", uniqueConstraints = @UniqueConstraint(columnNames = {"provider_code", "subject"})) @@ -24,6 +36,7 @@ public class IdentityBinding { @Column(name = "login_name", length = 128) private String loginName; + @JdbcTypeCode(SqlTypes.JSON) @Column(name = "extra_json", columnDefinition = "jsonb") private String extraJson; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java index c578baa3d..20ca3fd68 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java @@ -31,12 +31,14 @@ public class CustomOAuth2UserService implements OAuth2UserService(context.upstreamUser().getAttributes()); attrs.put("platformPrincipal", principal); + // Store providerLogin under a fixed key so DefaultOAuth2User can find it + attrs.put("providerLogin", principal.userId()); var authorities = new LinkedHashSet(context.upstreamUser().getAuthorities()); principal.platformRoles().stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .forEach(authorities::add); - return new DefaultOAuth2User(authorities, attrs, "login"); + return new DefaultOAuth2User(authorities, attrs, "providerLogin"); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java new file mode 100644 index 000000000..d6c840a27 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java @@ -0,0 +1,142 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.Map; + +/** + * Provider-specific claims extractor that enriches GitLab OAuth users with their + * verified email information. + * + *

GitLab OAuth2 user info endpoint returns user profile data. This extractor + * fetches additional email information from GitLab API when needed. + */ +@Component +public class GitLabClaimsExtractor implements OAuthClaimsExtractor { + + private static final Logger log = LoggerFactory.getLogger(GitLabClaimsExtractor.class); + + private final RestClient restClient; + + public GitLabClaimsExtractor(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + @Override + public String getProvider() { + return "gitlab"; + } + + @Override + public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { + Map attrs = oAuth2User.getAttributes(); + log.debug("Extracting GitLab OAuth claims for user attributes: {}", attrs.keySet()); + + // GitLab returns email directly in user info + String email = (String) attrs.get("email"); + + boolean emailVerified = isConfirmed(attrs.get("confirmed_at")); + + log.debug("Initial email from GitLab: {}, verified: {}", email, emailVerified); + + // If email is not verified or not present, try to fetch from emails API + if (email == null || !emailVerified) { + log.debug("Email not verified or missing, attempting to fetch from GitLab emails API"); + GitLabEmail primaryEmail = loadPrimaryEmail(request); + if (primaryEmail != null) { + email = primaryEmail.email(); + emailVerified = true; + log.debug("Found verified email from GitLab API: {}", email); + } else { + log.debug("No verified email found from GitLab emails API"); + } + } + + // GitLab uses "username" for login name + String username = (String) attrs.get("username"); + if (username == null) { + username = (String) attrs.get("login"); + } + + String subject = String.valueOf(attrs.get("id")); + log.info("GitLab OAuth claims extracted - subject: {}, username: {}, email: {}, emailVerified: {}", + subject, username, email, emailVerified); + + return new OAuthClaims( + "gitlab", + subject, + email, + emailVerified, + username, + attrs + ); + } + + private GitLabEmail loadPrimaryEmail(OAuth2UserRequest request) { + String baseUrl = getGitLabApiBaseUrl(request); + log.debug("Loading primary email from GitLab API base URL: {}", baseUrl); + + try { + List emails = restClient.get() + .uri(baseUrl + "/user/emails") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + request.getAccessToken().getTokenValue()) + .retrieve() + .body(new org.springframework.core.ParameterizedTypeReference>() {}); + + if (emails == null || emails.isEmpty()) { + log.debug("No emails returned from GitLab emails API"); + return null; + } + + log.debug("Retrieved {} emails from GitLab API", emails.size()); + + // Return the primary verified email + return emails.stream() + .filter(GitLabEmail::confirmed) + .findFirst() + .orElse(null); + } catch (Exception e) { + log.warn("Failed to fetch emails from GitLab API: {}", e.getMessage()); + return null; + } + } + + /** + * Determines the GitLab API base URL from the provider configuration. + * The user-info-uri is configured as ${OAUTH2_GITLAB_BASE_URI}/api/v4/user, + * so we simply remove the /user suffix to get the API base URL. + */ + private String getGitLabApiBaseUrl(OAuth2UserRequest request) { + String userInfoUri = request.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri(); + log.debug("GitLab user info URI: {}", userInfoUri); + // user-info-uri format: ${OAUTH2_GITLAB_BASE_URI}/api/v4/user + // Remove /user suffix to get API base URL + String baseUrl = userInfoUri.substring(0, userInfoUri.length() - "/user".length()); + log.debug("GitLab API base URL: {}", baseUrl); + return baseUrl; + } + + /** + * GitLab marks a confirmed email by populating confirmed_at. + */ + private record GitLabEmail(String email, @JsonProperty("confirmed_at") String confirmedAt) { + boolean confirmed() { + return confirmedAt != null && !confirmedAt.isBlank(); + } + } + + private boolean isConfirmed(Object confirmedAt) { + return confirmedAt instanceof String value && !value.isBlank(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java index 3908ab713..4b811d36c 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java @@ -1,7 +1,5 @@ package com.iflytek.skillhub.auth.session; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -10,6 +8,10 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.stereotype.Service; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; + +import jakarta.servlet.http.HttpServletRequest; + /** * Synchronizes {@link PlatformPrincipal} snapshots with Spring Security's * session-backed authentication context. @@ -53,7 +55,13 @@ public class PlatformSessionService { Authentication authentication, HttpServletRequest request, boolean rotateSessionId) { - persist(principal, authentication, request, rotateSessionId); + // Create a new authentication with PlatformPrincipal as the principal + // instead of using the OAuth2 authentication which has OAuth2User as principal + var authorities = principal.platformRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + Authentication platformAuth = new UsernamePasswordAuthenticationToken(principal, null, authorities); + persist(principal, platformAuth, request, rotateSessionId); } private void persist(PlatformPrincipal principal, diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java new file mode 100644 index 000000000..643be5838 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java @@ -0,0 +1,103 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.time.Instant; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +class GitLabClaimsExtractorTest { + + @Test + void extract_marksProfileEmailVerifiedWhenConfirmedAtPresent() { + RestClient.Builder restClientBuilder = RestClient.builder(); + GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder); + + OAuthClaims claims = extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "id", 42, + "username", "alice", + "email", "alice@gitlab.example", + "confirmed_at", "2026-04-16T08:00:00Z" + ), + "username" + ) + ); + + assertThat(claims.email()).isEqualTo("alice@gitlab.example"); + assertThat(claims.emailVerified()).isTrue(); + assertThat(claims.providerLogin()).isEqualTo("alice"); + } + + @Test + void extract_loadsConfirmedEmailFromEmailListWhenProfileEmailIsUnconfirmed() { + RestClient.Builder restClientBuilder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build(); + server.expect(requestTo("https://gitlab.example.com/api/v4/user/emails")) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123")) + .andRespond(withSuccess( + """ + [ + {"email":"alice@gitlab.example","confirmed_at":"2026-04-16T08:00:00Z"}, + {"email":"alice+pending@gitlab.example","confirmed_at":null} + ] + """, + MediaType.APPLICATION_JSON + )); + GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder); + + OAuthClaims claims = extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "id", 42, + "username", "alice", + "email", "alice+pending@gitlab.example" + ), + "username" + ) + ); + + assertThat(claims.email()).isEqualTo("alice@gitlab.example"); + assertThat(claims.emailVerified()).isTrue(); + server.verify(); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("gitlab") + .clientId("client-id") + .clientSecret("client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("read_user", "email") + .authorizationUri("https://gitlab.example.com/oauth/authorize") + .tokenUri("https://gitlab.example.com/oauth/token") + .userInfoUri("https://gitlab.example.com/api/v4/user") + .userNameAttributeName("username") + .clientName("GitLab") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "token-123", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java index 6bf8c0984..cb8ef84ca 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java @@ -61,7 +61,7 @@ class OAuth2LoginHandlersTest { assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); assertThat(session.getAttribute("platformPrincipal")).isEqualTo(principal); assertThat(securityContext).isNotNull(); - assertThat(securityContext.getAuthentication()).isSameAs(authentication); + assertThat(securityContext.getAuthentication().getPrincipal()).isEqualTo(principal); } @Test diff --git a/web/public/github-logo.svg b/web/public/github-logo.svg new file mode 100644 index 000000000..a11334011 --- /dev/null +++ b/web/public/github-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/web/public/gitlab-logo.svg b/web/public/gitlab-logo.svg new file mode 100644 index 000000000..71a666d04 --- /dev/null +++ b/web/public/gitlab-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index ba0bc040f..cde4453f0 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -6,6 +6,20 @@ interface LoginButtonProps { returnTo?: string } +/** + * Returns the appropriate icon for a given OAuth provider. + */ +function OAuthIcon({ provider }: { provider: string }) { + const normalizedProvider = provider.toLowerCase() + return ( + {provider} + ) +} + /** * Renders OAuth login buttons from the auth-method catalog returned by the backend. */ @@ -37,12 +51,11 @@ export function LoginButton({ returnTo }: LoginButtonProps) { window.location.href = provider.actionUrl }} > - - - + {t('loginButton.loginWith', { name: provider.displayName })} ))}

) } + diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b0cec8d43..c96495f9d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -212,7 +212,7 @@ "forgotPassword": "Forgot password?", "noAccount": "Don't have an account?", "register": "Sign up now", - "oauthHint": "After GitHub authentication, you will be automatically redirected back to this site.", + "oauthHint": "After OAuth authentication, you will be automatically redirected back to this site.", "passwordCompatHint": "This deployment has the password compatibility layer enabled. The form will route to {{name}} instead of the fixed local account endpoint.", "enterpriseSsoTitle": "Enterprise SSO", "enterpriseSsoHint": "This deployment has the compatibility layer enabled. If your browser already has a {{name}} session, you can try establishing a SkillHub session directly.", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 0e49a24f6..122192852 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -212,7 +212,7 @@ "forgotPassword": "忘记密码?", "noAccount": "还没有账号?", "register": "立即注册", - "oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。", + "oauthHint": "使用 OAuth 登录时,认证完成后会自动返回当前站点。", "passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。", "enterpriseSsoTitle": "企业单点登录", "enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。", From 1d0d6be816b60ab9dcce23e5c26895a320f62d19 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 20 Apr 2026 13:54:41 +0800 Subject: [PATCH 26/26] fix(env): add gitlab oauth release example config --- .env.release.example | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.env.release.example b/.env.release.example index 9b863c23a..3f020fc75 100644 --- a/.env.release.example +++ b/.env.release.example @@ -56,6 +56,13 @@ DEVICE_AUTH_VERIFICATION_URI= OAUTH2_GITHUB_CLIENT_ID= OAUTH2_GITHUB_CLIENT_SECRET= +# Optional: configure real GitLab OAuth before exposing the stack to other users. +# Set OAUTH2_GITLAB_BASE_URI to your self-hosted GitLab URL when applicable. +OAUTH2_GITLAB_CLIENT_ID= +OAUTH2_GITLAB_CLIENT_SECRET= +OAUTH2_GITLAB_BASE_URI=https://gitlab.com +OAUTH2_GITLAB_DISPLAY_NAME=GitLab + # SMTP configuration for password reset verification emails. SPRING_MAIL_HOST= SPRING_MAIL_PORT=587