fix(governance): restore hidden skill management

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-08 11:15:04 +08:00
parent 19cc56be9e
commit 5c5634dd22
11 changed files with 190 additions and 32 deletions

View file

@ -79,7 +79,9 @@ 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));
@ -92,6 +94,28 @@ public class MySkillAppService {
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);
}
if ((keyword == null || keyword.isBlank()) && (namespace == null || namespace.isBlank())) {
return skillRepository.findByHiddenTrue(pageable);
}
Long namespaceId = resolveNamespaceId(namespace);
String normalizedKeyword = normalizeKeyword(keyword);
List<Skill> filtered = skillRepository.findByHiddenTrue().stream()
.filter(skill -> matchesNamespace(skill, namespaceId))
.filter(skill -> matchesKeyword(skill, normalizedKeyword))
.toList();
return page(filtered, page, size);
}
public PageResponse<SkillSummaryResponse> listMyStars(String userId, int page, int size) {
Page<com.iflytek.skillhub.domain.social.SkillStar> starPage = skillStarRepository.findByUserId(
userId,
@ -150,17 +174,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 +185,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) {

View file

@ -230,14 +230,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(skillRepository.findByHiddenTrue(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,6 +246,14 @@ 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_exposesRejectedOwnerPreviewInSummary() {
Skill skill = createSkill(5L, 101L, "rejected-skill", "user-1");

View file

@ -22,6 +22,8 @@ public interface SkillRepository {
void delete(Skill skill);
List<Skill> findByOwnerId(String ownerId);
Page<Skill> findByOwnerId(String ownerId, Pageable pageable);
List<Skill> findByHiddenTrue();
Page<Skill> findByHiddenTrue(Pageable pageable);
void incrementDownloadCount(Long skillId);
void incrementSubscriptionCount(Long skillId);
void decrementSubscriptionCount(Long skillId);

View file

@ -87,6 +87,16 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
return delegate.findByOwnerId(ownerId, pageable);
}
@Override
public List<Skill> findByHiddenTrue() {
return delegate.findByHiddenTrue();
}
@Override
public Page<Skill> findByHiddenTrue(Pageable pageable) {
return delegate.findByHiddenTrue(pageable);
}
@Override
public void incrementDownloadCount(Long skillId) {
delegate.incrementDownloadCount(skillId);

View file

@ -34,12 +34,24 @@ public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRep
Page<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable);
List<Skill> findByOwnerId(String ownerId);
Page<Skill> findByOwnerIdOrderByUpdatedAtDesc(String ownerId, Pageable pageable);
List<Skill> findByHiddenTrueOrderByUpdatedAtDesc();
Page<Skill> findByHiddenTrueOrderByUpdatedAtDesc(Pageable pageable);
@Override
default Page<Skill> findByOwnerId(String ownerId, Pageable pageable) {
return findByOwnerIdOrderByUpdatedAtDesc(ownerId, pageable);
}
@Override
default List<Skill> findByHiddenTrue() {
return findByHiddenTrueOrderByUpdatedAtDesc();
}
@Override
default Page<Skill> findByHiddenTrue(Pageable pageable) {
return findByHiddenTrueOrderByUpdatedAtDesc(pageable);
}
@Modifying
@Transactional
@Query("UPDATE Skill s SET s.downloadCount = s.downloadCount + 1 WHERE s.id = :skillId")

View 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'] })
},
})
}

View file

@ -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.",

View file

@ -386,6 +386,12 @@
"unarchiveSuccessTitle": "Скилл восстановлен",
"unarchiveSuccessDescription": "«{{skill}}» восстановлен и снова может публиковать новые версии.",
"unarchiveErrorTitle": "Не удалось восстановить скилл",
"restoreHidden": "Вернуть видимость",
"restoreHiddenConfirmTitle": "Восстановить скрытый скилл",
"restoreHiddenConfirmDescription": "«{{skill}}» снова появится в обычных разделах продукта.",
"restoreHiddenSuccessTitle": "Видимость скилла восстановлена",
"restoreHiddenSuccessDescription": "«{{skill}}» удалён из списка скрытых скиллов.",
"restoreHiddenErrorTitle": "Не удалось восстановить скрытый скилл",
"withdrawReview": "Отозвать рецензию",
"withdrawConfirmTitle": "Отозвать загрузку",
"withdrawConfirmDescription": "После отзыва «{{skill}}» больше не будет рецензироваться, а ожидающая версия будет удалена.",

View file

@ -386,6 +386,12 @@
"unarchiveSuccessTitle": "技能已恢复",
"unarchiveSuccessDescription": "“{{skill}}”已恢复,可继续发布新版本。",
"unarchiveErrorTitle": "恢复技能失败",
"restoreHidden": "恢复显示",
"restoreHiddenConfirmTitle": "确认恢复隐藏技能",
"restoreHiddenConfirmDescription": "恢复后“{{skill}}”会重新进入普通用户可见的产品入口。",
"restoreHiddenSuccessTitle": "技能已恢复显示",
"restoreHiddenSuccessDescription": "“{{skill}}”已从隐藏技能列表移除。",
"restoreHiddenErrorTitle": "恢复隐藏技能失败",
"withdrawReview": "撤销审核",
"withdrawConfirmTitle": "确认撤销上传",
"withdrawConfirmDescription": "撤销后“{{skill}}”将不再进入审核流程,当前待审核版本会被删除。",

View file

@ -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,17 @@ 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('exports a named component function', () => {
expect(typeof MySkillsPage).toBe('function')
})

View file

@ -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({
@ -102,6 +104,7 @@ export function MySkillsPage() {
const unarchiveMutation = useUnarchiveSkill()
const withdrawMutation = useWithdrawSkillReview()
const submitPromotionMutation = useSubmitPromotion()
const restoreHiddenMutation = useRestoreHiddenSkill()
const handleSkillClick = (namespace: string, slug: string) => {
navigate({
@ -270,6 +273,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">
@ -362,8 +382,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 +427,18 @@ export function MySkillsPage() {
</div>
</div>
<div className="flex items-center gap-2 pl-4">
{skill.status !== 'ARCHIVED' && (
{filter === 'HIDDEN' ? (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setRestoreHiddenTarget({ skillId: skill.id, name: skill.displayName })
}}
>
{t('mySkills.restoreHidden')}
</Button>
) : skill.status !== 'ARCHIVED' && (
<Button
size="sm"
variant="outline"
@ -419,7 +450,7 @@ export function MySkillsPage() {
{t('mySkills.update')}
</Button>
)}
{hasPendingPreview && ownerPreviewVersion ? (
{filter === 'HIDDEN' ? null : hasPendingPreview && ownerPreviewVersion ? (
<Button
size="sm"
variant="outline"
@ -482,9 +513,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 +548,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) => {