mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
feat: add search index rebuild workflow
This commit is contained in:
parent
049f5acb64
commit
024e66d747
14 changed files with 477 additions and 19 deletions
|
|
@ -359,7 +359,7 @@
|
|||
| title | varchar(256) | |
|
||||
| summary | varchar(512) | |
|
||||
| keywords | varchar(512) | |
|
||||
| search_text | text | SKILL.md 正文 + frontmatter 拼接 |
|
||||
| search_text | text | `displayName`、`slug`、`summary`,以及 frontmatter 中除 `name` / `description` / `version` 外的字段展开结果 |
|
||||
| visibility | enum | 冗余,避免搜索时 join |
|
||||
| status | enum | |
|
||||
| updated_at | datetime | |
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ WHERE (visibility = 'PUBLIC')
|
|||
| title | varchar(256) | |
|
||||
| summary | varchar(512) | |
|
||||
| keywords | varchar(512) | |
|
||||
| search_text | text | SKILL.md 正文 + frontmatter 拼接 |
|
||||
| search_text | text | `displayName`、`slug`、`summary`,以及 frontmatter 中除 `name` / `description` / `version` 外的字段展开结果 |
|
||||
| visibility | enum | 冗余,避免搜索时 join |
|
||||
| status | enum | |
|
||||
| updated_at | datetime | |
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ description: 搜索和筛选技能
|
|||
在搜索框输入关键词,SkillHub 会在以下字段中搜索:
|
||||
- 技能名称
|
||||
- 技能描述
|
||||
- SKILL.md 正文内容
|
||||
- 关键词
|
||||
- 技能 slug
|
||||
- frontmatter 中除 `name`、`description`、`version` 外的其他字段
|
||||
- `keywords` / `tags` 等关键词字段
|
||||
|
||||
## 筛选条件
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ description: Search and filter skills
|
|||
Enter keywords in the search box, SkillHub searches in the following fields:
|
||||
- Skill name
|
||||
- Skill description
|
||||
- SKILL.md body content
|
||||
- Keywords
|
||||
- Skill slug
|
||||
- Frontmatter fields other than `name`, `description`, and `version`
|
||||
- Keyword-style fields such as `keywords` and `tags`
|
||||
|
||||
## Filter Conditions
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package com.iflytek.skillhub.controller.admin;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.MDC;
|
||||
import com.iflytek.skillhub.search.SearchRebuildService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Administrative maintenance endpoints for search-index operations reserved for super administrators.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/search")
|
||||
public class AdminSearchController extends BaseApiController {
|
||||
|
||||
private final SearchRebuildService searchRebuildService;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
public AdminSearchController(ApiResponseFactory responseFactory,
|
||||
SearchRebuildService searchRebuildService,
|
||||
AuditLogService auditLogService) {
|
||||
super(responseFactory);
|
||||
this.searchRebuildService = searchRebuildService;
|
||||
this.auditLogService = auditLogService;
|
||||
}
|
||||
|
||||
@PostMapping("/rebuild")
|
||||
@PreAuthorize("hasRole('SUPER_ADMIN')")
|
||||
public ApiResponse<Void> rebuildAll(@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
HttpServletRequest httpRequest) {
|
||||
searchRebuildService.rebuildAll();
|
||||
auditLogService.record(
|
||||
principal.userId(),
|
||||
"REBUILD_SEARCH_INDEX",
|
||||
"SEARCH_INDEX",
|
||||
null,
|
||||
MDC.get("requestId"),
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent"),
|
||||
"{\"scope\":\"ALL\"}"
|
||||
);
|
||||
return ok("response.success.updated", null);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package com.iflytek.skillhub.controller.admin;
|
||||
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.search.SearchRebuildService;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class AdminSearchControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private SearchRebuildService searchRebuildService;
|
||||
|
||||
@MockBean
|
||||
private AuditLogService auditLogService;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@Test
|
||||
void rebuildAll_returnsOkForSuperAdmin() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SUPER_ADMIN"));
|
||||
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")));
|
||||
|
||||
mockMvc.perform(post("/api/v1/admin/search/rebuild")
|
||||
.with(authentication(auth))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
verify(searchRebuildService).rebuildAll();
|
||||
verify(auditLogService).record(
|
||||
org.mockito.ArgumentMatchers.eq("admin"),
|
||||
org.mockito.ArgumentMatchers.eq("REBUILD_SEARCH_INDEX"),
|
||||
org.mockito.ArgumentMatchers.eq("SEARCH_INDEX"),
|
||||
org.mockito.ArgumentMatchers.isNull(),
|
||||
org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.eq("{\"scope\":\"ALL\"}")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rebuildAll_returnsForbiddenForSkillAdmin() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN"));
|
||||
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN")));
|
||||
|
||||
mockMvc.perform(post("/api/v1/admin/search/rebuild")
|
||||
.with(authentication(auth))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +1,54 @@
|
|||
package com.iflytek.skillhub.search.postgres;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.search.SearchIndexService;
|
||||
import com.iflytek.skillhub.search.SearchRebuildService;
|
||||
import com.iflytek.skillhub.search.SkillSearchDocument;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Reconstructs PostgreSQL search documents from canonical skill and namespace records.
|
||||
*/
|
||||
@Service
|
||||
public class PostgresSearchRebuildService implements SearchRebuildService {
|
||||
private static final Set<String> RESERVED_FRONTMATTER_FIELDS = Set.of("name", "description", "version");
|
||||
private static final Set<String> KEYWORD_FIELD_NAMES = Set.of("keywords", "keyword", "tags", "tag");
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
|
||||
|
||||
private final SkillRepository skillRepository;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SearchIndexService searchIndexService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public PostgresSearchRebuildService(
|
||||
SkillRepository skillRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SearchIndexService searchIndexService) {
|
||||
this.skillRepository = skillRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.searchIndexService = searchIndexService;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -61,16 +80,112 @@ public class PostgresSearchRebuildService implements SearchRebuildService {
|
|||
toDocument(skillOpt.get()).ifPresent(searchIndexService::index);
|
||||
}
|
||||
|
||||
private String buildSearchText(Skill skill) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (skill.getDisplayName() != null) {
|
||||
sb.append(skill.getDisplayName()).append(" ");
|
||||
private SearchIndexPayload buildSearchPayload(Skill skill) {
|
||||
List<String> searchParts = new ArrayList<>();
|
||||
addPart(searchParts, skill.getDisplayName());
|
||||
addPart(searchParts, skill.getSlug());
|
||||
addPart(searchParts, skill.getSummary());
|
||||
|
||||
Set<String> keywords = new TreeSet<>();
|
||||
resolveLatestVersion(skill)
|
||||
.map(this::extractParsedMetadata)
|
||||
.map(metadata -> metadata.get("frontmatter"))
|
||||
.map(this::asMap)
|
||||
.ifPresent(frontmatter -> appendFrontmatter(frontmatter, keywords, searchParts));
|
||||
|
||||
return new SearchIndexPayload(
|
||||
String.join(", ", keywords),
|
||||
String.join(" ", searchParts).trim()
|
||||
);
|
||||
}
|
||||
|
||||
private Optional<SkillVersion> resolveLatestVersion(Skill skill) {
|
||||
if (skill.getLatestVersionId() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
sb.append(skill.getSlug()).append(" ");
|
||||
if (skill.getSummary() != null) {
|
||||
sb.append(skill.getSummary()).append(" ");
|
||||
return skillVersionRepository.findById(skill.getLatestVersionId());
|
||||
}
|
||||
|
||||
private Map<String, Object> extractParsedMetadata(SkillVersion version) {
|
||||
String metadataJson = version.getParsedMetadataJson();
|
||||
if (metadataJson == null || metadataJson.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(metadataJson, MAP_TYPE);
|
||||
} catch (Exception e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> asMap(Object value) {
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
return map.entrySet().stream()
|
||||
.filter(entry -> entry.getKey() != null)
|
||||
.collect(Collectors.toMap(entry -> String.valueOf(entry.getKey()), Map.Entry::getValue));
|
||||
}
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
private void appendFrontmatter(Map<String, Object> frontmatter, Set<String> keywords, List<String> searchParts) {
|
||||
for (Map.Entry<String, Object> entry : frontmatter.entrySet()) {
|
||||
String fieldName = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (KEYWORD_FIELD_NAMES.contains(fieldName.toLowerCase())) {
|
||||
flattenToStrings(value).forEach(keyword -> {
|
||||
String normalized = keyword.trim();
|
||||
if (!normalized.isBlank()) {
|
||||
keywords.add(normalized);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!RESERVED_FRONTMATTER_FIELDS.contains(fieldName.toLowerCase())) {
|
||||
addPart(searchParts, fieldName);
|
||||
flattenToStrings(value).forEach(text -> addPart(searchParts, text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> flattenToStrings(Object value) {
|
||||
if (value instanceof String text) {
|
||||
return List.of(text);
|
||||
}
|
||||
if (value instanceof Number || value instanceof Boolean) {
|
||||
return List.of(String.valueOf(value));
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
List<String> values = new ArrayList<>();
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
if (entry.getKey() != null) {
|
||||
values.add(String.valueOf(entry.getKey()));
|
||||
}
|
||||
values.addAll(flattenToStrings(entry.getValue()));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
if (value instanceof Collection<?> collection) {
|
||||
return collection.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.flatMap(item -> flattenToStrings(item).stream())
|
||||
.toList();
|
||||
}
|
||||
return List.of(String.valueOf(value));
|
||||
}
|
||||
|
||||
private void addPart(List<String> parts, String value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
String normalized = value.trim();
|
||||
if (!normalized.isBlank()) {
|
||||
parts.add(normalized);
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
private Optional<SkillSearchDocument> toDocument(Skill skill) {
|
||||
|
|
@ -80,7 +195,7 @@ public class PostgresSearchRebuildService implements SearchRebuildService {
|
|||
}
|
||||
|
||||
Namespace namespace = namespaceOpt.get();
|
||||
String searchText = buildSearchText(skill);
|
||||
SearchIndexPayload payload = buildSearchPayload(skill);
|
||||
|
||||
return Optional.of(new SkillSearchDocument(
|
||||
skill.getId(),
|
||||
|
|
@ -89,11 +204,14 @@ public class PostgresSearchRebuildService implements SearchRebuildService {
|
|||
skill.getOwnerId(),
|
||||
skill.getDisplayName() != null ? skill.getDisplayName() : skill.getSlug(),
|
||||
skill.getSummary(),
|
||||
"",
|
||||
searchText,
|
||||
payload.keywords(),
|
||||
payload.searchText(),
|
||||
null,
|
||||
skill.getVisibility().name(),
|
||||
skill.getStatus().name()
|
||||
));
|
||||
}
|
||||
|
||||
private record SearchIndexPayload(String keywords, String searchText) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
package com.iflytek.skillhub.search.postgres;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
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.SkillVisibility;
|
||||
import com.iflytek.skillhub.search.SearchIndexService;
|
||||
import com.iflytek.skillhub.search.SkillSearchDocument;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PostgresSearchRebuildServiceTest {
|
||||
|
||||
@Test
|
||||
void rebuildBySkill_shouldIndexFrontmatterFieldsAndKeywordsWithoutBody() {
|
||||
SkillRepository skillRepository = mock(SkillRepository.class);
|
||||
NamespaceRepository namespaceRepository = mock(NamespaceRepository.class);
|
||||
SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class);
|
||||
SearchIndexService searchIndexService = mock(SearchIndexService.class);
|
||||
|
||||
Skill skill = new Skill(7L, "smart-agent", "owner-1", SkillVisibility.PUBLIC);
|
||||
skill.setDisplayName("Smart Agent");
|
||||
skill.setSummary("Builds workflows");
|
||||
skill.setLatestVersionId(99L);
|
||||
|
||||
Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1");
|
||||
|
||||
SkillVersion version = new SkillVersion(1L, "1.2.0", "owner-1");
|
||||
version.setParsedMetadataJson("""
|
||||
{
|
||||
"name": "Smart Agent",
|
||||
"description": "Builds workflows",
|
||||
"version": "1.2.0",
|
||||
"body": "# ignored",
|
||||
"frontmatter": {
|
||||
"name": "Smart Agent",
|
||||
"description": "Builds workflows",
|
||||
"version": "1.2.0",
|
||||
"author": "Jane Doe",
|
||||
"tags": ["automation", "agentic"],
|
||||
"keywords": ["workflow", "assistant"],
|
||||
"config": {
|
||||
"provider": "openai"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(namespaceRepository.findById(7L)).thenReturn(Optional.of(namespace));
|
||||
when(skillVersionRepository.findById(99L)).thenReturn(Optional.of(version));
|
||||
|
||||
PostgresSearchRebuildService service = new PostgresSearchRebuildService(
|
||||
skillRepository,
|
||||
namespaceRepository,
|
||||
skillVersionRepository,
|
||||
searchIndexService
|
||||
);
|
||||
|
||||
service.rebuildBySkill(1L);
|
||||
|
||||
ArgumentCaptor<SkillSearchDocument> captor = ArgumentCaptor.forClass(SkillSearchDocument.class);
|
||||
verify(searchIndexService).index(captor.capture());
|
||||
|
||||
SkillSearchDocument document = captor.getValue();
|
||||
assertThat(document.keywords()).isEqualTo("agentic, assistant, automation, workflow");
|
||||
assertThat(document.searchText()).contains("Smart Agent");
|
||||
assertThat(document.searchText()).contains("smart-agent");
|
||||
assertThat(document.searchText()).contains("Builds workflows");
|
||||
assertThat(document.searchText()).contains("author");
|
||||
assertThat(document.searchText()).contains("Jane Doe");
|
||||
assertThat(document.searchText()).contains("config");
|
||||
assertThat(document.searchText()).contains("provider");
|
||||
assertThat(document.searchText()).contains("openai");
|
||||
assertThat(document.searchText()).doesNotContain("# ignored");
|
||||
}
|
||||
}
|
||||
|
|
@ -831,6 +831,13 @@ export const governanceApi = {
|
|||
headers: getCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async rebuildSearchIndex(): Promise<void> {
|
||||
await fetchJson<void>('/api/v1/admin/search/rebuild', {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const meApi = {
|
||||
|
|
|
|||
|
|
@ -49,3 +49,9 @@ export function useMarkGovernanceNotificationRead() {
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRebuildSearchIndex() {
|
||||
return useMutation({
|
||||
mutationFn: () => governanceApi.rebuildSearchIndex(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -493,6 +493,9 @@
|
|||
"filterUnhideSkill": "Skill Unhidden",
|
||||
"filterUnarchiveSkill": "Skill Restored",
|
||||
"filterYankVersion": "Version Yanked",
|
||||
"filterRebuildSearchIndex": "Search Index Rebuilt",
|
||||
"quickFilterSearchRebuild": "Search index rebuilds only",
|
||||
"clearFilters": "Clear filters",
|
||||
"userIdPlaceholder": "User ID...",
|
||||
"requestIdPlaceholder": "Request ID...",
|
||||
"ipPlaceholder": "IP address...",
|
||||
|
|
@ -762,7 +765,17 @@
|
|||
"activityTitle": "Governance activity",
|
||||
"activitySubtitle": "Recent audit events for review, promotion, report, and lifecycle actions.",
|
||||
"emptyActivity": "No recent governance activity.",
|
||||
"unknownActor": "Unknown actor"
|
||||
"unknownActor": "Unknown actor",
|
||||
"searchMaintenanceTitle": "Search Index Maintenance",
|
||||
"searchMaintenanceDescription": "Rebuild the full skill search index. This action is available only to super administrators and is intended for full backfills after search rule changes.",
|
||||
"searchMaintenanceHint": "This may take a while. Avoid triggering it repeatedly.",
|
||||
"searchRebuildAction": "Rebuild full search index",
|
||||
"searchRebuildRunning": "Rebuilding...",
|
||||
"searchRebuildConfirmTitle": "Rebuild the full search index?",
|
||||
"searchRebuildConfirmDescription": "The system will rebuild search documents for all skills using the current indexing rules. This operation may take some time.",
|
||||
"searchRebuildSuccessTitle": "Search index rebuild started",
|
||||
"searchRebuildSuccessDescription": "The system is rebuilding the full search index using the current rules.",
|
||||
"searchRebuildErrorTitle": "Search index rebuild failed"
|
||||
},
|
||||
"members": {
|
||||
"title": "Member Management",
|
||||
|
|
|
|||
|
|
@ -493,6 +493,9 @@
|
|||
"filterUnhideSkill": "恢复隐藏",
|
||||
"filterUnarchiveSkill": "恢复归档",
|
||||
"filterYankVersion": "版本撤回",
|
||||
"filterRebuildSearchIndex": "重建搜索索引",
|
||||
"quickFilterSearchRebuild": "仅看搜索索引重建",
|
||||
"clearFilters": "清空筛选",
|
||||
"userIdPlaceholder": "用户 ID...",
|
||||
"requestIdPlaceholder": "请求 ID...",
|
||||
"ipPlaceholder": "IP 地址...",
|
||||
|
|
@ -762,7 +765,17 @@
|
|||
"activityTitle": "治理活动",
|
||||
"activitySubtitle": "最近的审核、提升、举报和生命周期治理审计记录。",
|
||||
"emptyActivity": "暂无治理活动。",
|
||||
"unknownActor": "未知操作者"
|
||||
"unknownActor": "未知操作者",
|
||||
"searchMaintenanceTitle": "搜索索引维护",
|
||||
"searchMaintenanceDescription": "重新构建全部技能搜索索引。该操作仅超级管理员可执行,适用于搜索规则变更后的全量回填。",
|
||||
"searchMaintenanceHint": "执行期间可能耗时较长,请避免频繁触发。",
|
||||
"searchRebuildAction": "重建全部搜索索引",
|
||||
"searchRebuildRunning": "重建中...",
|
||||
"searchRebuildConfirmTitle": "确认重建全部搜索索引?",
|
||||
"searchRebuildConfirmDescription": "系统会按当前搜索规则重建所有技能的搜索文档。该操作可能持续一段时间。",
|
||||
"searchRebuildSuccessTitle": "已触发搜索索引重建",
|
||||
"searchRebuildSuccessDescription": "系统正在按当前规则重建全部搜索索引。",
|
||||
"searchRebuildErrorTitle": "搜索索引重建失败"
|
||||
},
|
||||
"members": {
|
||||
"title": "成员管理",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const ACTION_OPTIONS = [
|
|||
{ value: 'UNHIDE_SKILL', labelKey: 'auditLog.filterUnhideSkill' },
|
||||
{ value: 'UNARCHIVE_SKILL', labelKey: 'auditLog.filterUnarchiveSkill' },
|
||||
{ value: 'YANK_SKILL_VERSION', labelKey: 'auditLog.filterYankVersion' },
|
||||
{ value: 'REBUILD_SEARCH_INDEX', labelKey: 'auditLog.filterRebuildSearchIndex' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
|
|
@ -68,6 +69,24 @@ export function AuditLogPage() {
|
|||
return formatLocalDateTime(dateString, i18n.language)
|
||||
}
|
||||
|
||||
const applySearchIndexRebuildFilter = () => {
|
||||
setActionFilter('REBUILD_SEARCH_INDEX')
|
||||
setResourceTypeFilter('SEARCH_INDEX')
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setActionFilter('')
|
||||
setUserIdFilter('')
|
||||
setRequestIdFilter('')
|
||||
setIpFilter('')
|
||||
setResourceTypeFilter('')
|
||||
setResourceIdFilter('')
|
||||
setStartTimeFilter('')
|
||||
setEndTimeFilter('')
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<div>
|
||||
|
|
@ -76,6 +95,15 @@ export function AuditLogPage() {
|
|||
</div>
|
||||
|
||||
<Card className="p-5">
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={applySearchIndexRebuildFilter}>
|
||||
{t('auditLog.quickFilterSearchRebuild')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={clearFilters}>
|
||||
{t('auditLog.clearFilters')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Select value={actionFilter} onChange={(e) => {
|
||||
setActionFilter(e.target.value)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
import { GovernanceInbox } from '@/features/governance/governance-inbox'
|
||||
|
|
@ -10,6 +14,7 @@ import {
|
|||
useGovernanceActivity,
|
||||
useGovernanceInbox,
|
||||
useGovernanceNotifications,
|
||||
useRebuildSearchIndex,
|
||||
useGovernanceSummary,
|
||||
useMarkGovernanceNotificationRead,
|
||||
} from '@/features/governance/use-governance'
|
||||
|
|
@ -31,14 +36,28 @@ function SummaryCard({ label, value }: { label: string; value?: number }) {
|
|||
|
||||
export function GovernancePage() {
|
||||
const { t } = useTranslation()
|
||||
const { hasRole } = useAuth()
|
||||
const [inboxType, setInboxType] = useState<GovernanceInboxTab>('ALL')
|
||||
const [rebuildDialogOpen, setRebuildDialogOpen] = useState(false)
|
||||
const { data: summary, isLoading: isSummaryLoading } = useGovernanceSummary()
|
||||
const { data: inboxItems, isLoading: isInboxLoading } = useGovernanceInbox(inboxType === 'ALL' ? undefined : inboxType)
|
||||
const { data: activityItems, isLoading: isActivityLoading } = useGovernanceActivity()
|
||||
const { data: notifications, isLoading: isNotificationsLoading } = useGovernanceNotifications()
|
||||
const markReadMutation = useMarkGovernanceNotificationRead()
|
||||
const rebuildSearchIndexMutation = useRebuildSearchIndex()
|
||||
|
||||
const unreadCount = notifications?.filter((item) => item.status === 'UNREAD').length ?? 0
|
||||
const canRebuildSearchIndex = hasRole('SUPER_ADMIN')
|
||||
|
||||
const handleRebuildSearchIndex = async () => {
|
||||
try {
|
||||
await rebuildSearchIndexMutation.mutateAsync()
|
||||
toast.success(t('governance.searchRebuildSuccessTitle'), t('governance.searchRebuildSuccessDescription'))
|
||||
} catch (error) {
|
||||
toast.error(t('governance.searchRebuildErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
|
|
@ -101,6 +120,37 @@ export function GovernancePage() {
|
|||
<GovernanceActivity items={activityItems} isLoading={isActivityLoading} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{canRebuildSearchIndex ? (
|
||||
<>
|
||||
<Card className="p-5 space-y-4 border border-amber-500/20 bg-amber-500/5">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold font-heading">{t('governance.searchMaintenanceTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('governance.searchMaintenanceDescription')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setRebuildDialogOpen(true)}
|
||||
disabled={rebuildSearchIndexMutation.isPending}
|
||||
>
|
||||
{rebuildSearchIndexMutation.isPending ? t('governance.searchRebuildRunning') : t('governance.searchRebuildAction')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{t('governance.searchMaintenanceHint')}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={rebuildDialogOpen}
|
||||
onOpenChange={setRebuildDialogOpen}
|
||||
title={t('governance.searchRebuildConfirmTitle')}
|
||||
description={t('governance.searchRebuildConfirmDescription')}
|
||||
confirmText={t('governance.searchRebuildAction')}
|
||||
onConfirm={handleRebuildSearchIndex}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue