mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-06 02:48:28 +00:00
Merge remote-tracking branch 'origin/main' into feature/project-fixbug319
This commit is contained in:
commit
234d5d83b9
10 changed files with 274 additions and 109 deletions
|
|
@ -2,14 +2,11 @@ package com.iflytek.skillhub.service;
|
|||
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.search.SearchQuery;
|
||||
import com.iflytek.skillhub.search.SearchQueryService;
|
||||
|
|
@ -34,7 +31,6 @@ public class SkillSearchAppService {
|
|||
private final SkillRepository skillRepository;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceService namespaceService;
|
||||
private final VisibilityChecker visibilityChecker;
|
||||
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
|
||||
|
||||
public SkillSearchAppService(
|
||||
|
|
@ -42,13 +38,11 @@ public class SkillSearchAppService {
|
|||
SkillRepository skillRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceService namespaceService,
|
||||
VisibilityChecker visibilityChecker,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService) {
|
||||
this.searchQueryService = searchQueryService;
|
||||
this.skillRepository = skillRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceService = namespaceService;
|
||||
this.visibilityChecker = visibilityChecker;
|
||||
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +66,7 @@ public class SkillSearchAppService {
|
|||
|
||||
SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles);
|
||||
|
||||
return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, userId, userNsRoles, scope);
|
||||
return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, scope);
|
||||
}
|
||||
|
||||
private Long resolveNamespaceId(String namespaceSlug, String userId, Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
|
@ -106,46 +100,20 @@ public class SkillSearchAppService {
|
|||
String sortBy,
|
||||
int page,
|
||||
int size,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles,
|
||||
SearchVisibilityScope scope) {
|
||||
int batchSize = Math.max(size, 20);
|
||||
long rawTotal = Long.MAX_VALUE;
|
||||
int rawPage = 0;
|
||||
long visibleSeen = 0;
|
||||
int visibleStart = page * size;
|
||||
List<SkillSummaryResponse> pageItems = new java.util.ArrayList<>();
|
||||
|
||||
while ((long) rawPage * batchSize < rawTotal) {
|
||||
SearchResult result = searchQueryService.search(new SearchQuery(
|
||||
keyword,
|
||||
namespaceId,
|
||||
scope,
|
||||
sortBy,
|
||||
rawPage,
|
||||
batchSize
|
||||
));
|
||||
rawTotal = result.total();
|
||||
List<SkillSummaryResponse> visibleBatch = mapVisibleSkillSummaries(result.skillIds(), userId, userNsRoles);
|
||||
for (SkillSummaryResponse item : visibleBatch) {
|
||||
if (visibleSeen >= visibleStart && pageItems.size() < size) {
|
||||
pageItems.add(item);
|
||||
}
|
||||
visibleSeen++;
|
||||
}
|
||||
if (result.skillIds().isEmpty()) {
|
||||
break;
|
||||
}
|
||||
rawPage++;
|
||||
}
|
||||
|
||||
return new SearchResponse(pageItems, visibleSeen, page, size);
|
||||
SearchResult result = searchQueryService.search(new SearchQuery(
|
||||
keyword,
|
||||
namespaceId,
|
||||
scope,
|
||||
sortBy,
|
||||
page,
|
||||
size
|
||||
));
|
||||
List<SkillSummaryResponse> pageItems = mapVisibleSkillSummaries(result.skillIds());
|
||||
return new SearchResponse(pageItems, result.total(), page, size);
|
||||
}
|
||||
|
||||
private List<SkillSummaryResponse> mapVisibleSkillSummaries(
|
||||
List<Long> skillIds,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
private List<SkillSummaryResponse> mapVisibleSkillSummaries(List<Long> skillIds) {
|
||||
if (skillIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
|
@ -164,24 +132,20 @@ public class SkillSearchAppService {
|
|||
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
|
||||
Map<Long, String> namespaceSlugsById = namespacesById.entrySet().stream()
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getSlug()));
|
||||
Map<Long, SkillLifecycleProjectionService.Projection> projectionsBySkillId =
|
||||
skillLifecycleProjectionService.projectPublishedSummaries(matchedSkills);
|
||||
|
||||
return skillIds.stream()
|
||||
.map(skillsById::get)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.filter(skill -> visibilityChecker.canAccess(skill, userId, userNsRoles != null ? userNsRoles : Map.of()))
|
||||
.filter(skill -> namespaceVisible(skill.getNamespaceId(), namespacesById, userId, userNsRoles))
|
||||
.map(skill -> toSummaryResponse(skill, namespaceSlugsById))
|
||||
.map(skill -> toSummaryResponse(skill, namespaceSlugsById, projectionsBySkillId.get(skill.getId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private SkillSummaryResponse toSummaryResponse(
|
||||
Skill skill,
|
||||
Map<Long, String> namespaceSlugsById) {
|
||||
SkillLifecycleProjectionService.Projection projection = skillLifecycleProjectionService.projectForViewer(
|
||||
skill,
|
||||
null,
|
||||
Map.of()
|
||||
);
|
||||
Map<Long, String> namespaceSlugsById,
|
||||
SkillLifecycleProjectionService.Projection projection) {
|
||||
String namespaceSlug = namespaceSlugsById.get(skill.getNamespaceId());
|
||||
|
||||
return new SkillSummaryResponse(
|
||||
|
|
@ -216,17 +180,4 @@ public class SkillSearchAppService {
|
|||
);
|
||||
}
|
||||
|
||||
private boolean namespaceVisible(
|
||||
Long namespaceId,
|
||||
Map<Long, Namespace> namespacesById,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
NamespaceStatus status = java.util.Optional.ofNullable(namespacesById.get(namespaceId))
|
||||
.map(Namespace::getStatus)
|
||||
.orElse(NamespaceStatus.ACTIVE);
|
||||
if (status != NamespaceStatus.ARCHIVED) {
|
||||
return true;
|
||||
}
|
||||
return userId != null && userNsRoles != null && userNsRoles.containsKey(namespaceId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
CREATE INDEX IF NOT EXISTS idx_skill_active_visible_updated
|
||||
ON skill (updated_at DESC, id DESC)
|
||||
WHERE status = 'ACTIVE' AND hidden = FALSE;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
CREATE INDEX IF NOT EXISTS idx_skill_active_visible_downloads
|
||||
ON skill (download_count DESC, updated_at DESC, id DESC)
|
||||
WHERE status = 'ACTIVE' AND hidden = FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_active_visible_rating
|
||||
ON skill (rating_avg DESC, updated_at DESC, id DESC)
|
||||
WHERE status = 'ACTIVE' AND hidden = FALSE;
|
||||
|
|
@ -55,4 +55,25 @@ class SkillSearchControllerTest {
|
|||
.andExpect(jsonPath("$.timestamp").isNotEmpty())
|
||||
.andExpect(jsonPath("$.requestId").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchShouldPassExplicitSortPageAndSize() throws Exception {
|
||||
when(skillSearchAppService.search(
|
||||
eq(null),
|
||||
eq(null),
|
||||
eq("newest"),
|
||||
eq(0),
|
||||
eq(12),
|
||||
any(),
|
||||
any()))
|
||||
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 12));
|
||||
|
||||
mockMvc.perform(get("/api/web/skills")
|
||||
.param("sort", "newest")
|
||||
.param("page", "0")
|
||||
.param("size", "12"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.size").value(12))
|
||||
.andExpect(jsonPath("$.data.page").value(0));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,13 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
|
|
@ -54,57 +57,46 @@ class SkillSearchAppServiceTest {
|
|||
skillRepository,
|
||||
namespaceRepository,
|
||||
namespaceService,
|
||||
new VisibilityChecker(),
|
||||
new SkillLifecycleProjectionService(skillVersionRepository)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_shouldExcludeArchivedNamespaceSkillsForAnonymousUsers() {
|
||||
Skill archivedSkill = new Skill(1L, "archived-skill", "owner-1", SkillVisibility.PUBLIC);
|
||||
setField(archivedSkill, "id", 10L);
|
||||
|
||||
Namespace archivedNamespace = new Namespace("archived-team", "Archived Team", "owner-1");
|
||||
setField(archivedNamespace, "id", 1L);
|
||||
archivedNamespace.setStatus(NamespaceStatus.ARCHIVED);
|
||||
|
||||
when(searchQueryService.search(org.mockito.ArgumentMatchers.any()))
|
||||
.thenReturn(new SearchResult(List.of(10L), 1, 0, 20));
|
||||
when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(archivedSkill));
|
||||
when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(archivedNamespace));
|
||||
.thenReturn(new SearchResult(List.of(), 0, 0, 20));
|
||||
|
||||
SkillSearchAppService.SearchResponse response = service.search("archive", null, "newest", 0, 20, null, null);
|
||||
|
||||
assertEquals(0, response.items().size());
|
||||
assertEquals(0, response.total());
|
||||
verify(skillRepository, times(0)).findByIdIn(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_shouldFillVisiblePageAcrossArchivedNamespaceResults() {
|
||||
Skill archivedSkill = new Skill(1L, "archived-skill", "owner-1", SkillVisibility.PUBLIC);
|
||||
setField(archivedSkill, "id", 10L);
|
||||
archivedSkill.setLatestVersionId(110L);
|
||||
Skill visibleSkill = new Skill(2L, "visible-skill", "owner-1", SkillVisibility.PUBLIC);
|
||||
setField(visibleSkill, "id", 11L);
|
||||
visibleSkill.setLatestVersionId(111L);
|
||||
|
||||
Namespace archivedNamespace = new Namespace("archived-team", "Archived Team", "owner-1");
|
||||
setField(archivedNamespace, "id", 1L);
|
||||
archivedNamespace.setStatus(NamespaceStatus.ARCHIVED);
|
||||
Namespace activeNamespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
setField(activeNamespace, "id", 2L);
|
||||
activeNamespace.setStatus(NamespaceStatus.ACTIVE);
|
||||
|
||||
when(searchQueryService.search(org.mockito.ArgumentMatchers.any()))
|
||||
.thenReturn(new SearchResult(List.of(10L, 11L), 2, 0, 20));
|
||||
when(skillRepository.findByIdIn(List.of(10L, 11L))).thenReturn(List.of(archivedSkill, visibleSkill));
|
||||
when(namespaceRepository.findByIdIn(List.of(1L, 2L))).thenReturn(List.of(archivedNamespace, activeNamespace));
|
||||
.thenReturn(new SearchResult(List.of(11L), 1, 0, 20));
|
||||
when(skillRepository.findByIdIn(List.of(11L))).thenReturn(List.of(visibleSkill));
|
||||
when(namespaceRepository.findByIdIn(List.of(2L))).thenReturn(List.of(activeNamespace));
|
||||
when(skillVersionRepository.findByIdIn(List.of(111L))).thenReturn(List.of());
|
||||
when(skillVersionRepository.findBySkillIdInAndStatus(List.of(11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED))
|
||||
.thenReturn(List.of());
|
||||
|
||||
SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 1, null, null);
|
||||
|
||||
assertEquals(1, response.items().size());
|
||||
assertEquals("visible-skill", response.items().getFirst().slug());
|
||||
assertEquals(1, response.total());
|
||||
verify(searchQueryService, times(1)).search(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -141,9 +133,12 @@ class SkillSearchAppServiceTest {
|
|||
namespace.setStatus(NamespaceStatus.ACTIVE);
|
||||
|
||||
when(searchQueryService.search(org.mockito.ArgumentMatchers.any()))
|
||||
.thenReturn(new SearchResult(List.of(10L, 11L), 2, 0, 20));
|
||||
when(skillRepository.findByIdIn(List.of(10L, 11L))).thenReturn(List.of(visibleSkill, hiddenSkill));
|
||||
.thenReturn(new SearchResult(List.of(10L), 1, 0, 20));
|
||||
when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(visibleSkill));
|
||||
when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));
|
||||
when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of());
|
||||
when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED))
|
||||
.thenReturn(List.of());
|
||||
|
||||
SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 20, "user-9", Map.of());
|
||||
|
||||
|
|
@ -152,6 +147,35 @@ class SkillSearchAppServiceTest {
|
|||
assertEquals(1, response.total());
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_shouldResolvePublishedVersionsInBatch() {
|
||||
Skill first = new Skill(1L, "skill-a", "owner-1", SkillVisibility.PUBLIC);
|
||||
setField(first, "id", 10L);
|
||||
first.setLatestVersionId(101L);
|
||||
Skill second = new Skill(1L, "skill-b", "owner-1", SkillVisibility.PUBLIC);
|
||||
setField(second, "id", 11L);
|
||||
second.setLatestVersionId(102L);
|
||||
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
setField(namespace, "id", 1L);
|
||||
namespace.setStatus(NamespaceStatus.ACTIVE);
|
||||
|
||||
when(searchQueryService.search(any()))
|
||||
.thenReturn(new SearchResult(List.of(10L, 11L), 2, 0, 20));
|
||||
when(skillRepository.findByIdIn(List.of(10L, 11L))).thenReturn(List.of(first, second));
|
||||
when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));
|
||||
when(skillVersionRepository.findByIdIn(List.of(101L, 102L))).thenReturn(List.of());
|
||||
when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED))
|
||||
.thenReturn(List.of());
|
||||
|
||||
SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null);
|
||||
|
||||
assertEquals(2, response.items().size());
|
||||
verify(skillVersionRepository, times(1)).findByIdIn(List.of(101L, 102L));
|
||||
verify(skillVersionRepository, times(1))
|
||||
.findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
private void setField(Object target, String fieldName, Object value) {
|
||||
try {
|
||||
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ public interface SkillVersionRepository {
|
|||
Optional<SkillVersion> findById(Long id);
|
||||
List<SkillVersion> findByIdIn(List<Long> ids);
|
||||
List<SkillVersion> findBySkillIdIn(List<Long> skillIds);
|
||||
List<SkillVersion> findBySkillIdInAndStatus(List<Long> skillIds, SkillVersionStatus status);
|
||||
List<SkillVersion> findBySkillId(Long skillId);
|
||||
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
|
||||
List<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
|||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
|
|
@ -62,6 +65,44 @@ public class SkillLifecycleProjectionService {
|
|||
return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode);
|
||||
}
|
||||
|
||||
public Map<Long, Projection> projectPublishedSummaries(List<Skill> skills) {
|
||||
if (skills.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
Map<Long, SkillVersion> latestVersionsById = skillVersionRepository.findByIdIn(
|
||||
skills.stream()
|
||||
.map(Skill::getLatestVersionId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList())
|
||||
.stream()
|
||||
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
|
||||
|
||||
Map<Long, SkillVersion> publishedBySkillId = new java.util.HashMap<>();
|
||||
List<Long> unresolvedSkillIds = new java.util.ArrayList<>();
|
||||
for (Skill skill : skills) {
|
||||
SkillVersion latestVersion = latestVersionsById.get(skill.getLatestVersionId());
|
||||
if (latestVersion != null && latestVersion.getStatus() == SkillVersionStatus.PUBLISHED) {
|
||||
publishedBySkillId.put(skill.getId(), latestVersion);
|
||||
} else {
|
||||
unresolvedSkillIds.add(skill.getId());
|
||||
}
|
||||
}
|
||||
|
||||
if (!unresolvedSkillIds.isEmpty()) {
|
||||
for (SkillVersion version : skillVersionRepository.findBySkillIdInAndStatus(unresolvedSkillIds, SkillVersionStatus.PUBLISHED)) {
|
||||
publishedBySkillId.merge(version.getSkillId(), version, this::newerVersion);
|
||||
}
|
||||
}
|
||||
|
||||
return skills.stream().collect(Collectors.toMap(Skill::getId, skill -> {
|
||||
VersionProjection publishedVersion = toProjection(publishedBySkillId.get(skill.getId()));
|
||||
ResolutionMode resolutionMode = publishedVersion == null ? ResolutionMode.NONE : ResolutionMode.PUBLISHED;
|
||||
return new Projection(publishedVersion, publishedVersion, null, resolutionMode);
|
||||
}));
|
||||
}
|
||||
|
||||
private SkillVersion resolvePublishedVersion(Skill skill) {
|
||||
if (skill.getLatestVersionId() != null) {
|
||||
SkillVersion latest = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null);
|
||||
|
|
@ -109,6 +150,10 @@ public class SkillLifecycleProjectionService {
|
|||
.thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder()));
|
||||
}
|
||||
|
||||
private SkillVersion newerVersion(SkillVersion left, SkillVersion right) {
|
||||
return versionComparator().compare(left, right) >= 0 ? left : right;
|
||||
}
|
||||
|
||||
private VersionProjection toProjection(SkillVersion version) {
|
||||
if (version == null) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ public interface SkillVersionJpaRepository extends JpaRepository<SkillVersion, L
|
|||
List<SkillVersion> findByIdIn(List<Long> ids);
|
||||
List<SkillVersion> findBySkillId(Long skillId);
|
||||
List<SkillVersion> findBySkillIdIn(List<Long> skillIds);
|
||||
List<SkillVersion> findBySkillIdInAndStatusOrderByCreatedAtDesc(List<Long> skillIds, SkillVersionStatus status);
|
||||
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
|
||||
|
||||
@Override
|
||||
|
|
@ -26,6 +27,11 @@ public interface SkillVersionJpaRepository extends JpaRepository<SkillVersion, L
|
|||
return findBySkillIdAndStatusOrderByCreatedAtDesc(skillId, status);
|
||||
}
|
||||
|
||||
@Override
|
||||
default List<SkillVersion> findBySkillIdInAndStatus(List<Long> skillIds, SkillVersionStatus status) {
|
||||
return findBySkillIdInAndStatusOrderByCreatedAtDesc(skillIds, status);
|
||||
}
|
||||
|
||||
List<SkillVersion> findBySkillIdAndStatusOrderByCreatedAtDesc(Long skillId, SkillVersionStatus status);
|
||||
Page<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status, Pageable pageable);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,22 +98,33 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
: query.visibilityScope().adminNamespaceIds();
|
||||
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT skill_id FROM skill_search_document WHERE 1=1 ");
|
||||
sql.append("SELECT d.skill_id ");
|
||||
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 ");
|
||||
sql.append("WHERE 1=1 ");
|
||||
|
||||
// Visibility filtering
|
||||
sql.append("AND (visibility = 'PUBLIC' ");
|
||||
sql.append("AND (d.visibility = 'PUBLIC' ");
|
||||
if (query.visibilityScope().userId() != null) {
|
||||
sql.append("OR (visibility = 'NAMESPACE_ONLY' AND namespace_id IN :memberNamespaceIds) ");
|
||||
sql.append("OR (visibility = 'PRIVATE' AND (namespace_id IN :adminNamespaceIds OR owner_id = :userId)) ");
|
||||
sql.append("OR (d.visibility = 'NAMESPACE_ONLY' AND d.namespace_id IN :memberNamespaceIds) ");
|
||||
sql.append("OR (d.visibility = 'PRIVATE' AND (d.namespace_id IN :adminNamespaceIds OR d.owner_id = :userId)) ");
|
||||
}
|
||||
sql.append(") ");
|
||||
|
||||
// Status filtering
|
||||
sql.append("AND status = 'ACTIVE' ");
|
||||
sql.append("AND d.status = 'ACTIVE' ");
|
||||
sql.append("AND s.status = 'ACTIVE' ");
|
||||
sql.append("AND s.hidden = FALSE ");
|
||||
sql.append("AND (n.status <> 'ARCHIVED' ");
|
||||
if (query.visibilityScope().userId() != null) {
|
||||
sql.append("OR d.namespace_id IN :memberNamespaceIds ");
|
||||
}
|
||||
sql.append(") ");
|
||||
|
||||
// Namespace filtering
|
||||
if (query.namespaceId() != null) {
|
||||
sql.append("AND namespace_id = :namespaceId ");
|
||||
sql.append("AND d.namespace_id = :namespaceId ");
|
||||
}
|
||||
|
||||
// Full-text search
|
||||
|
|
@ -123,7 +134,7 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
if (useShortPrefixTitleSearch) {
|
||||
sql.append(TITLE_VECTOR_SQL).append(" @@ to_tsquery('simple', :tsQuery) ");
|
||||
} else {
|
||||
sql.append("search_vector @@ to_tsquery('simple', :tsQuery) ");
|
||||
sql.append("d.search_vector @@ to_tsquery('simple', :tsQuery) ");
|
||||
}
|
||||
sql.append(" OR ");
|
||||
}
|
||||
|
|
@ -133,13 +144,11 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
|
||||
// Sorting
|
||||
if ("downloads".equals(query.sortBy())) {
|
||||
sql.append("ORDER BY (SELECT download_count FROM skill WHERE id = skill_id) DESC, ");
|
||||
sql.append("(SELECT updated_at FROM skill WHERE id = skill_id) DESC, skill_id DESC ");
|
||||
sql.append("ORDER BY s.download_count DESC, s.updated_at DESC, d.skill_id DESC ");
|
||||
} else if ("rating".equals(query.sortBy())) {
|
||||
sql.append("ORDER BY (SELECT rating_avg FROM skill WHERE id = skill_id) DESC, ");
|
||||
sql.append("(SELECT updated_at FROM skill WHERE id = skill_id) DESC, skill_id DESC ");
|
||||
sql.append("ORDER BY s.rating_avg DESC, s.updated_at DESC, d.skill_id DESC ");
|
||||
} else if ("newest".equals(query.sortBy())) {
|
||||
sql.append("ORDER BY (SELECT updated_at FROM skill WHERE id = skill_id) DESC, skill_id DESC ");
|
||||
sql.append("ORDER BY s.updated_at DESC, d.skill_id DESC ");
|
||||
} else if (useRelevanceOrdering) {
|
||||
sql.append("ORDER BY CASE ");
|
||||
sql.append("WHEN ").append(TITLE_SQL).append(" = :titleExact THEN 4 ");
|
||||
|
|
@ -148,14 +157,14 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
sql.append("ELSE 1 END DESC, ");
|
||||
if (useShortPrefixTitleSearch) {
|
||||
sql.append("ts_rank_cd(").append(TITLE_VECTOR_SQL)
|
||||
.append(", to_tsquery('simple', :tsQuery)) DESC, updated_at DESC, skill_id DESC ");
|
||||
.append(", to_tsquery('simple', :tsQuery)) DESC, d.updated_at DESC, d.skill_id DESC ");
|
||||
} else if (hasTsQuery) {
|
||||
sql.append("ts_rank_cd(search_vector, to_tsquery('simple', :tsQuery)) DESC, updated_at DESC, skill_id DESC ");
|
||||
sql.append("ts_rank_cd(d.search_vector, to_tsquery('simple', :tsQuery)) DESC, d.updated_at DESC, d.skill_id DESC ");
|
||||
} else {
|
||||
sql.append("updated_at DESC, skill_id DESC ");
|
||||
sql.append("d.updated_at DESC, d.skill_id DESC ");
|
||||
}
|
||||
} else {
|
||||
sql.append("ORDER BY updated_at DESC, skill_id DESC ");
|
||||
sql.append("ORDER BY s.updated_at DESC, d.skill_id DESC ");
|
||||
}
|
||||
|
||||
// Pagination
|
||||
|
|
@ -193,7 +202,7 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
.toList();
|
||||
|
||||
// Count total
|
||||
String countSql = sql.toString().replaceFirst("SELECT skill_id", "SELECT COUNT(*)");
|
||||
String countSql = sql.toString().replaceFirst("SELECT d\\.skill_id", "SELECT COUNT(*)");
|
||||
int orderByIndex = countSql.indexOf("ORDER BY");
|
||||
if (orderByIndex >= 0) {
|
||||
countSql = countSql.substring(0, orderByIndex);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.iflytek.skillhub.search.SearchVisibilityScope;
|
|||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Query;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
|
@ -108,7 +109,7 @@ class PostgresFullTextQueryServiceTest {
|
|||
var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
|
||||
assertThat(sqlCaptor.getAllValues().getFirst()).contains("search_vector @@ to_tsquery('simple', :tsQuery)");
|
||||
assertThat(sqlCaptor.getAllValues().getFirst()).contains("ts_rank_cd(search_vector, to_tsquery('simple', :tsQuery))");
|
||||
assertThat(sqlCaptor.getAllValues().getFirst()).contains("ts_rank_cd(d.search_vector, to_tsquery('simple', :tsQuery))");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -233,7 +234,11 @@ class PostgresFullTextQueryServiceTest {
|
|||
var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
|
||||
assertThat(sqlCaptor.getAllValues().getFirst())
|
||||
.contains("ORDER BY (SELECT download_count FROM skill WHERE id = skill_id) DESC, (SELECT updated_at FROM skill WHERE id = skill_id) DESC, skill_id DESC");
|
||||
.contains("JOIN skill s ON s.id = d.skill_id")
|
||||
.contains("JOIN namespace n ON n.id = d.namespace_id")
|
||||
.contains("AND s.hidden = FALSE")
|
||||
.contains("AND (n.status <> 'ARCHIVED' ")
|
||||
.contains("ORDER BY s.download_count DESC, s.updated_at DESC, d.skill_id DESC");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -263,7 +268,100 @@ class PostgresFullTextQueryServiceTest {
|
|||
var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
|
||||
assertThat(sqlCaptor.getAllValues().getFirst())
|
||||
.contains("ORDER BY updated_at DESC, skill_id DESC");
|
||||
.contains("ORDER BY s.updated_at DESC, d.skill_id DESC");
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedQueriesShouldAllowArchivedNamespacesForMembers() {
|
||||
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());
|
||||
when(countQuery.getSingleResult()).thenReturn(0L);
|
||||
|
||||
PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager);
|
||||
|
||||
service.search(new SearchQuery(
|
||||
null,
|
||||
null,
|
||||
new SearchVisibilityScope("user-1", Set.of(7L), Set.of()),
|
||||
"newest",
|
||||
0,
|
||||
12
|
||||
));
|
||||
|
||||
var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
|
||||
assertThat(sqlCaptor.getAllValues().getFirst()).contains("OR d.namespace_id IN :memberNamespaceIds");
|
||||
}
|
||||
|
||||
@Test
|
||||
void maliciousKeywordShouldBeBoundAsParameterInsteadOfInlinedIntoSql() {
|
||||
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());
|
||||
when(countQuery.getSingleResult()).thenReturn(0L);
|
||||
|
||||
PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager);
|
||||
String payload = "x%' OR 1=1 --";
|
||||
|
||||
service.search(new SearchQuery(
|
||||
payload,
|
||||
null,
|
||||
new SearchVisibilityScope(null, Set.of(), Set.of()),
|
||||
"relevance",
|
||||
0,
|
||||
12
|
||||
));
|
||||
|
||||
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
|
||||
assertThat(sqlCaptor.getAllValues().getFirst()).doesNotContain(payload);
|
||||
verify(nativeQuery).setParameter("titleLike", "%" + payload.toLowerCase() + "%");
|
||||
verify(countQuery).setParameter("titleLike", "%" + payload.toLowerCase() + "%");
|
||||
}
|
||||
|
||||
@Test
|
||||
void maliciousSortShouldFallBackWithoutBeingInlinedIntoSql() {
|
||||
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());
|
||||
when(countQuery.getSingleResult()).thenReturn(0L);
|
||||
|
||||
PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager);
|
||||
String payload = "newest desc; drop table skill; --";
|
||||
|
||||
service.search(new SearchQuery(
|
||||
null,
|
||||
null,
|
||||
new SearchVisibilityScope(null, Set.of(), Set.of()),
|
||||
payload,
|
||||
0,
|
||||
12
|
||||
));
|
||||
|
||||
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
|
||||
assertThat(sqlCaptor.getAllValues().getFirst())
|
||||
.doesNotContain(payload)
|
||||
.contains("ORDER BY s.updated_at DESC, d.skill_id DESC");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue