feat: hide archived namespaces from public reads

This commit is contained in:
yun-zhi-ztl 2026-03-16 12:00:08 +08:00 • committed by Xudong Sun
parent fcc2df2975
commit 33ed8fe17d
6 changed files with 174 additions and 22 deletions

View file

@ -1,6 +1,8 @@
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.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
@ -89,19 +91,21 @@ public class SkillSearchAppService {
.map(Skill::getNamespaceId)
.distinct()
.toList();
Map<Long, String> namespaceSlugsById = namespaceIds.isEmpty()
Map<Long, Namespace> namespacesById = namespaceIds.isEmpty()
? Map.of()
: namespaceRepository.findByIdIn(namespaceIds).stream()
.collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId,
com.iflytek.skillhub.domain.namespace.Namespace::getSlug));
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
Map<Long, String> namespaceSlugsById = namespacesById.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getSlug()));
List<SkillSummaryResponse> skills = result.skillIds().stream()
.map(skillsById::get)
.filter(java.util.Objects::nonNull)
.filter(skill -> namespaceVisible(skill.getNamespaceId(), namespacesById, userId, userNsRoles))
.map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById))
.toList();
return new SearchResponse(skills, result.total(), result.page(), result.size());
return new SearchResponse(skills, skills.size(), result.page(), result.size());
}
private Long resolveNamespaceId(String namespaceSlug) {
@ -158,4 +162,18 @@ public class SkillSearchAppService {
skill.getUpdatedAt()
);
}
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);
}
}

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.SkillFile;
import com.iflytek.skillhub.domain.skill.service.SkillDownloadService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
@ -138,6 +139,20 @@ class SkillControllerTest {
.andExpect(jsonPath("$.data.canInteract").value(false));
}
@Test
void getSkillDetailShouldReturnForbiddenForArchivedNamespace() throws Exception {
when(skillQueryService.getSkillDetail(
eq("team"),
eq("demo"),
eq((String) null),
eq(Map.<Long, NamespaceRole>of())))
.thenThrow(new DomainForbiddenException("error.namespace.archived", "team"));
mockMvc.perform(get("/api/web/skills/team/demo"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(403));
}
@Test
void listFilesByTagShouldReturnUnifiedEnvelope() throws Exception {
when(skillQueryService.listFilesByTag(

View file

@ -12,6 +12,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@ -40,11 +41,11 @@ class SkillSearchControllerTest {
eq("newest"),
eq(0),
eq(20),
eq((String) null),
eq(null)))
any(),
any()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20));
mockMvc.perform(get("/api/v1/skills")
mockMvc.perform(get("/api/web/skills")
.param("q", "review")
.param("namespace", "global"))
.andExpect(status().isOk())

View file

@ -0,0 +1,76 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
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;
import org.mockito.Mock;
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.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SkillSearchAppServiceTest {
@Mock
private SearchQueryService searchQueryService;
@Mock
private SkillRepository skillRepository;
@Mock
private NamespaceRepository namespaceRepository;
@Mock
private SkillVersionRepository skillVersionRepository;
private SkillSearchAppService service;
@BeforeEach
void setUp() {
service = new SkillSearchAppService(searchQueryService, skillRepository, namespaceRepository, 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));
SkillSearchAppService.SearchResponse response = service.search("archive", null, "newest", 0, 20, null, null);
assertEquals(0, response.items().size());
assertEquals(0, response.total());
}
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

@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.*;
@ -170,8 +171,9 @@ public class SkillQueryService {
String version,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = findVersion(skill, version);
assertPreviewAccessible(skill, skillVersion, version, currentUserId);
@ -194,8 +196,9 @@ public class SkillQueryService {
String version,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = findVersion(skill, version);
assertPreviewAccessible(skill, skillVersion, version, currentUserId);
@ -209,8 +212,9 @@ public class SkillQueryService {
String tagName,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null);
return skillFileRepository.findByVersionId(skillVersion.getId());
}
@ -222,8 +226,9 @@ public class SkillQueryService {
String filePath,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = findVersion(skill, version);
assertPreviewAccessible(skill, skillVersion, version, currentUserId);
@ -240,8 +245,9 @@ public class SkillQueryService {
String filePath,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null);
SkillFile file = findFile(skillVersion, filePath);
return readFileContent(file);
@ -252,8 +258,9 @@ public class SkillQueryService {
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Pageable pageable) {
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
List<SkillVersion> visibleVersions;
if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream()
@ -295,8 +302,9 @@ public class SkillQueryService {
throw new DomainBadRequestException("error.skill.resolve.versionTag.conflict");
}
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = findSkill(namespace, skillSlug);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash);
String fingerprint = computeFingerprint(resolved);
Boolean matched = hash == null || hash.isBlank() ? null : Objects.equals(hash, fingerprint);
@ -324,6 +332,10 @@ public class SkillQueryService {
private Skill findSkill(String namespaceSlug, String skillSlug) {
Namespace namespace = findNamespace(namespaceSlug);
return findSkill(namespace, skillSlug);
}
private Skill findSkill(Namespace namespace, String skillSlug) {
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
}
@ -440,7 +452,14 @@ public class SkillQueryService {
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
}
private void assertPublishedAccessible(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
private void assertPublishedAccessible(
Namespace namespace,
Skill skill,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
if (namespace.getStatus() == NamespaceStatus.ARCHIVED && !isNamespaceMember(skill.getNamespaceId(), currentUserId, userNsRoles)) {
throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug());
}
if (skill.getStatus() != SkillStatus.ACTIVE && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug());
}
@ -466,6 +485,10 @@ public class SkillQueryService {
return currentUserId != null && skill.getOwnerId().equals(currentUserId);
}
private boolean isNamespaceMember(Long namespaceId, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
return currentUserId != null && userNsRoles.containsKey(namespaceId);
}
private int lifecycleListPriority(SkillVersionStatus status) {
if (status == SkillVersionStatus.PUBLISHED) {
return 0;

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.*;
@ -118,6 +119,24 @@ class SkillQueryServiceTest {
);
}
@Test
void testGetSkillDetail_ShouldHideArchivedNamespaceFromAnonymousUsers() throws Exception {
String namespaceSlug = "archived-team";
String skillSlug = "test-skill";
Namespace namespace = new Namespace(namespaceSlug, "Archived Team", "user-1");
namespace.setStatus(NamespaceStatus.ARCHIVED);
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC);
setId(skill, 1L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
assertThrows(DomainForbiddenException.class, () ->
service.getSkillDetail(namespaceSlug, skillSlug, null, Map.of()));
}
@Test
void testListSkillsByNamespace() throws Exception {
// Arrange