fix: add my skills status filters

This commit is contained in:
yun-zhi-ztl 2026-03-19 17:06:26 +08:00
parent 234d5d83b9
commit 296c713652
11 changed files with 272 additions and 11 deletions

View file

@ -33,12 +33,16 @@ public class MeController extends BaseApiController {
public ApiResponse<PageResponse<SkillSummaryResponse>> listMySkills(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String filter,
@AuthenticationPrincipal PlatformPrincipal principal) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}
return ok("response.success.read", mySkillAppService.listMySkills(principal.userId(), page, size));
return ok(
"response.success.read",
mySkillAppService.listMySkills(principal.userId(), page, size, filter, principal.platformRoles())
);
}
@GetMapping("/stars")

View file

@ -16,6 +16,7 @@ import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
@ -31,6 +32,15 @@ import java.util.stream.Collectors;
*/
@Service
public class MySkillAppService {
public enum MySkillFilter {
ALL,
PENDING_REVIEW,
PUBLISHED,
REJECTED,
ARCHIVED,
HIDDEN
}
private final SkillRepository skillRepository;
private final NamespaceRepository namespaceRepository;
private final SkillVersionRepository skillVersionRepository;
@ -54,7 +64,18 @@ public class MySkillAppService {
}
public PageResponse<SkillSummaryResponse> listMySkills(String userId, int page, int size) {
Page<Skill> skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size));
return listMySkills(userId, page, size, null, java.util.Set.of());
}
public PageResponse<SkillSummaryResponse> listMySkills(String userId,
int page,
int size,
String filter,
java.util.Set<String> platformRoles) {
MySkillFilter normalizedFilter = parseFilter(filter);
Page<Skill> skillPage = normalizedFilter == MySkillFilter.ALL
? skillRepository.findByOwnerId(userId, PageRequest.of(page, size))
: filterSkillsByLifecycle(userId, page, size, normalizedFilter, platformRoles);
List<Skill> skills = skillPage.getContent();
List<Long> namespaceIds = skills.stream()
@ -117,6 +138,9 @@ public class MySkillAppService {
currentUserId,
Map.of()
);
if (skill.getOwnerId().equals(currentUserId)) {
projection = skillLifecycleProjectionService.projectForOwnerSummary(skill);
}
SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion();
SkillLifecycleProjectionService.VersionProjection publishedVersion = projection.publishedVersion();
SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion = projection.ownerPreviewVersion();
@ -169,4 +193,60 @@ public class MySkillAppService {
}
return new SkillLifecycleVersionResponse(projection.id(), projection.version(), projection.status());
}
private Page<Skill> filterSkillsByLifecycle(String userId,
int page,
int size,
MySkillFilter filter,
java.util.Set<String> platformRoles) {
List<Skill> skills = skillRepository.findByOwnerId(userId);
List<Skill> filtered = skills.stream()
.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()
);
}
private boolean matchesFilter(Skill skill, MySkillFilter filter, java.util.Set<String> platformRoles) {
if (filter == MySkillFilter.HIDDEN) {
return platformRoles.contains("SUPER_ADMIN") && skill.isHidden();
}
if (skill.isHidden()) {
return false;
}
if (filter == MySkillFilter.ARCHIVED) {
return skill.getStatus() == com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED;
}
if (skill.getStatus() == com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED) {
return false;
}
SkillLifecycleProjectionService.Projection projection = skillLifecycleProjectionService.projectForOwnerSummary(skill);
SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion = projection.ownerPreviewVersion();
SkillLifecycleProjectionService.VersionProjection publishedVersion = projection.publishedVersion();
return switch (filter) {
case PENDING_REVIEW -> ownerPreviewVersion != null && "PENDING_REVIEW".equals(ownerPreviewVersion.status());
case PUBLISHED -> publishedVersion != null;
case REJECTED -> ownerPreviewVersion != null && "REJECTED".equals(ownerPreviewVersion.status());
case ALL, ARCHIVED, HIDDEN -> true;
};
}
private MySkillFilter parseFilter(String filter) {
if (filter == null || filter.isBlank()) {
return MySkillFilter.ALL;
}
try {
return MySkillFilter.valueOf(filter.trim().toUpperCase(java.util.Locale.ROOT));
} catch (IllegalArgumentException ignored) {
return MySkillFilter.ALL;
}
}
}

View file

@ -56,7 +56,7 @@ class MeControllerTest {
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
given(mySkillAppService.listMySkills("user-42", 1, 5))
given(mySkillAppService.listMySkills("user-42", 1, 5, null, Set.of("USER")))
.willReturn(new PageResponse<>(
List.of(new SkillSummaryResponse(
7L,
@ -93,6 +93,25 @@ class MeControllerTest {
.andExpect(jsonPath("$.data.size").value(5));
}
@Test
void listMySkills_forwardsFilterAndRoles() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"user-42", "tester", "tester@example.com", "", "github", Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
given(mySkillAppService.listMySkills("user-42", 0, 10, "HIDDEN", Set.of("SUPER_ADMIN")))
.willReturn(new PageResponse<>(List.of(), 0, 0, 10));
mockMvc.perform(get("/api/v1/me/skills")
.with(authentication(auth))
.param("filter", "HIDDEN"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(0));
}
@Test
void listMyStars_returns_paginated_items() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(

View file

@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.review.PromotionRequest;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
@ -27,6 +28,7 @@ import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
@ -94,6 +96,7 @@ class MySkillAppServiceTest {
given(skillRepository.findByIdIn(List.of(2L))).willReturn(List.of(secondSkill));
given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of());
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of());
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1")));
var stars = service.listMyStars("user-1", 1, 1);
@ -120,7 +123,7 @@ class MySkillAppServiceTest {
given(skillRepository.findByOwnerId("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.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)).willReturn(List.of(pendingVersion));
given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(pendingVersion));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1")));
var skills = service.listMySkills("user-1", 0, 10);
@ -153,6 +156,7 @@ class MySkillAppServiceTest {
given(skillRepository.findByOwnerId("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));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace));
given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.PENDING)).willReturn(Optional.empty());
given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.APPROVED)).willReturn(Optional.empty());
@ -184,6 +188,7 @@ class MySkillAppServiceTest {
given(skillRepository.findByOwnerId("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));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace));
given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.PENDING))
.willReturn(Optional.of(new PromotionRequest(2L, 22L, 999L, "user-1")));
@ -193,4 +198,87 @@ class MySkillAppServiceTest {
assertThat(skills.items()).hasSize(1);
assertThat(skills.items().get(0).canSubmitPromotion()).isFalse();
}
@Test
void listMySkills_filtersPendingReviewForRegularUsers() {
Skill pendingSkill = createSkill(1L, 101L, "pending-skill", "user-1");
SkillVersion pendingVersion = createVersion(1L, 11L, "1.0.0", SkillVersionStatus.PENDING_REVIEW, "2026-03-15T09:30:00Z");
Skill publishedSkill = createSkill(2L, 101L, "published-skill", "user-1");
SkillVersion publishedVersion = createVersion(2L, 22L, "1.2.0", SkillVersionStatus.PUBLISHED, "2026-03-15T10:30:00Z");
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(pendingSkill, publishedSkill));
given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(pendingVersion));
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(publishedVersion));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
var result = service.listMySkills("user-1", 0, 10, "PENDING_REVIEW", Set.of("USER"));
assertThat(result.total()).isEqualTo(1);
assertThat(result.items()).extracting("slug").containsExactly("pending-skill");
}
@Test
void listMySkills_filtersHiddenOnlyForSuperAdmins() {
Skill hiddenSkill = createSkill(3L, 101L, "hidden-skill", "user-1");
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(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
var regularUserResult = service.listMySkills("user-1", 0, 10, "HIDDEN", Set.of("USER"));
var superAdminResult = service.listMySkills("user-1", 0, 10, "HIDDEN", Set.of("SUPER_ADMIN"));
assertThat(regularUserResult.total()).isZero();
assertThat(superAdminResult.total()).isEqualTo(1);
assertThat(superAdminResult.items()).extracting("slug").containsExactly("hidden-skill");
}
@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)))
.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")));
var result = service.listMySkills("user-1", 0, 10);
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).ownerPreviewVersion()).isNotNull();
assertThat(result.items().get(0).ownerPreviewVersion().status()).isEqualTo("REJECTED");
assertThat(result.items().get(0).headlineVersion().status()).isEqualTo("REJECTED");
}
private Skill createSkill(Long id, Long namespaceId, String slug, String ownerId) {
Skill skill = new Skill(namespaceId, slug, ownerId, SkillVisibility.PUBLIC);
skill.setDisplayName(slug);
ReflectionTestUtils.setField(skill, "id", id);
ReflectionTestUtils.setField(skill, "namespaceId", namespaceId);
ReflectionTestUtils.setField(skill, "updatedAt", Instant.parse("2026-03-15T11:00:00Z"));
ReflectionTestUtils.setField(skill, "ratingAvg", BigDecimal.ZERO);
ReflectionTestUtils.setField(skill, "ratingCount", 0);
ReflectionTestUtils.setField(skill, "downloadCount", 0L);
ReflectionTestUtils.setField(skill, "starCount", 0);
skill.setStatus(SkillStatus.ACTIVE);
return skill;
}
private SkillVersion createVersion(Long skillId, Long id, String version, SkillVersionStatus status, String createdAt) {
SkillVersion skillVersion = new SkillVersion(skillId, version, "user-1");
skillVersion.setStatus(status);
ReflectionTestUtils.setField(skillVersion, "id", id);
ReflectionTestUtils.setField(skillVersion, "createdAt", Instant.parse(createdAt));
return skillVersion;
}
private Namespace namespace(Long id, String slug) {
Namespace namespace = new Namespace(slug, slug, "user-1");
ReflectionTestUtils.setField(namespace, "id", id);
return namespace;
}
}

View file

@ -845,10 +845,13 @@ export const governanceApi = {
}
export const meApi = {
async getSkills(params?: { page?: number; size?: number }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> {
async getSkills(params?: { page?: number; size?: number; filter?: string }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> {
const searchParams = new URLSearchParams()
searchParams.set('page', String(params?.page ?? 0))
searchParams.set('size', String(params?.size ?? 10))
if (params?.filter) {
searchParams.set('filter', params.filter)
}
return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/skills?${searchParams.toString()}`)
},

View file

@ -295,6 +295,14 @@
"mySkills": {
"title": "My Skills",
"subtitle": "Manage your published skills",
"filters": {
"ALL": "All",
"PENDING_REVIEW": "Pending Review",
"PUBLISHED": "Published",
"REJECTED": "Rejected",
"ARCHIVED": "Archived",
"HIDDEN": "Hidden"
},
"publishNew": "Publish New Skill",
"archive": "Archive",
"unarchive": "Restore",
@ -302,6 +310,7 @@
"statusPendingReview": "Pending Review",
"statusPublished": "Published",
"statusRejected": "Rejected",
"statusHidden": "Hidden",
"archiveConfirmTitle": "Archive skill",
"archiveConfirmDescription": "After archiving, regular users will no longer be able to view or download \"{{skill}}\". Continue?",
"unarchiveConfirmTitle": "Restore skill",

View file

@ -295,6 +295,14 @@
"mySkills": {
"title": "我的技能",
"subtitle": "管理你发布的技能",
"filters": {
"ALL": "全部",
"PENDING_REVIEW": "待审核",
"PUBLISHED": "已发布",
"REJECTED": "已拒绝",
"ARCHIVED": "已归档",
"HIDDEN": "已隐藏"
},
"publishNew": "发布新技能",
"archive": "归档",
"unarchive": "恢复",
@ -302,6 +310,7 @@
"statusPendingReview": "审核中",
"statusPublished": "已发布",
"statusRejected": "已拒绝",
"statusHidden": "已隐藏",
"archiveConfirmTitle": "确认归档技能",
"archiveConfirmDescription": "归档后普通用户将无法看到或下载“{{skill}}”,确定继续吗?",
"unarchiveConfirmTitle": "确认恢复技能",

View file

@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { getMySkillFilters } from './my-skill-filters'
describe('getMySkillFilters', () => {
it('returns lifecycle filters for regular users', () => {
expect(getMySkillFilters(false)).toEqual(['ALL', 'PENDING_REVIEW', 'PUBLISHED', 'REJECTED', 'ARCHIVED'])
})
it('adds hidden filter for super admins', () => {
expect(getMySkillFilters(true)).toEqual(['ALL', 'PUBLISHED', 'HIDDEN', 'ARCHIVED'])
})
})

View file

@ -0,0 +1,9 @@
export type MySkillFilter = 'ALL' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | 'ARCHIVED' | 'HIDDEN'
export function getMySkillFilters(isSuperAdmin: boolean): MySkillFilter[] {
if (isSuperAdmin) {
return ['ALL', 'PUBLISHED', 'HIDDEN', 'ARCHIVED']
}
return ['ALL', 'PENDING_REVIEW', 'PUBLISHED', 'REJECTED', 'ARCHIVED']
}

View file

@ -1,6 +1,7 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { EmptyState } from '@/shared/components/empty-state'
@ -12,6 +13,7 @@ import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPen
import { formatCompactCount } from '@/shared/lib/number-format'
import { toast } from '@/shared/lib/toast'
import { ApiError } from '@/api/client'
import { getMySkillFilters, type MySkillFilter } from './my-skill-filters'
const PAGE_SIZE = 10
@ -34,14 +36,17 @@ function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending'
export function MySkillsPage() {
const navigate = useNavigate()
const { t } = useTranslation()
const { hasRole } = useAuth()
const [page, setPage] = useState(0)
const [filter, setFilter] = useState<MySkillFilter>('ALL')
const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null)
const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null)
const { data: skillPage, isLoading } = useMySkills({ page, size: PAGE_SIZE })
const { data: skillPage, isLoading } = useMySkills({ page, size: PAGE_SIZE, filter: filter === 'ALL' ? undefined : filter })
const skills = skillPage?.items ?? []
const totalPages = skillPage ? Math.max(Math.ceil(skillPage.total / skillPage.size), 1) : 1
const availableFilters = getMySkillFilters(hasRole('SUPER_ADMIN'))
const archiveMutation = useArchiveSkill()
const unarchiveMutation = useUnarchiveSkill()
const withdrawMutation = useWithdrawSkillReview()
@ -55,6 +60,9 @@ export function MySkillsPage() {
}
const resolveStatusLabel = (status?: string) => {
if (status === 'HIDDEN') {
return t('mySkills.statusHidden')
}
if (status === 'ARCHIVED') {
return t('mySkills.statusArchived')
}
@ -71,6 +79,9 @@ export function MySkillsPage() {
}
const resolveStatusClassName = (status?: string) => {
if (status === 'HIDDEN') {
return 'status-pill status-pill--archived'
}
if (status === 'ARCHIVED') {
return 'status-pill status-pill--archived'
}
@ -200,6 +211,23 @@ export function MySkillsPage() {
)}
/>
<div className="flex flex-wrap gap-2">
{availableFilters.map((option) => (
<Button
key={option}
type="button"
size="sm"
variant={filter === option ? 'default' : 'outline'}
onClick={() => {
setFilter(option)
setPage(0)
}}
>
{t(`mySkills.filters.${option}`)}
</Button>
))}
</div>
{skillPage && skillPage.total > 0 ? (
<>
<div className="grid grid-cols-1 gap-4">
@ -235,9 +263,9 @@ export function MySkillsPage() {
</svg>
{formatCompactCount(skill.downloadCount)}
</span>
{skill.status ? (
<span className={resolveStatusClassName(skill.status)}>
{resolveStatusLabel(skill.status)}
{skill.status || filter === 'HIDDEN' ? (
<span className={resolveStatusClassName(filter === 'HIDDEN' ? 'HIDDEN' : skill.status)}>
{resolveStatusLabel(filter === 'HIDDEN' ? 'HIDDEN' : skill.status)}
</span>
) : null}
{headlineVersion?.status ? (

View file

@ -42,7 +42,7 @@ async function getSkillDocumentation(namespace: string, slug: string, version: s
return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=${encodeURIComponent(path)}`)
}
async function getMySkills(params: { page?: number; size?: number } = {}): Promise<PagedResponse<SkillSummary>> {
async function getMySkills(params: { page?: number; size?: number; filter?: string } = {}): Promise<PagedResponse<SkillSummary>> {
return meApi.getSkills(params)
}
@ -159,7 +159,7 @@ export function useSkillVersionDetail(namespace: string, slug: string, version?:
})
}
export function useMySkills(params: { page?: number; size?: number } = {}) {
export function useMySkills(params: { page?: number; size?: number; filter?: string } = {}) {
return useQuery({
queryKey: ['skills', 'my', params],
queryFn: () => getMySkills(params),