mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-10 22:41:02 +00:00
Merge pull request #829 from iflytek/codex/feat/issue-819-hidden-skill-restore-20260908
fix(governance): restore hidden skill management
This commit is contained in:
commit
25e18e047c
16 changed files with 392 additions and 64 deletions
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
/** Query-side access for the platform hidden-skill governance list. */
|
||||
public interface HiddenSkillQueryRepository {
|
||||
Page<Skill> search(String keyword, Long namespaceId, Pageable pageable);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Executes the pageable, presentation-specific hidden-skill search without widening the domain
|
||||
* repository with platform-governance query concerns.
|
||||
*/
|
||||
@Repository
|
||||
public class JpaHiddenSkillQueryRepository implements HiddenSkillQueryRepository {
|
||||
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public JpaHiddenSkillQueryRepository(EntityManager entityManager) {
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<Skill> search(String keyword, Long namespaceId, Pageable pageable) {
|
||||
CriteriaBuilder builder = entityManager.getCriteriaBuilder();
|
||||
|
||||
CriteriaQuery<Skill> query = builder.createQuery(Skill.class);
|
||||
Root<Skill> root = query.from(Skill.class);
|
||||
query.select(root)
|
||||
.where(buildPredicates(keyword, namespaceId, builder, root).toArray(Predicate[]::new))
|
||||
.orderBy(builder.desc(root.get("updatedAt")));
|
||||
TypedQuery<Skill> typedQuery = entityManager.createQuery(query);
|
||||
typedQuery.setFirstResult((int) pageable.getOffset());
|
||||
typedQuery.setMaxResults(pageable.getPageSize());
|
||||
|
||||
CriteriaQuery<Long> countQuery = builder.createQuery(Long.class);
|
||||
Root<Skill> countRoot = countQuery.from(Skill.class);
|
||||
countQuery.select(builder.count(countRoot))
|
||||
.where(buildPredicates(keyword, namespaceId, builder, countRoot).toArray(Predicate[]::new));
|
||||
|
||||
return new PageImpl<>(typedQuery.getResultList(), pageable, entityManager.createQuery(countQuery).getSingleResult());
|
||||
}
|
||||
|
||||
private List<Predicate> buildPredicates(String keyword,
|
||||
Long namespaceId,
|
||||
CriteriaBuilder builder,
|
||||
Root<Skill> root) {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(builder.isTrue(root.get("hidden")));
|
||||
if (namespaceId != null) {
|
||||
predicates.add(builder.equal(root.get("namespaceId"), namespaceId));
|
||||
}
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
String pattern = "%" + keyword.trim().toLowerCase(Locale.ROOT) + "%";
|
||||
predicates.add(builder.or(
|
||||
builder.like(builder.lower(root.get("displayName")), pattern),
|
||||
builder.like(builder.lower(root.get("slug")), pattern),
|
||||
builder.like(builder.lower(root.get("summary")), pattern)
|
||||
));
|
||||
}
|
||||
return predicates;
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +63,33 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
|
|||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SkillSummaryResponse> getHiddenSkillSummaries(List<Skill> skills) {
|
||||
if (skills.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Map<Long, Namespace> namespacesById = namespaceRepository.findByIdIn(
|
||||
skills.stream().map(Skill::getNamespaceId).distinct().toList())
|
||||
.stream()
|
||||
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
|
||||
Map<String, UserAccount> ownersById = userAccountRepository == null
|
||||
? Map.of()
|
||||
: userAccountRepository.findByIdIn(skills.stream().map(Skill::getOwnerId).distinct().toList())
|
||||
.stream().collect(Collectors.toMap(UserAccount::getId, Function.identity()));
|
||||
Map<Long, SkillLifecycleProjectionService.Projection> projections =
|
||||
skillLifecycleProjectionService.projectPublishedSummaries(skills);
|
||||
|
||||
return skills.stream()
|
||||
.map(skill -> toSummaryResponse(
|
||||
skill,
|
||||
namespacesById,
|
||||
ownersById,
|
||||
projections.get(skill.getId()),
|
||||
false
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private SkillSummaryResponse toSummaryResponse(Skill skill,
|
||||
String currentUserId,
|
||||
Map<Long, Namespace> namespacesById,
|
||||
|
|
@ -76,6 +103,21 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
|
|||
if (skill.getOwnerId().equals(currentUserId)) {
|
||||
projection = skillLifecycleProjectionService.projectForOwnerSummary(skill);
|
||||
}
|
||||
return toSummaryResponse(
|
||||
skill,
|
||||
namespacesById,
|
||||
ownersById,
|
||||
projection,
|
||||
canSubmitPromotion(skill, projection.publishedVersion(), namespace)
|
||||
);
|
||||
}
|
||||
|
||||
private SkillSummaryResponse toSummaryResponse(Skill skill,
|
||||
Map<Long, Namespace> namespacesById,
|
||||
Map<String, UserAccount> ownersById,
|
||||
SkillLifecycleProjectionService.Projection projection,
|
||||
boolean canSubmitPromotion) {
|
||||
Namespace namespace = namespacesById.get(skill.getNamespaceId());
|
||||
SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion();
|
||||
SkillLifecycleProjectionService.VersionProjection publishedVersion = projection.publishedVersion();
|
||||
SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion = projection.ownerPreviewVersion();
|
||||
|
|
@ -97,7 +139,7 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
|
|||
ownersById.get(skill.getOwnerId()) != null
|
||||
? ownersById.get(skill.getOwnerId()).getDisplayName()
|
||||
: null,
|
||||
canSubmitPromotion(skill, publishedVersion, namespace),
|
||||
canSubmitPromotion,
|
||||
toLifecycleVersion(headlineVersion),
|
||||
toLifecycleVersion(publishedVersion),
|
||||
toLifecycleVersion(ownerPreviewVersion),
|
||||
|
|
|
|||
|
|
@ -10,4 +10,6 @@ import java.util.List;
|
|||
*/
|
||||
public interface MySkillQueryRepository {
|
||||
List<SkillSummaryResponse> getSkillSummaries(List<Skill> skills, String currentUserId);
|
||||
|
||||
List<SkillSummaryResponse> getHiddenSkillSummaries(List<Skill> skills);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import com.iflytek.skillhub.domain.social.SkillStarRepository;
|
|||
import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.repository.HiddenSkillQueryRepository;
|
||||
import com.iflytek.skillhub.repository.MySkillQueryRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
|
|
@ -37,6 +38,7 @@ public class MySkillAppService {
|
|||
private final SkillStarRepository skillStarRepository;
|
||||
private final SkillSubscriptionRepository skillSubscriptionRepository;
|
||||
private final MySkillQueryRepository mySkillQueryRepository;
|
||||
private final HiddenSkillQueryRepository hiddenSkillQueryRepository;
|
||||
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
|
||||
|
|
@ -46,6 +48,7 @@ public class MySkillAppService {
|
|||
SkillStarRepository skillStarRepository,
|
||||
SkillSubscriptionRepository skillSubscriptionRepository,
|
||||
MySkillQueryRepository mySkillQueryRepository,
|
||||
HiddenSkillQueryRepository hiddenSkillQueryRepository,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService,
|
||||
NamespaceRepository namespaceRepository) {
|
||||
this.skillRepository = skillRepository;
|
||||
|
|
@ -53,6 +56,7 @@ public class MySkillAppService {
|
|||
this.skillStarRepository = skillStarRepository;
|
||||
this.skillSubscriptionRepository = skillSubscriptionRepository;
|
||||
this.mySkillQueryRepository = mySkillQueryRepository;
|
||||
this.hiddenSkillQueryRepository = hiddenSkillQueryRepository;
|
||||
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
}
|
||||
|
|
@ -79,19 +83,36 @@ public class MySkillAppService {
|
|||
MySkillFilter normalizedFilter = parseFilter(filter);
|
||||
|
||||
Page<Skill> skillPage;
|
||||
if (normalizedFilter == MySkillFilter.ALL
|
||||
if (normalizedFilter == MySkillFilter.HIDDEN) {
|
||||
skillPage = listHiddenSkills(page, size, keyword, namespace, platformRoles);
|
||||
} else if (normalizedFilter == MySkillFilter.ALL
|
||||
&& (keyword == null || keyword.isBlank())
|
||||
&& (namespace == null || namespace.isBlank())) {
|
||||
skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size));
|
||||
skillPage = skillRepository.findVisibleByOwnerId(userId, PageRequest.of(page, size));
|
||||
} else {
|
||||
skillPage = filterSkills(userId, page, size, normalizedFilter, keyword, namespace, platformRoles);
|
||||
}
|
||||
|
||||
List<SkillSummaryResponse> items = mySkillQueryRepository.getSkillSummaries(skillPage.getContent(), userId);
|
||||
List<SkillSummaryResponse> items = normalizedFilter == MySkillFilter.HIDDEN
|
||||
? mySkillQueryRepository.getHiddenSkillSummaries(skillPage.getContent())
|
||||
: mySkillQueryRepository.getSkillSummaries(skillPage.getContent(), userId);
|
||||
|
||||
return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize());
|
||||
}
|
||||
|
||||
private Page<Skill> listHiddenSkills(int page,
|
||||
int size,
|
||||
String keyword,
|
||||
String namespace,
|
||||
java.util.Set<String> platformRoles) {
|
||||
PageRequest pageable = PageRequest.of(page, size);
|
||||
if (!platformRoles.contains("SUPER_ADMIN")) {
|
||||
return Page.empty(pageable);
|
||||
}
|
||||
Long namespaceId = resolveNamespaceId(namespace);
|
||||
return hiddenSkillQueryRepository.search(normalizeKeyword(keyword), namespaceId, pageable);
|
||||
}
|
||||
|
||||
public PageResponse<SkillSummaryResponse> listMyStars(String userId, int page, int size) {
|
||||
Page<com.iflytek.skillhub.domain.social.SkillStar> starPage = skillStarRepository.findByUserId(
|
||||
userId,
|
||||
|
|
@ -150,17 +171,10 @@ public class MySkillAppService {
|
|||
List<Skill> skills = skillRepository.findByOwnerId(userId);
|
||||
|
||||
// Namespace filter
|
||||
Long namespaceId = null;
|
||||
if (namespace != null && !namespace.isBlank()) {
|
||||
namespaceId = namespaceRepository.findBySlug(namespace.trim())
|
||||
.map(Namespace::getId)
|
||||
.orElse(-1L);
|
||||
}
|
||||
Long namespaceId = resolveNamespaceId(namespace);
|
||||
|
||||
final Long finalNamespaceId = namespaceId;
|
||||
String normalizedKeyword = keyword != null && !keyword.isBlank()
|
||||
? keyword.trim().toLowerCase(java.util.Locale.ROOT)
|
||||
: null;
|
||||
String normalizedKeyword = normalizeKeyword(keyword);
|
||||
|
||||
List<Skill> filtered = skills.stream()
|
||||
.filter(skill -> matchesNamespace(skill, finalNamespaceId))
|
||||
|
|
@ -168,13 +182,28 @@ public class MySkillAppService {
|
|||
.filter(skill -> matchesFilter(skill, filter, platformRoles))
|
||||
.toList();
|
||||
|
||||
int fromIndex = Math.min(page * size, filtered.size());
|
||||
int toIndex = Math.min(fromIndex + size, filtered.size());
|
||||
return new PageImpl<>(
|
||||
filtered.subList(fromIndex, toIndex),
|
||||
PageRequest.of(page, size),
|
||||
filtered.size()
|
||||
);
|
||||
return page(filtered, page, size);
|
||||
}
|
||||
|
||||
private Long resolveNamespaceId(String namespace) {
|
||||
if (namespace == null || namespace.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return namespaceRepository.findBySlug(namespace.trim())
|
||||
.map(Namespace::getId)
|
||||
.orElse(-1L);
|
||||
}
|
||||
|
||||
private String normalizeKeyword(String keyword) {
|
||||
return keyword != null && !keyword.isBlank()
|
||||
? keyword.trim().toLowerCase(java.util.Locale.ROOT)
|
||||
: null;
|
||||
}
|
||||
|
||||
private Page<Skill> page(List<Skill> skills, int page, int size) {
|
||||
int fromIndex = Math.min(page * size, skills.size());
|
||||
int toIndex = Math.min(fromIndex + size, skills.size());
|
||||
return new PageImpl<>(skills.subList(fromIndex, toIndex), PageRequest.of(page, size), skills.size());
|
||||
}
|
||||
|
||||
private boolean matchesNamespace(Skill skill, Long namespaceId) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ package com.iflytek.skillhub.repository;
|
|||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
|
|
@ -132,4 +135,35 @@ class JpaMySkillQueryRepositoryTest {
|
|||
assertThat(responses.get(0).publishedVersion()).isNotNull();
|
||||
assertThat(responses.get(0).canSubmitPromotion()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getHiddenSkillSummaries_batchesPublishedVersionProjection() {
|
||||
Skill first = new Skill(101L, "first-hidden", "owner-1", SkillVisibility.PUBLIC);
|
||||
Skill second = new Skill(101L, "second-hidden", "owner-2", SkillVisibility.PUBLIC);
|
||||
ReflectionTestUtils.setField(first, "id", 10L);
|
||||
ReflectionTestUtils.setField(second, "id", 20L);
|
||||
first.setLatestVersionId(110L);
|
||||
second.setLatestVersionId(120L);
|
||||
|
||||
SkillVersion firstVersion = new SkillVersion(10L, "1.0.0", "owner-1");
|
||||
SkillVersion secondVersion = new SkillVersion(20L, "2.0.0", "owner-2");
|
||||
firstVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
secondVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
ReflectionTestUtils.setField(firstVersion, "id", 110L);
|
||||
ReflectionTestUtils.setField(secondVersion, "id", 120L);
|
||||
|
||||
Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", 101L);
|
||||
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace));
|
||||
given(skillVersionRepository.findByIdIn(List.of(110L, 120L)))
|
||||
.willReturn(List.of(firstVersion, secondVersion));
|
||||
|
||||
var responses = repository.getHiddenSkillSummaries(List.of(first, second));
|
||||
|
||||
assertThat(responses).extracting("slug").containsExactly("first-hidden", "second-hidden");
|
||||
assertThat(responses).allMatch(response -> !response.canSubmitPromotion());
|
||||
verify(skillVersionRepository).findByIdIn(List.of(110L, 120L));
|
||||
verifyNoMoreInteractions(skillVersionRepository);
|
||||
verifyNoInteractions(promotionRequestRepository);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import com.iflytek.skillhub.domain.social.SkillStar;
|
|||
import com.iflytek.skillhub.domain.social.SkillStarRepository;
|
||||
import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository;
|
||||
import com.iflytek.skillhub.repository.JpaMySkillQueryRepository;
|
||||
import com.iflytek.skillhub.repository.HiddenSkillQueryRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
|
@ -57,6 +58,9 @@ class MySkillAppServiceTest {
|
|||
@Mock
|
||||
private PromotionRequestRepository promotionRequestRepository;
|
||||
|
||||
@Mock
|
||||
private HiddenSkillQueryRepository hiddenSkillQueryRepository;
|
||||
|
||||
private MySkillAppService service;
|
||||
private SkillLifecycleProjectionService skillLifecycleProjectionService;
|
||||
private JpaMySkillQueryRepository mySkillQueryRepository;
|
||||
|
|
@ -75,6 +79,7 @@ class MySkillAppServiceTest {
|
|||
skillStarRepository,
|
||||
skillSubscriptionRepository,
|
||||
mySkillQueryRepository,
|
||||
hiddenSkillQueryRepository,
|
||||
skillLifecycleProjectionService,
|
||||
namespaceRepository
|
||||
);
|
||||
|
|
@ -132,7 +137,7 @@ class MySkillAppServiceTest {
|
|||
ReflectionTestUtils.setField(pendingVersion, "id", 11L);
|
||||
ReflectionTestUtils.setField(pendingVersion, "createdAt", Instant.parse("2026-03-15T09:30:00Z"));
|
||||
|
||||
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
|
||||
given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10)))
|
||||
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
|
||||
given(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).willReturn(List.of());
|
||||
given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(pendingVersion));
|
||||
|
|
@ -165,7 +170,7 @@ class MySkillAppServiceTest {
|
|||
Namespace namespace = new Namespace("team-ai", "Team AI", "user-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", 101L);
|
||||
|
||||
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
|
||||
given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10)))
|
||||
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
|
||||
given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion));
|
||||
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(publishedVersion));
|
||||
|
|
@ -197,7 +202,7 @@ class MySkillAppServiceTest {
|
|||
Namespace namespace = new Namespace("team-ai", "Team AI", "user-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", 101L);
|
||||
|
||||
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
|
||||
given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10)))
|
||||
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
|
||||
given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion));
|
||||
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(publishedVersion));
|
||||
|
|
@ -230,14 +235,12 @@ class MySkillAppServiceTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void listMySkills_filtersHiddenOnlyForSuperAdmins() {
|
||||
Skill hiddenSkill = createSkill(3L, 101L, "hidden-skill", "user-1");
|
||||
void listMySkills_listsHiddenSkillsAcrossOwnersOnlyForSuperAdmins() {
|
||||
Skill hiddenSkill = createSkill(3L, 101L, "hidden-skill", "publisher");
|
||||
hiddenSkill.setHidden(true);
|
||||
Skill publishedSkill = createSkill(4L, 101L, "published-skill", "user-1");
|
||||
SkillVersion hiddenVersion = createVersion(3L, 33L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z");
|
||||
|
||||
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(hiddenSkill, publishedSkill));
|
||||
given(skillVersionRepository.findBySkillId(3L)).willReturn(List.of(hiddenVersion));
|
||||
given(hiddenSkillQueryRepository.search(null, null, PageRequest.of(0, 10)))
|
||||
.willReturn(new PageImpl<>(List.of(hiddenSkill), PageRequest.of(0, 10), 1));
|
||||
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
|
||||
|
||||
var regularUserResult = service.listMySkills("user-1", 0, 10, "HIDDEN", Set.of("USER"));
|
||||
|
|
@ -248,12 +251,41 @@ class MySkillAppServiceTest {
|
|||
assertThat(superAdminResult.items()).extracting("slug").containsExactly("hidden-skill");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMySkills_doesNotExposeHiddenSkillsToSkillAdmins() {
|
||||
var result = service.listMySkills("skill-admin", 0, 10, "HIDDEN", Set.of("SKILL_ADMIN"));
|
||||
|
||||
assertThat(result.total()).isZero();
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMySkills_delegatesHiddenFilteringAndPaginationToQueryRepository() {
|
||||
Skill hiddenSkill = createSkill(7L, 101L, "hidden-agent", "publisher");
|
||||
hiddenSkill.setHidden(true);
|
||||
Namespace namespace = namespace(101L, "team-ai");
|
||||
PageRequest pageRequest = PageRequest.of(1, 5);
|
||||
|
||||
given(namespaceRepository.findBySlug("team-ai")).willReturn(Optional.of(namespace));
|
||||
given(hiddenSkillQueryRepository.search("agent", 101L, pageRequest))
|
||||
.willReturn(new PageImpl<>(List.of(hiddenSkill), pageRequest, 6));
|
||||
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace));
|
||||
|
||||
var result = service.listMySkills(
|
||||
"super-admin", 1, 5, "HIDDEN", " Agent ", "team-ai", Set.of("SUPER_ADMIN"));
|
||||
|
||||
assertThat(result.total()).isEqualTo(6);
|
||||
assertThat(result.page()).isEqualTo(1);
|
||||
assertThat(result.size()).isEqualTo(5);
|
||||
assertThat(result.items()).extracting("slug").containsExactly("hidden-agent");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMySkills_exposesRejectedOwnerPreviewInSummary() {
|
||||
Skill skill = createSkill(5L, 101L, "rejected-skill", "user-1");
|
||||
SkillVersion rejectedVersion = createVersion(5L, 55L, "1.1.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z");
|
||||
|
||||
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
|
||||
given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10)))
|
||||
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
|
||||
given(skillVersionRepository.findBySkillId(5L)).willReturn(List.of(rejectedVersion));
|
||||
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
|
||||
|
|
@ -272,7 +304,7 @@ class MySkillAppServiceTest {
|
|||
SkillVersion rejectedVersion = createVersion(6L, 60L, "1.0.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z");
|
||||
SkillVersion publishedVersion = createVersion(6L, 61L, "2.0.0", SkillVersionStatus.PUBLISHED, "2026-03-16T09:30:00Z");
|
||||
|
||||
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
|
||||
given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10)))
|
||||
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
|
||||
given(skillVersionRepository.findBySkillIdAndStatus(6L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion));
|
||||
given(skillVersionRepository.findBySkillId(6L)).willReturn(List.of(rejectedVersion, publishedVersion));
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ public interface SkillRepository {
|
|||
void flush();
|
||||
void delete(Skill skill);
|
||||
List<Skill> findByOwnerId(String ownerId);
|
||||
Page<Skill> findByOwnerId(String ownerId, Pageable pageable);
|
||||
Page<Skill> findVisibleByOwnerId(String ownerId, Pageable pageable);
|
||||
void incrementDownloadCount(Long skillId);
|
||||
void incrementSubscriptionCount(Long skillId);
|
||||
void decrementSubscriptionCount(Long skillId);
|
||||
|
|
|
|||
|
|
@ -83,8 +83,8 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
|
|||
}
|
||||
|
||||
@Override
|
||||
public Page<Skill> findByOwnerId(String ownerId, Pageable pageable) {
|
||||
return delegate.findByOwnerId(ownerId, pageable);
|
||||
public Page<Skill> findVisibleByOwnerId(String ownerId, Pageable pageable) {
|
||||
return delegate.findVisibleByOwnerId(ownerId, pageable);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -33,11 +33,11 @@ public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRep
|
|||
List<Skill> findByNamespaceIdAndStatusOrderByCreatedAtDesc(Long namespaceId, SkillStatus status);
|
||||
Page<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable);
|
||||
List<Skill> findByOwnerId(String ownerId);
|
||||
Page<Skill> findByOwnerIdOrderByUpdatedAtDesc(String ownerId, Pageable pageable);
|
||||
Page<Skill> findByOwnerIdAndHiddenFalseOrderByUpdatedAtDesc(String ownerId, Pageable pageable);
|
||||
|
||||
@Override
|
||||
default Page<Skill> findByOwnerId(String ownerId, Pageable pageable) {
|
||||
return findByOwnerIdOrderByUpdatedAtDesc(ownerId, pageable);
|
||||
default Page<Skill> findVisibleByOwnerId(String ownerId, Pageable pageable) {
|
||||
return findByOwnerIdAndHiddenFalseOrderByUpdatedAtDesc(ownerId, pageable);
|
||||
}
|
||||
|
||||
@Modifying
|
||||
|
|
|
|||
13
web/src/features/admin/use-admin-skills.ts
Normal file
13
web/src/features/admin/use-admin-skills.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
export function useRestoreHiddenSkill() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (skillId: number) => adminApi.unhideSkill(skillId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skills'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -386,6 +386,12 @@
|
|||
"unarchiveSuccessTitle": "Skill restored",
|
||||
"unarchiveSuccessDescription": "\"{{skill}}\" has been restored and can publish new versions again.",
|
||||
"unarchiveErrorTitle": "Failed to restore skill",
|
||||
"restoreHidden": "Restore visibility",
|
||||
"restoreHiddenConfirmTitle": "Restore hidden skill",
|
||||
"restoreHiddenConfirmDescription": "\"{{skill}}\" will return to ordinary product surfaces.",
|
||||
"restoreHiddenSuccessTitle": "Skill visibility restored",
|
||||
"restoreHiddenSuccessDescription": "\"{{skill}}\" was removed from the hidden skill list.",
|
||||
"restoreHiddenErrorTitle": "Failed to restore hidden skill",
|
||||
"withdrawReview": "Withdraw Review",
|
||||
"withdrawConfirmTitle": "Withdraw upload",
|
||||
"withdrawConfirmDescription": "After withdrawal, \"{{skill}}\" will no longer be reviewed and the pending version will be deleted.",
|
||||
|
|
|
|||
|
|
@ -386,6 +386,12 @@
|
|||
"unarchiveSuccessTitle": "Скилл восстановлен",
|
||||
"unarchiveSuccessDescription": "«{{skill}}» восстановлен и снова может публиковать новые версии.",
|
||||
"unarchiveErrorTitle": "Не удалось восстановить скилл",
|
||||
"restoreHidden": "Вернуть видимость",
|
||||
"restoreHiddenConfirmTitle": "Восстановить скрытый скилл",
|
||||
"restoreHiddenConfirmDescription": "«{{skill}}» снова появится в обычных разделах продукта.",
|
||||
"restoreHiddenSuccessTitle": "Видимость скилла восстановлена",
|
||||
"restoreHiddenSuccessDescription": "«{{skill}}» удалён из списка скрытых скиллов.",
|
||||
"restoreHiddenErrorTitle": "Не удалось восстановить скрытый скилл",
|
||||
"withdrawReview": "Отозвать рецензию",
|
||||
"withdrawConfirmTitle": "Отозвать загрузку",
|
||||
"withdrawConfirmDescription": "После отзыва «{{skill}}» больше не будет рецензироваться, а ожидающая версия будет удалена.",
|
||||
|
|
|
|||
|
|
@ -386,6 +386,12 @@
|
|||
"unarchiveSuccessTitle": "技能已恢复",
|
||||
"unarchiveSuccessDescription": "“{{skill}}”已恢复,可继续发布新版本。",
|
||||
"unarchiveErrorTitle": "恢复技能失败",
|
||||
"restoreHidden": "恢复显示",
|
||||
"restoreHiddenConfirmTitle": "确认恢复隐藏技能",
|
||||
"restoreHiddenConfirmDescription": "恢复后“{{skill}}”会重新进入普通用户可见的产品入口。",
|
||||
"restoreHiddenSuccessTitle": "技能已恢复显示",
|
||||
"restoreHiddenSuccessDescription": "“{{skill}}”已从隐藏技能列表移除。",
|
||||
"restoreHiddenErrorTitle": "恢复隐藏技能失败",
|
||||
"withdrawReview": "撤销审核",
|
||||
"withdrawConfirmTitle": "确认撤销上传",
|
||||
"withdrawConfirmDescription": "撤销后“{{skill}}”将不再进入审核流程,当前待审核版本会被删除。",
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
const navigateMock = vi.fn()
|
||||
const buttonRecords: Array<{ label: string; onClick?: ((event?: { stopPropagation: () => void }) => void) | undefined }> = []
|
||||
const useMySkillsMock = vi.fn()
|
||||
const searchMock: { filter?: string } = {}
|
||||
let isSuperAdmin = false
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useLocation: () => ({ pathname: '/dashboard/skills' }),
|
||||
useSearch: () => ({}),
|
||||
useSearch: () => searchMock,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
|
|
@ -23,7 +25,7 @@ vi.mock('react-i18next', async () => {
|
|||
})
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({ hasRole: () => false }),
|
||||
useAuth: () => ({ hasRole: (role: string) => role === 'SUPER_ADMIN' && isSuperAdmin }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
|
|
@ -79,6 +81,10 @@ vi.mock('@/shared/hooks/use-debounce', () => ({
|
|||
useDebounce: (value: string) => value,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/admin/use-admin-skills', () => ({
|
||||
useRestoreHiddenSkill: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/skill-lifecycle', () => ({
|
||||
getHeadlineVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }),
|
||||
getPublishedVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }),
|
||||
|
|
@ -114,6 +120,8 @@ describe('MySkillsPage', () => {
|
|||
beforeEach(() => {
|
||||
navigateMock.mockReset()
|
||||
buttonRecords.length = 0
|
||||
delete searchMock.filter
|
||||
isSuperAdmin = false
|
||||
useMySkillsMock.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
|
|
@ -216,6 +224,25 @@ describe('MySkillsPage', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('shows a direct restore action instead of owner actions in the hidden governance view', () => {
|
||||
searchMock.filter = 'HIDDEN'
|
||||
isSuperAdmin = true
|
||||
|
||||
renderToStaticMarkup(createElement(MySkillsPage))
|
||||
|
||||
expect(buttonRecords.some((button) => button.label === 'mySkills.restoreHidden')).toBe(true)
|
||||
expect(buttonRecords.some((button) => button.label === 'mySkills.update')).toBe(false)
|
||||
expect(buttonRecords.some((button) => button.label === 'mySkills.archive')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render the restore action for a non-super-admin even when the hidden filter is in the URL', () => {
|
||||
searchMock.filter = 'HIDDEN'
|
||||
|
||||
renderToStaticMarkup(createElement(MySkillsPage))
|
||||
|
||||
expect(buttonRecords.some((button) => button.label === 'mySkills.restoreHidden')).toBe(false)
|
||||
})
|
||||
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof MySkillsPage).toBe('function')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useArchiveSkill, useUnarchiveSkill, useWithdrawSkillReview } from '@/sh
|
|||
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
|
||||
import { useMySkills, useSubmitPromotion } from '@/shared/hooks/use-user-queries'
|
||||
import { useDebounce } from '@/shared/hooks/use-debounce'
|
||||
import { useRestoreHiddenSkill } from '@/features/admin/use-admin-skills'
|
||||
import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
|
|
@ -63,6 +64,7 @@ export function MySkillsPage() {
|
|||
const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null)
|
||||
const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null)
|
||||
const [restoreHiddenTarget, setRestoreHiddenTarget] = useState<{ skillId: number; name: string } | null>(null)
|
||||
|
||||
const updateSearch = useCallback((next: Partial<typeof search>, options?: { replace?: boolean }) => {
|
||||
navigate({
|
||||
|
|
@ -96,12 +98,14 @@ export function MySkillsPage() {
|
|||
const skills = skillPage?.items ?? []
|
||||
const totalPages = skillPage ? Math.max(Math.ceil(skillPage.total / skillPage.size), 1) : 1
|
||||
const availableFilters = getMySkillFilters(hasRole('SUPER_ADMIN'))
|
||||
const canRestoreHidden = filter === 'HIDDEN' && hasRole('SUPER_ADMIN')
|
||||
const hasActiveSearch = keyword.trim() !== '' || namespaceFilter !== ''
|
||||
const emptyStateKey = getMySkillEmptyStateKey(filter)
|
||||
const archiveMutation = useArchiveSkill()
|
||||
const unarchiveMutation = useUnarchiveSkill()
|
||||
const withdrawMutation = useWithdrawSkillReview()
|
||||
const submitPromotionMutation = useSubmitPromotion()
|
||||
const restoreHiddenMutation = useRestoreHiddenSkill()
|
||||
|
||||
const handleSkillClick = (namespace: string, slug: string) => {
|
||||
navigate({
|
||||
|
|
@ -270,6 +274,23 @@ export function MySkillsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleRestoreHiddenSkill = async () => {
|
||||
if (!restoreHiddenTarget) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await restoreHiddenMutation.mutateAsync(restoreHiddenTarget.skillId)
|
||||
toast.success(
|
||||
t('mySkills.restoreHiddenSuccessTitle'),
|
||||
t('mySkills.restoreHiddenSuccessDescription', { skill: restoreHiddenTarget.name }),
|
||||
)
|
||||
setRestoreHiddenTarget(null)
|
||||
} catch (error) {
|
||||
toast.error(t('mySkills.restoreHiddenErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
|
|
@ -302,24 +323,26 @@ export function MySkillsPage() {
|
|||
aria-label={t('mySkills.searchPlaceholder')}
|
||||
className="sm:max-w-md"
|
||||
/>
|
||||
<Select
|
||||
value={namespaceFilter || ALL_NAMESPACES_VALUE}
|
||||
onValueChange={(value) => {
|
||||
updateSearch({ namespace: value === ALL_NAMESPACES_VALUE ? undefined : value, page: 0 })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={t('mySkills.namespaceFilterLabel')} className="sm:max-w-[14rem]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_NAMESPACES_VALUE}>{t('mySkills.namespaceFilterAll')}</SelectItem>
|
||||
{(namespaceOptions ?? []).map((ns: { id: number; slug: string }) => (
|
||||
<SelectItem key={ns.id} value={ns.slug}>
|
||||
@{ns.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{filter === 'HIDDEN' ? null : (
|
||||
<Select
|
||||
value={namespaceFilter || ALL_NAMESPACES_VALUE}
|
||||
onValueChange={(value) => {
|
||||
updateSearch({ namespace: value === ALL_NAMESPACES_VALUE ? undefined : value, page: 0 })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={t('mySkills.namespaceFilterLabel')} className="sm:max-w-[14rem]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_NAMESPACES_VALUE}>{t('mySkills.namespaceFilterAll')}</SelectItem>
|
||||
{(namespaceOptions ?? []).map((ns: { id: number; slug: string }) => (
|
||||
<SelectItem key={ns.id} value={ns.slug}>
|
||||
@{ns.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{hasActiveSearch ? (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -362,8 +385,8 @@ export function MySkillsPage() {
|
|||
return (
|
||||
<Card
|
||||
key={skill.id}
|
||||
className={`p-5 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
|
||||
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
|
||||
className={`p-5 group animate-fade-up delay-${Math.min(idx + 1, 6)} ${filter === 'HIDDEN' ? '' : 'cursor-pointer'}`}
|
||||
onClick={filter === 'HIDDEN' ? undefined : () => handleSkillClick(skill.namespace, skill.slug)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
|
|
@ -407,7 +430,18 @@ export function MySkillsPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
{skill.status !== 'ARCHIVED' && (
|
||||
{canRestoreHidden ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setRestoreHiddenTarget({ skillId: skill.id, name: skill.displayName })
|
||||
}}
|
||||
>
|
||||
{t('mySkills.restoreHidden')}
|
||||
</Button>
|
||||
) : filter === 'HIDDEN' ? null : skill.status !== 'ARCHIVED' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -419,7 +453,7 @@ export function MySkillsPage() {
|
|||
{t('mySkills.update')}
|
||||
</Button>
|
||||
)}
|
||||
{hasPendingPreview && ownerPreviewVersion ? (
|
||||
{filter === 'HIDDEN' ? null : hasPendingPreview && ownerPreviewVersion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -482,9 +516,11 @@ export function MySkillsPage() {
|
|||
{t('mySkills.archive')}
|
||||
</Button>
|
||||
) : null}
|
||||
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{filter === 'HIDDEN' ? null : (
|
||||
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -515,6 +551,19 @@ export function MySkillsPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!restoreHiddenTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setRestoreHiddenTarget(null)
|
||||
}
|
||||
}}
|
||||
title={t('mySkills.restoreHiddenConfirmTitle')}
|
||||
description={restoreHiddenTarget ? t('mySkills.restoreHiddenConfirmDescription', { skill: restoreHiddenTarget.name }) : ''}
|
||||
confirmText={t('mySkills.restoreHidden')}
|
||||
onConfirm={handleRestoreHiddenSkill}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!promotionTarget}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue