fix(skill): tighten lifecycle detail and search paging

This commit is contained in:
yun-zhi-ztl 2026-03-15 17:19:05 +08:00
parent 090cb156ac
commit c552829367
9 changed files with 207 additions and 14 deletions

View file

@ -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);

View file

@ -15,5 +15,6 @@ public record SkillDetailResponse(
Integer ratingCount,
boolean hidden,
String latestVersion,
String namespace
String namespace,
boolean canManageLifecycle
) {}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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) {

View file

@ -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);
}
}

View file

@ -132,6 +132,7 @@ export interface SkillDetail {
hidden: boolean
latestVersion?: string
namespace: string
canManageLifecycle: boolean
}
export interface SkillVersion {

View file

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