fix(cli): filter installable search before pagination

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-17 11:15:03 +08:00
parent 5a708a5bd6
commit cb4bf94711
7 changed files with 225 additions and 22 deletions

View file

@ -605,8 +605,8 @@ window.location.href = '/oauth2/authorization/github'
| `GET /api/v1/skills`(搜索) | 仅 `PUBLIC`,且仅搜索 `ACTIVE`、非 hidden、已索引 skill | `PUBLIC + NAMESPACE_ONLY成员空间+ PRIVATEowner/admin` | `SearchVisibilityScope` + 搜索索引状态 |
| `GET /api/v1/skills/{ns}/{slug}` | 仅已发布且可见的 `PUBLIC` skill | 同左,另加 owner 可读未发布 skill、namespace `ADMIN` / `OWNER` 可读 hidden | `visibility + latest_version_id + hidden + namespace 成员关系` |
| `GET /api/v1/skills/{ns}/{slug}/versions` | 仅 `PUBLISHED` 版本 | owner / namespace `ADMIN` / `OWNER` 可见全部五种状态 | 同上 + version status 过滤 |
| `GET /api/v1/skills/{ns}/{slug}/download` | 仅全局 namespace 下的 `PUBLIC` skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须`PUBLISHED` | visibility + namespace type + version status |
| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅全局 namespace 下的 `PUBLIC` skill 可匿名 | 同上 | visibility + namespace type + version status |
| `GET /api/v1/skills/{ns}/{slug}/download` | 仅 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须可安装 | visibility + namespace status + `SkillInstallability` |
| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 可匿名 | 同上 | visibility + namespace status + `SkillInstallability` |
| `GET /api/v1/namespaces` | 全部 | 全部 | 无限制 |
### 10.2 Authenticated API
@ -655,6 +655,6 @@ window.location.href = '/oauth2/authorization/github'
|------|---------|---------|
| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 |
| `GET /api/v1/search` | 可选(匿名限 PUBLIC | `SearchVisibilityScope` |
| `GET /api/v1/resolve` | 可选(匿名仅限全局 namespace 下的 PUBLIC | visibility + namespace type + version status |
| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限全局 namespace 下的 PUBLIC | visibility + namespace type + version status |
| `GET /api/v1/resolve` | 可选(匿名仅限 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装 | visibility + namespace status + `SkillInstallability` |
| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装 | visibility + namespace status + `SkillInstallability` |
| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过namespace 由 canonical slug 解析) |

View file

@ -85,7 +85,20 @@ public class SkillSearchAppService {
SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles);
return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope);
return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope, false);
}
public SearchResponse searchInstallableLatest(
String keyword,
String namespaceSlug,
String sortBy,
int page,
int size,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles);
SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles);
return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, List.of(), scope, true);
}
private Long resolveNamespaceId(String namespaceSlug, String userId, Map<Long, NamespaceRole> userNsRoles) {
@ -133,7 +146,8 @@ public class SkillSearchAppService {
int page,
int size,
List<String> labelSlugs,
SearchVisibilityScope scope) {
SearchVisibilityScope scope,
boolean requireInstallableLatest) {
SearchResult result = searchQueryService.search(new SearchQuery(
keyword,
namespaceId,
@ -141,7 +155,8 @@ public class SkillSearchAppService {
sortBy,
page,
size,
normalizeLabelSlugs(labelSlugs)
normalizeLabelSlugs(labelSlugs),
requireInstallableLatest
));
List<SkillSummaryResponse> pageItems = mapVisibleSkillSummaries(result.skillIds());
return new SearchResponse(pageItems, result.total(), page, size);

View file

@ -51,12 +51,11 @@ public class CliSkillAppService {
public record CliSearchResult(List<CliSearchItem> items, long total, int limit) {}
public CliSearchResult search(String q, int limit, String userId, Map<Long, NamespaceRole> userNsRoles) {
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
SkillSearchAppService.SearchResponse response = skillSearchAppService.searchInstallableLatest(
q, null, "newest", 0, limit, userId, userNsRoles
);
List<CliSearchItem> items = response.items().stream()
.filter(item -> item.publishedVersion() != null)
.map(item -> new CliSearchItem(
item.namespace(),
item.slug(),
@ -65,7 +64,7 @@ public class CliSkillAppService {
))
.toList();
return new CliSearchResult(items, items.size(), limit);
return new CliSearchResult(items, response.total(), limit);
}
public CliResolveResponse resolve(String namespace, String slug, String version, String userId, Map<Long, NamespaceRole> userNsRoles) {

View file

@ -1,9 +1,18 @@
package com.iflytek.skillhub.service.cli;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillDownloadService;
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
@ -15,6 +24,9 @@ import com.iflytek.skillhub.dto.cli.CliResolveResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.SkillDeleteAppService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import com.iflytek.skillhub.search.SearchQuery;
import com.iflytek.skillhub.search.SearchQueryService;
import com.iflytek.skillhub.search.SearchResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -39,6 +51,11 @@ class CliSkillAppServiceTest {
@Mock SkillDownloadService skillDownloadService;
@Mock SkillDeleteAppService skillDeleteAppService;
@Mock SkillPublishService skillPublishService;
@Mock SkillRepository skillRepository;
@Mock NamespaceRepository namespaceRepository;
@Mock SkillVersionRepository skillVersionRepository;
@Mock NamespaceService namespaceService;
@Mock RbacService rbacService;
private CliSkillAppService service;
@ -62,7 +79,7 @@ class CliSkillAppServiceTest {
)),
1L, 0, 20
);
given(skillSearchAppService.search("pdf", null, "newest", 0, 20, null, null))
given(skillSearchAppService.searchInstallableLatest("pdf", null, "newest", 0, 20, null, null))
.willReturn(searchResponse);
var result = service.search("pdf", 20, null, null);
@ -77,15 +94,9 @@ class CliSkillAppServiceTest {
}
@Test
void search_filtersResultsWithoutInstallablePublishedVersion() {
void search_mapsInstallableSearchTotalFromQueryStage() {
var searchResponse = new SkillSearchAppService.SearchResponse(
List.of(
new SkillSummaryResponse(
1L, "draft-only", "Draft Only", "No installable version",
"PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0,
"global", Instant.now(), false,
null, null, null, "NONE"
),
new SkillSummaryResponse(
2L, "ready", "Ready", "Installable",
"PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0,
@ -95,9 +106,9 @@ class CliSkillAppServiceTest {
null, "PUBLISHED"
)
),
2L, 0, 20
1L, 0, 20
);
given(skillSearchAppService.search("demo", null, "newest", 0, 20, null, null))
given(skillSearchAppService.searchInstallableLatest("demo", null, "newest", 0, 20, null, null))
.willReturn(searchResponse);
var result = service.search("demo", 20, null, null);
@ -107,6 +118,93 @@ class CliSkillAppServiceTest {
assertEquals(1L, result.total());
}
@Test
void search_limitOneSkipsUninstallableMatchAndReturnsNextInstallableWithFilteredTotal() {
Skill unavailableFirstMatch = new Skill(1L, "draft-first", "owner-1", SkillVisibility.PUBLIC);
setField(unavailableFirstMatch, "id", 1L);
assertLimitOneSkipsUninstallableFirstMatch(unavailableFirstMatch, List.of());
}
@Test
void search_limitOneSkipsYankedLatestMatchAndReturnsNextInstallableWithFilteredTotal() {
Skill unavailableFirstMatch = new Skill(1L, "yanked-first", "owner-1", SkillVisibility.PUBLIC);
setField(unavailableFirstMatch, "id", 1L);
unavailableFirstMatch.setLatestVersionId(10L);
SkillVersion yanked = publishedVersion(1L, 10L, "1.0.0");
yanked.setYankedAt(Instant.parse("2026-06-12T00:00:00Z"));
assertLimitOneSkipsUninstallableFirstMatch(unavailableFirstMatch, List.of(yanked));
}
@Test
void search_limitOneSkipsDownloadUnavailableLatestAndReturnsNextInstallableWithFilteredTotal() {
Skill unavailableFirstMatch = new Skill(1L, "not-ready-first", "owner-1", SkillVisibility.PUBLIC);
setField(unavailableFirstMatch, "id", 1L);
unavailableFirstMatch.setLatestVersionId(10L);
SkillVersion notReady = publishedVersion(1L, 10L, "1.0.0");
notReady.setDownloadReady(false);
assertLimitOneSkipsUninstallableFirstMatch(unavailableFirstMatch, List.of(notReady));
}
private void assertLimitOneSkipsUninstallableFirstMatch(
Skill unavailableFirstMatch,
List<SkillVersion> unavailableLatestVersions) {
SearchQueryService rankedSearch = query -> requiresInstallableLatest(query)
? new SearchResult(List.of(2L), 1L, 0, 1)
: new SearchResult(List.of(1L), 2L, 0, 1);
SkillSearchAppService realSearchAppService = new SkillSearchAppService(
rankedSearch,
skillRepository,
namespaceRepository,
namespaceService,
new SkillLifecycleProjectionService(skillVersionRepository),
rbacService
);
CliSkillAppService realService = new CliSkillAppService(
realSearchAppService,
skillQueryService,
skillDownloadService,
skillDeleteAppService,
skillPublishService
);
Skill installableSecondMatch = new Skill(1L, "ready-second", "owner-1", SkillVisibility.PUBLIC);
setField(installableSecondMatch, "id", 2L);
installableSecondMatch.setLatestVersionId(20L);
Namespace namespace = new Namespace("global", "Global", "owner-1");
setField(namespace, "id", 1L);
SkillVersion installableVersion = publishedVersion(2L, 20L, "1.0.0");
org.mockito.Mockito.lenient()
.when(skillRepository.findByIdIn(List.of(1L)))
.thenReturn(List.of(unavailableFirstMatch));
org.mockito.Mockito.lenient()
.when(skillRepository.findByIdIn(List.of(2L)))
.thenReturn(List.of(installableSecondMatch));
org.mockito.Mockito.lenient()
.when(namespaceRepository.findByIdIn(List.of(1L)))
.thenReturn(List.of(namespace));
org.mockito.Mockito.lenient()
.when(skillVersionRepository.findByIdIn(List.of()))
.thenReturn(List.of());
org.mockito.Mockito.lenient()
.when(skillVersionRepository.findByIdIn(List.of(10L)))
.thenReturn(unavailableLatestVersions);
org.mockito.Mockito.lenient()
.when(skillVersionRepository.findByIdIn(List.of(20L)))
.thenReturn(List.of(installableVersion));
var result = realService.search("demo", 1, null, null);
assertEquals(1, result.items().size());
assertEquals("ready-second", result.items().getFirst().slug());
assertEquals("1.0.0", result.items().getFirst().latestVersion());
assertEquals(1L, result.total());
assertEquals(1, result.limit());
}
@Test
void resolve_delegatesToQueryService() {
given(skillQueryService.resolveVersion("global", "demo", "2.0.0", null, null, "user-1", Map.of()))
@ -156,4 +254,30 @@ class CliSkillAppServiceTest {
assertEquals("1.0.0", response.version());
assertEquals("PUBLIC", response.visibility());
}
private boolean requiresInstallableLatest(SearchQuery query) {
try {
return (boolean) query.getClass().getMethod("requireInstallableLatest").invoke(query);
} catch (ReflectiveOperationException e) {
return false;
}
}
private SkillVersion publishedVersion(Long skillId, Long versionId, String versionNumber) {
SkillVersion version = new SkillVersion(skillId, versionNumber, "owner-1");
setField(version, "id", versionId);
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setDownloadReady(true);
return version;
}
private void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View file

@ -12,8 +12,20 @@ public record SearchQuery(
String sortBy,
int page,
int size,
List<String> labelSlugs
List<String> labelSlugs,
boolean requireInstallableLatest
) {
public SearchQuery(
String keyword,
Long namespaceId,
SearchVisibilityScope visibilityScope,
String sortBy,
int page,
int size,
List<String> labelSlugs) {
this(keyword, namespaceId, visibilityScope, sortBy, page, size, labelSlugs, false);
}
public SearchQuery(
String keyword,
Long namespaceId,
@ -21,6 +33,6 @@ public record SearchQuery(
String sortBy,
int page,
int size) {
this(keyword, namespaceId, visibilityScope, sortBy, page, size, List.of());
this(keyword, namespaceId, visibilityScope, sortBy, page, size, List.of(), false);
}
}

View file

@ -107,6 +107,9 @@ public class PostgresFullTextQueryService implements SearchQueryService {
sql.append("FROM skill_search_document d ");
sql.append("JOIN skill s ON s.id = d.skill_id ");
sql.append("JOIN namespace n ON n.id = d.namespace_id ");
if (query.requireInstallableLatest()) {
sql.append("JOIN skill_version latest ON latest.id = s.latest_version_id ");
}
sql.append("WHERE 1=1 ");
// Visibility filtering
@ -120,6 +123,11 @@ public class PostgresFullTextQueryService implements SearchQueryService {
sql.append("AND d.status = 'ACTIVE' ");
sql.append("AND s.status = 'ACTIVE' ");
sql.append("AND s.hidden = FALSE ");
if (query.requireInstallableLatest()) {
sql.append("AND latest.status = 'PUBLISHED' ");
sql.append("AND latest.download_ready = TRUE ");
sql.append("AND latest.yanked_at IS NULL ");
}
sql.append("AND (n.status <> 'ARCHIVED' ");
if (query.visibilityScope().userId() != null) {
sql.append("OR d.namespace_id IN :memberNamespaceIds ");

View file

@ -400,6 +400,51 @@ class PostgresFullTextQueryServiceTest {
verify(countQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("memberNamespaceIds"), org.mockito.ArgumentMatchers.any());
}
@Test
void installableLatestFilterShouldApplyToSearchAndCountQueries() {
EntityManager entityManager = mock(EntityManager.class);
Query nativeQuery = mock(Query.class);
Query countQuery = mock(Query.class);
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(2L));
when(countQuery.getSingleResult()).thenReturn(1L);
PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager);
var result = service.search(new SearchQuery(
"demo",
null,
SearchVisibilityScope.anonymous(),
"newest",
0,
1,
List.of(),
true
));
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
assertThat(sqlCaptor.getAllValues().getFirst())
.contains("JOIN skill_version latest ON latest.id = s.latest_version_id")
.contains("AND latest.status = 'PUBLISHED'")
.contains("AND latest.download_ready = TRUE")
.contains("AND latest.yanked_at IS NULL")
.contains("LIMIT :limit OFFSET :offset");
assertThat(sqlCaptor.getAllValues().get(1))
.contains("JOIN skill_version latest ON latest.id = s.latest_version_id")
.contains("AND latest.status = 'PUBLISHED'")
.contains("AND latest.download_ready = TRUE")
.contains("AND latest.yanked_at IS NULL")
.doesNotContain("LIMIT :limit")
.doesNotContain("ORDER BY");
assertThat(result.skillIds()).containsExactly(2L);
assertThat(result.total()).isEqualTo(1L);
}
@Test
void authenticatedQueriesShouldAllowArchivedNamespacesForMembers() {
EntityManager entityManager = mock(EntityManager.class);