mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
fix(skill): tighten lifecycle detail and search paging
This commit is contained in:
parent
090cb156ac
commit
c552829367
9 changed files with 207 additions and 14 deletions
|
|
@ -70,7 +70,8 @@ public class SkillController extends BaseApiController {
|
|||
detail.ratingCount(),
|
||||
detail.hidden(),
|
||||
detail.latestVersion(),
|
||||
namespace
|
||||
namespace,
|
||||
detail.canManageLifecycle()
|
||||
);
|
||||
|
||||
return ok("response.success.read", response);
|
||||
|
|
|
|||
|
|
@ -15,5 +15,6 @@ public record SkillDetailResponse(
|
|||
Integer ratingCount,
|
||||
boolean hidden,
|
||||
String latestVersion,
|
||||
String namespace
|
||||
String namespace,
|
||||
boolean canManageLifecycle
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -147,7 +147,8 @@ class ClawHubRegistryControllerTest {
|
|||
0,
|
||||
false,
|
||||
"1.2.0",
|
||||
1L
|
||||
1L,
|
||||
false
|
||||
));
|
||||
|
||||
Skill skill = new Skill(1L, "global-skill", "owner-1", SkillVisibility.PUBLIC);
|
||||
|
|
|
|||
|
|
@ -65,7 +65,8 @@ public class SkillQueryService {
|
|||
Integer ratingCount,
|
||||
boolean hidden,
|
||||
String latestVersion,
|
||||
Long namespaceId
|
||||
Long namespaceId,
|
||||
boolean canManageLifecycle
|
||||
) {}
|
||||
|
||||
public record SkillVersionDetailDTO(
|
||||
|
|
@ -127,7 +128,8 @@ public class SkillQueryService {
|
|||
skill.getRatingCount(),
|
||||
skill.isHidden(),
|
||||
latestVersion,
|
||||
skill.getNamespaceId()
|
||||
skill.getNamespaceId(),
|
||||
canManageRestrictedSkill(skill, currentUserId, userNsRoles)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -243,16 +245,31 @@ public class SkillQueryService {
|
|||
Pageable pageable) {
|
||||
Skill skill = findSkill(namespaceSlug, skillSlug);
|
||||
assertPublishedAccessible(skill, currentUserId, userNsRoles);
|
||||
|
||||
List<SkillVersion> publishedVersions = skillVersionRepository.findBySkillIdAndStatus(
|
||||
skill.getId(), SkillVersionStatus.PUBLISHED);
|
||||
List<SkillVersion> visibleVersions;
|
||||
if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
|
||||
visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream()
|
||||
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED
|
||||
|| version.getStatus() == SkillVersionStatus.DRAFT
|
||||
|| version.getStatus() == SkillVersionStatus.REJECTED)
|
||||
.sorted(Comparator
|
||||
.comparingInt((SkillVersion version) -> lifecycleListPriority(version.getStatus()))
|
||||
.thenComparing(SkillVersion::getPublishedAt,
|
||||
Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(SkillVersion::getCreatedAt,
|
||||
Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(SkillVersion::getId, Comparator.reverseOrder()))
|
||||
.toList();
|
||||
} else {
|
||||
visibleVersions = skillVersionRepository.findBySkillIdAndStatus(
|
||||
skill.getId(), SkillVersionStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
// Manual pagination
|
||||
int start = (int) pageable.getOffset();
|
||||
int end = Math.min(start + pageable.getPageSize(), publishedVersions.size());
|
||||
List<SkillVersion> pageContent = publishedVersions.subList(start, end);
|
||||
int start = Math.min((int) pageable.getOffset(), visibleVersions.size());
|
||||
int end = Math.min(start + pageable.getPageSize(), visibleVersions.size());
|
||||
List<SkillVersion> pageContent = visibleVersions.subList(start, end);
|
||||
|
||||
return new PageImpl<>(pageContent, pageable, publishedVersions.size());
|
||||
return new PageImpl<>(pageContent, pageable, visibleVersions.size());
|
||||
}
|
||||
|
||||
public ResolvedVersionDTO resolveVersion(
|
||||
|
|
@ -404,6 +421,16 @@ public class SkillQueryService {
|
|||
|| role == NamespaceRole.OWNER;
|
||||
}
|
||||
|
||||
private int lifecycleListPriority(SkillVersionStatus status) {
|
||||
if (status == SkillVersionStatus.PUBLISHED) {
|
||||
return 0;
|
||||
}
|
||||
if (status == SkillVersionStatus.REJECTED) {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
private void assertPublishedVersion(SkillVersion version, String versionStr) {
|
||||
if (version.getStatus() != SkillVersionStatus.PUBLISHED) {
|
||||
throw new DomainBadRequestException("error.skill.version.notPublished", versionStr);
|
||||
|
|
|
|||
|
|
@ -351,6 +351,124 @@ class SkillQueryServiceTest {
|
|||
assertEquals("/api/v1/skills/global/smoke-skill-two/versions/1.0.0%20beta/download", result.downloadUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String userId = "owner-1";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of();
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", userId);
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
|
||||
|
||||
assertTrue(result.canManageLifecycle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_ShouldNotFlagLifecyclePermissionForRegularViewer() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String userId = "viewer-1";
|
||||
Map<Long, NamespaceRole> 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);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
|
||||
|
||||
assertFalse(result.canManageLifecycle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListVersions_ShouldIncludeDraftAndRejectedForLifecycleManagers() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String userId = "owner-1";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of();
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", userId);
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
|
||||
SkillVersion published = new SkillVersion(1L, "1.0.0", userId);
|
||||
setId(published, 11L);
|
||||
published.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
SkillVersion draft = new SkillVersion(1L, "1.1.0", userId);
|
||||
setId(draft, 12L);
|
||||
draft.setStatus(SkillVersionStatus.DRAFT);
|
||||
SkillVersion rejected = new SkillVersion(1L, "1.2.0", userId);
|
||||
setId(rejected, 13L);
|
||||
rejected.setStatus(SkillVersionStatus.REJECTED);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published));
|
||||
|
||||
Page<SkillVersion> result = service.listVersions(
|
||||
namespaceSlug,
|
||||
skillSlug,
|
||||
userId,
|
||||
userNsRoles,
|
||||
PageRequest.of(0, 10)
|
||||
);
|
||||
|
||||
assertEquals(List.of("1.0.0", "1.2.0", "1.1.0"),
|
||||
result.getContent().stream().map(SkillVersion::getVersion).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListVersions_ShouldOnlyReturnPublishedForRegularViewers() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String userId = "viewer-1";
|
||||
Map<Long, NamespaceRole> 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);
|
||||
|
||||
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(Optional.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published));
|
||||
|
||||
Page<SkillVersion> result = service.listVersions(
|
||||
namespaceSlug,
|
||||
skillSlug,
|
||||
userId,
|
||||
userNsRoles,
|
||||
PageRequest.of(0, 10)
|
||||
);
|
||||
|
||||
assertEquals(List.of("1.0.0"),
|
||||
result.getContent().stream().map(SkillVersion::getVersion).toList());
|
||||
}
|
||||
|
||||
private void setId(Object entity, Long id) throws Exception {
|
||||
Field idField = entity.getClass().getDeclaredField("id");
|
||||
idField.setAccessible(true);
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
&& searchDocumentRepository != null
|
||||
&& searchEmbeddingService != null;
|
||||
int requestedOffset = query.page() * query.size();
|
||||
if (useSemanticRerank && requestedOffset + query.size() > maxCandidates) {
|
||||
useSemanticRerank = false;
|
||||
}
|
||||
int sqlLimit = query.size();
|
||||
int sqlOffset = requestedOffset;
|
||||
if (useSemanticRerank) {
|
||||
|
|
|
|||
|
|
@ -215,4 +215,45 @@ class PostgresFullTextQueryServiceTest {
|
|||
verify(nativeQuery).setParameter("offset", 0);
|
||||
assertThat(result.skillIds()).containsExactly(1L, 2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deepSemanticPagesShouldFallBackToDatabasePagination() {
|
||||
EntityManager entityManager = mock(EntityManager.class);
|
||||
Query nativeQuery = mock(Query.class);
|
||||
Query countQuery = mock(Query.class);
|
||||
SkillSearchDocumentJpaRepository repository = mock(SkillSearchDocumentJpaRepository.class);
|
||||
HashingSearchEmbeddingService embeddingService = new HashingSearchEmbeddingService();
|
||||
when(entityManager.createNativeQuery(anyString()))
|
||||
.thenReturn(nativeQuery)
|
||||
.thenReturn(countQuery);
|
||||
when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery);
|
||||
when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery);
|
||||
when(nativeQuery.getResultList()).thenReturn(List.of(201L, 202L));
|
||||
when(countQuery.getSingleResult()).thenReturn(1000L);
|
||||
|
||||
PostgresFullTextQueryService service = new PostgresFullTextQueryService(
|
||||
entityManager,
|
||||
repository,
|
||||
embeddingService,
|
||||
true,
|
||||
0.6D,
|
||||
8,
|
||||
120
|
||||
);
|
||||
|
||||
var result = service.search(new SearchQuery(
|
||||
"self improvement",
|
||||
null,
|
||||
new SearchVisibilityScope(null, Set.of(), Set.of()),
|
||||
"relevance",
|
||||
20,
|
||||
10
|
||||
));
|
||||
|
||||
verify(nativeQuery).setParameter("limit", 10);
|
||||
verify(nativeQuery).setParameter("offset", 200);
|
||||
verify(repository, never()).findBySkillIdIn(org.mockito.ArgumentMatchers.anyList());
|
||||
assertThat(result.skillIds()).containsExactly(201L, 202L);
|
||||
assertThat(result.total()).isEqualTo(1000L);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ export interface SkillDetail {
|
|||
hidden: boolean
|
||||
latestVersion?: string
|
||||
namespace: string
|
||||
canManageLifecycle: boolean
|
||||
}
|
||||
|
||||
export interface SkillVersion {
|
||||
|
|
|
|||
|
|
@ -314,7 +314,7 @@ export function SkillDetailPage() {
|
|||
<span className="text-sm text-muted-foreground">
|
||||
{formatLocalDateTime(version.publishedAt, i18n.language)}
|
||||
</span>
|
||||
{canDeleteVersion(version.status) && (
|
||||
{skill.canManageLifecycle && canDeleteVersion(version.status) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -418,7 +418,7 @@ export function SkillDetailPage() {
|
|||
{t('skillDetail.download')}
|
||||
</Button>
|
||||
|
||||
{user && (
|
||||
{skill.canManageLifecycle && (
|
||||
<Card className="p-5 space-y-3">
|
||||
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.lifecycle')}</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue