mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-06 08:15:57 +00:00
fix(cli): filter installable search before pagination
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
5a708a5bd6
commit
cb4bf94711
7 changed files with 225 additions and 22 deletions
|
|
@ -605,8 +605,8 @@ window.location.href = '/oauth2/authorization/github'
|
|||
| `GET /api/v1/skills`(搜索) | 仅 `PUBLIC`,且仅搜索 `ACTIVE`、非 hidden、已索引 skill | `PUBLIC + NAMESPACE_ONLY(成员空间)+ PRIVATE(owner/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 解析) |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue