mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
Merge remote-tracking branch 'origin/main' into feature/project-fixbug319
This commit is contained in:
commit
ae91e7ead2
42 changed files with 1886 additions and 639 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 | |
|
||||
|
|
|
|||
|
|
@ -2,8 +2,31 @@
|
|||
|
||||
This document records architecture and structure issues that became consistently visible while enriching backend comments. The goal is to preserve concrete observations discovered during code reading, not to propose a full redesign.
|
||||
|
||||
## Status Update (2026-03-19)
|
||||
|
||||
This document was re-checked after the refactor branch work for findings 1, 2, and 4.
|
||||
|
||||
- Finding 1 is now handled in code.
|
||||
- Finding 2 is partially handled in code.
|
||||
- Finding 4 is now handled in code.
|
||||
|
||||
Validation completed on the standard regression path:
|
||||
|
||||
- `make test`
|
||||
- backend Maven tests: `208` passed
|
||||
- frontend Vitest tests: `61` passed
|
||||
|
||||
Double-check notes:
|
||||
|
||||
- The admin-user refactor removed an overlapping, unused application service rather than changing the controller-facing workflow owner.
|
||||
- The namespace and skill-lifecycle refactors moved orchestration out of controllers, but preserved the same downstream domain-service calls, request parameters, audit fields, response message keys, and mutation response shapes.
|
||||
- The security refactor centralized route metadata into one registry, but preserved the same route authorization rules, API-token scope behavior, and CSRF-ignore behavior.
|
||||
- `AuthContextFilter` is now scoped to API paths when projecting request attributes. This narrows unnecessary work on non-API requests, but it does not change existing business behavior because `userId` and `userNsRoles` consumers are API-side controllers and interceptors.
|
||||
|
||||
## 1. Admin user management is split across overlapping application services
|
||||
|
||||
Status: handled on branch `docs/backend-annotation-findings-discussion`
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java`
|
||||
|
|
@ -19,8 +42,16 @@ Suggested direction:
|
|||
|
||||
- Either consolidate them into one application service, or split them with an explicit boundary such as query vs. command, or account governance vs. account operations.
|
||||
|
||||
Current state:
|
||||
|
||||
- `AdminUserManagementService` has been removed.
|
||||
- `UserManagementController` continues to use `AdminUserAppService` as the single application-service entry point.
|
||||
- Behavior review found no business-logic drift here because the deleted service had no active controller call path.
|
||||
|
||||
## 2. Several controllers still perform orchestration that belongs in application services
|
||||
|
||||
Status: partially handled on branch `docs/backend-annotation-findings-discussion`
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java`
|
||||
|
|
@ -39,6 +70,13 @@ Suggested direction:
|
|||
|
||||
- Move multi-step orchestration into dedicated application services and keep controllers focused on transport concerns.
|
||||
|
||||
Current state:
|
||||
|
||||
- `NamespaceController` has been slimmed down by moving orchestration into `NamespacePortalQueryAppService` and `NamespacePortalCommandAppService`.
|
||||
- `SkillLifecycleController` has been slimmed down by moving orchestration into `SkillLifecycleAppService`.
|
||||
- This branch preserved the original domain-service calls and response contracts for the refactored endpoints.
|
||||
- `ReviewController`, `PromotionController`, and `ClawHubCompatController` still exhibit the same structural issue and remain future work.
|
||||
|
||||
## 3. Compatibility endpoints are tightly coupled to canonical domain and repository internals
|
||||
|
||||
Observed files:
|
||||
|
|
@ -58,6 +96,8 @@ Suggested direction:
|
|||
|
||||
## 4. Security route policy is spread across configuration and implementation classes
|
||||
|
||||
Status: handled on branch `docs/backend-annotation-findings-discussion`
|
||||
|
||||
Observed files:
|
||||
|
||||
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java`
|
||||
|
|
@ -73,6 +113,12 @@ Suggested direction:
|
|||
|
||||
- Centralize route policy metadata or at least define one authoritative mapping between path patterns, authentication modes, and scope requirements.
|
||||
|
||||
Current state:
|
||||
|
||||
- Route metadata is now centralized in `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java`.
|
||||
- `SecurityConfig`, `ApiTokenScopeService`, and `AuthContextFilter` now depend on that shared registry instead of maintaining separate route lists.
|
||||
- Double-check review confirmed that the refactor preserved the previous access model while removing duplication.
|
||||
|
||||
## 5. Governance behavior is distributed across multiple services without one clear workflow owner
|
||||
|
||||
Observed files:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,19 +2,29 @@ package com.iflytek.skillhub.controller.portal;
|
|||
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.*;
|
||||
import com.iflytek.skillhub.dto.*;
|
||||
import com.iflytek.skillhub.exception.ForbiddenException;
|
||||
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.MemberRequest;
|
||||
import com.iflytek.skillhub.dto.MemberResponse;
|
||||
import com.iflytek.skillhub.dto.MessageResponse;
|
||||
import com.iflytek.skillhub.dto.MyNamespaceResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceLifecycleRequest;
|
||||
import com.iflytek.skillhub.dto.NamespaceRequest;
|
||||
import com.iflytek.skillhub.dto.NamespaceResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.UpdateMemberRoleRequest;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
import com.iflytek.skillhub.service.NamespacePortalCommandAppService;
|
||||
import com.iflytek.skillhub.service.NamespacePortalQueryAppService;
|
||||
import com.iflytek.skillhub.service.NamespaceMemberCandidateService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
|
@ -26,84 +36,46 @@ import java.util.Map;
|
|||
@RequestMapping({"/api/v1", "/api/web"})
|
||||
public class NamespaceController extends BaseApiController {
|
||||
|
||||
private final NamespaceService namespaceService;
|
||||
private final NamespaceMemberService namespaceMemberService;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceGovernanceService namespaceGovernanceService;
|
||||
private final NamespaceAccessPolicy namespaceAccessPolicy;
|
||||
private final NamespacePortalQueryAppService namespacePortalQueryAppService;
|
||||
private final NamespacePortalCommandAppService namespacePortalCommandAppService;
|
||||
private final NamespaceMemberCandidateService namespaceMemberCandidateService;
|
||||
|
||||
public NamespaceController(NamespaceService namespaceService,
|
||||
NamespaceMemberService namespaceMemberService,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceGovernanceService namespaceGovernanceService,
|
||||
NamespaceAccessPolicy namespaceAccessPolicy,
|
||||
NamespaceMemberCandidateService namespaceMemberCandidateService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
public NamespaceController(NamespacePortalQueryAppService namespacePortalQueryAppService,
|
||||
NamespacePortalCommandAppService namespacePortalCommandAppService,
|
||||
NamespaceMemberCandidateService namespaceMemberCandidateService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.namespaceService = namespaceService;
|
||||
this.namespaceMemberService = namespaceMemberService;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceGovernanceService = namespaceGovernanceService;
|
||||
this.namespaceAccessPolicy = namespaceAccessPolicy;
|
||||
this.namespacePortalQueryAppService = namespacePortalQueryAppService;
|
||||
this.namespacePortalCommandAppService = namespacePortalCommandAppService;
|
||||
this.namespaceMemberCandidateService = namespaceMemberCandidateService;
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces")
|
||||
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(Pageable pageable) {
|
||||
Page<Namespace> namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable);
|
||||
PageResponse<NamespaceResponse> response = PageResponse.from(namespaces.map(NamespaceResponse::from));
|
||||
return ok("response.success.read", response);
|
||||
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable));
|
||||
}
|
||||
|
||||
@GetMapping("/me/namespaces")
|
||||
public ApiResponse<List<MyNamespaceResponse>> listMyNamespaces(
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
|
||||
if (namespaceRoles.isEmpty()) {
|
||||
return ok("response.success.read", List.of());
|
||||
}
|
||||
|
||||
List<MyNamespaceResponse> response = namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream()
|
||||
.sorted(Comparator.comparing(Namespace::getSlug))
|
||||
.map(namespace -> MyNamespaceResponse.from(namespace, namespaceRoles.get(namespace.getId()), namespaceAccessPolicy))
|
||||
.toList();
|
||||
|
||||
return ok("response.success.read", response);
|
||||
return ok("response.success.read", namespacePortalQueryAppService.listMyNamespaces(userNsRoles));
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces/{slug}")
|
||||
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlugForRead(slug, userId, userNsRoles != null ? userNsRoles : Map.of());
|
||||
return ok("response.success.read", NamespaceResponse.from(namespace));
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces")
|
||||
public ApiResponse<NamespaceResponse> createNamespace(
|
||||
@Valid @RequestBody NamespaceRequest request,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
if (principal == null) {
|
||||
throw new UnauthorizedException("error.auth.required");
|
||||
}
|
||||
if (!canCreateNamespace(principal)) {
|
||||
throw new ForbiddenException("error.namespace.create.platformAdminRequired");
|
||||
}
|
||||
|
||||
Namespace namespace = namespaceService.createNamespace(
|
||||
request.slug(),
|
||||
request.displayName(),
|
||||
request.description(),
|
||||
principal.userId()
|
||||
);
|
||||
return ok("response.success.created", NamespaceResponse.from(namespace));
|
||||
}
|
||||
|
||||
private boolean canCreateNamespace(PlatformPrincipal principal) {
|
||||
return principal.platformRoles().contains("SKILL_ADMIN")
|
||||
|| principal.platformRoles().contains("SUPER_ADMIN");
|
||||
return ok("response.success.created",
|
||||
namespacePortalCommandAppService.createNamespace(request, principal));
|
||||
}
|
||||
|
||||
@PutMapping("/namespaces/{slug}")
|
||||
|
|
@ -111,15 +83,8 @@ public class NamespaceController extends BaseApiController {
|
|||
@PathVariable String slug,
|
||||
@RequestBody NamespaceRequest request,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
Namespace updated = namespaceService.updateNamespace(
|
||||
namespace.getId(),
|
||||
request.displayName(),
|
||||
request.description(),
|
||||
null,
|
||||
userId
|
||||
);
|
||||
return ok("response.success.updated", NamespaceResponse.from(updated));
|
||||
return ok("response.success.updated",
|
||||
namespacePortalCommandAppService.updateNamespace(slug, request, userId));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces/{slug}/freeze")
|
||||
|
|
@ -127,29 +92,23 @@ public class NamespaceController extends BaseApiController {
|
|||
@RequestBody(required = false) NamespaceLifecycleRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
Namespace namespace = namespaceGovernanceService.freezeNamespace(
|
||||
slug,
|
||||
userId,
|
||||
request != null ? request.reason() : null,
|
||||
null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
return ok("response.success.updated", NamespaceResponse.from(namespace));
|
||||
return ok("response.success.updated",
|
||||
namespacePortalCommandAppService.freezeNamespace(
|
||||
slug,
|
||||
request,
|
||||
userId,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces/{slug}/unfreeze")
|
||||
public ApiResponse<NamespaceResponse> unfreezeNamespace(@PathVariable String slug,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
Namespace namespace = namespaceGovernanceService.unfreezeNamespace(
|
||||
slug,
|
||||
userId,
|
||||
null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
return ok("response.success.updated", NamespaceResponse.from(namespace));
|
||||
return ok("response.success.updated",
|
||||
namespacePortalCommandAppService.unfreezeNamespace(
|
||||
slug,
|
||||
userId,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces/{slug}/archive")
|
||||
|
|
@ -157,40 +116,31 @@ public class NamespaceController extends BaseApiController {
|
|||
@RequestBody(required = false) NamespaceLifecycleRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
Namespace namespace = namespaceGovernanceService.archiveNamespace(
|
||||
slug,
|
||||
userId,
|
||||
request != null ? request.reason() : null,
|
||||
null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
return ok("response.success.updated", NamespaceResponse.from(namespace));
|
||||
return ok("response.success.updated",
|
||||
namespacePortalCommandAppService.archiveNamespace(
|
||||
slug,
|
||||
request,
|
||||
userId,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces/{slug}/restore")
|
||||
public ApiResponse<NamespaceResponse> restoreNamespace(@PathVariable String slug,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
Namespace namespace = namespaceGovernanceService.restoreNamespace(
|
||||
slug,
|
||||
userId,
|
||||
null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
return ok("response.success.updated", NamespaceResponse.from(namespace));
|
||||
return ok("response.success.updated",
|
||||
namespacePortalCommandAppService.restoreNamespace(
|
||||
slug,
|
||||
userId,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces/{slug}/members")
|
||||
public ApiResponse<PageResponse<MemberResponse>> listMembers(@PathVariable String slug,
|
||||
Pageable pageable,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
namespaceService.assertMember(namespace.getId(), userId);
|
||||
Page<NamespaceMember> members = namespaceMemberService.listMembers(namespace.getId(), pageable);
|
||||
PageResponse<MemberResponse> response = PageResponse.from(members.map(MemberResponse::from));
|
||||
return ok("response.success.read", response);
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.listMembers(slug, pageable, userId));
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces/{slug}/member-candidates")
|
||||
|
|
@ -207,14 +157,8 @@ public class NamespaceController extends BaseApiController {
|
|||
@PathVariable String slug,
|
||||
@Valid @RequestBody MemberRequest request,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
NamespaceMember member = namespaceMemberService.addMember(
|
||||
namespace.getId(),
|
||||
request.userId(),
|
||||
request.role(),
|
||||
userId
|
||||
);
|
||||
return ok("response.success.created", MemberResponse.from(member));
|
||||
return ok("response.success.created",
|
||||
namespacePortalCommandAppService.addMember(slug, request.userId(), request.role(), userId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/namespaces/{slug}/members/{userId}")
|
||||
|
|
@ -222,9 +166,8 @@ public class NamespaceController extends BaseApiController {
|
|||
@PathVariable String slug,
|
||||
@PathVariable("userId") String memberUserId,
|
||||
@RequestAttribute("userId") String operatorUserId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
namespaceMemberService.removeMember(namespace.getId(), memberUserId, operatorUserId);
|
||||
return ok("response.success.deleted", new MessageResponse("Member removed successfully"));
|
||||
return ok("response.success.deleted",
|
||||
namespacePortalCommandAppService.removeMember(slug, memberUserId, operatorUserId));
|
||||
}
|
||||
|
||||
@PutMapping("/namespaces/{slug}/members/{userId}/role")
|
||||
|
|
@ -233,13 +176,7 @@ public class NamespaceController extends BaseApiController {
|
|||
@PathVariable String userId,
|
||||
@Valid @RequestBody UpdateMemberRoleRequest request,
|
||||
@RequestAttribute("userId") String operatorUserId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
NamespaceMember member = namespaceMemberService.updateMemberRole(
|
||||
namespace.getId(),
|
||||
userId,
|
||||
request.role(),
|
||||
operatorUserId
|
||||
);
|
||||
return ok("response.success.updated", MemberResponse.from(member));
|
||||
return ok("response.success.updated",
|
||||
namespacePortalCommandAppService.updateMemberRole(slug, userId, request, operatorUserId));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,14 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
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.review.ReviewService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import com.iflytek.skillhub.dto.AdminSkillActionRequest;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
|
||||
import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
import com.iflytek.skillhub.service.SkillLifecycleAppService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Map;
|
||||
|
|
@ -37,30 +28,12 @@ import org.springframework.web.bind.annotation.RestController;
|
|||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillLifecycleController extends BaseApiController {
|
||||
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillGovernanceService skillGovernanceService;
|
||||
private final ReviewService reviewService;
|
||||
private final SkillPublishService skillPublishService;
|
||||
private final AuditLogService auditLogService;
|
||||
private final SkillSlugResolutionService skillSlugResolutionService;
|
||||
private final SkillLifecycleAppService skillLifecycleAppService;
|
||||
|
||||
public SkillLifecycleController(NamespaceRepository namespaceRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillGovernanceService skillGovernanceService,
|
||||
ReviewService reviewService,
|
||||
SkillPublishService skillPublishService,
|
||||
AuditLogService auditLogService,
|
||||
SkillSlugResolutionService skillSlugResolutionService,
|
||||
public SkillLifecycleController(SkillLifecycleAppService skillLifecycleAppService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillGovernanceService = skillGovernanceService;
|
||||
this.reviewService = reviewService;
|
||||
this.skillPublishService = skillPublishService;
|
||||
this.auditLogService = auditLogService;
|
||||
this.skillSlugResolutionService = skillSlugResolutionService;
|
||||
this.skillLifecycleAppService = skillLifecycleAppService;
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/archive")
|
||||
|
|
@ -70,18 +43,14 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
Skill archived = skillGovernanceService.archiveSkill(
|
||||
skill.getId(),
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent"),
|
||||
request != null ? request.reason() : null
|
||||
);
|
||||
|
||||
return ok("response.success.updated",
|
||||
new SkillLifecycleMutationResponse(archived.getId(), null, "ARCHIVE", archived.getStatus().name()));
|
||||
skillLifecycleAppService.archiveSkill(
|
||||
namespace,
|
||||
slug,
|
||||
request,
|
||||
userId,
|
||||
userNsRoles,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/unarchive")
|
||||
|
|
@ -90,17 +59,13 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
Skill restored = skillGovernanceService.unarchiveSkill(
|
||||
skill.getId(),
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
|
||||
return ok("response.success.updated",
|
||||
new SkillLifecycleMutationResponse(restored.getId(), null, "UNARCHIVE", restored.getStatus().name()));
|
||||
skillLifecycleAppService.unarchiveSkill(
|
||||
namespace,
|
||||
slug,
|
||||
userId,
|
||||
userNsRoles,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{namespace}/{slug}/versions/{version}")
|
||||
|
|
@ -110,20 +75,14 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
|
||||
skillGovernanceService.deleteVersion(
|
||||
skill,
|
||||
skillVersion,
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
|
||||
return ok("response.success.deleted",
|
||||
new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "DELETE_VERSION", version));
|
||||
skillLifecycleAppService.deleteVersion(
|
||||
namespace,
|
||||
slug,
|
||||
version,
|
||||
userId,
|
||||
userNsRoles,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/versions/{version}/withdraw-review")
|
||||
|
|
@ -132,23 +91,13 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@PathVariable String version,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
|
||||
SkillVersion withdrawnVersion = reviewService.withdrawReview(skillVersion.getId(), userId);
|
||||
auditLogService.record(
|
||||
userId,
|
||||
"REVIEW_WITHDRAW",
|
||||
"SKILL_VERSION",
|
||||
skillVersion.getId(),
|
||||
null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent"),
|
||||
"{\"version\":\"" + version.replace("\"", "\\\"") + "\"}"
|
||||
);
|
||||
|
||||
return ok("response.success.updated",
|
||||
new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "WITHDRAW_REVIEW", withdrawnVersion.getStatus().name()));
|
||||
skillLifecycleAppService.withdrawReview(
|
||||
namespace,
|
||||
slug,
|
||||
version,
|
||||
userId,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/versions/{version}/rerelease")
|
||||
|
|
@ -159,44 +108,14 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
|
||||
SkillPublishService.PublishResult result = skillPublishService.rereleasePublishedVersion(
|
||||
skill.getId(),
|
||||
skillVersion.getVersion(),
|
||||
request.targetVersion().trim(),
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of()
|
||||
);
|
||||
auditLogService.record(
|
||||
userId,
|
||||
"RERELEASE_SKILL_VERSION",
|
||||
"SKILL_VERSION",
|
||||
skillVersion.getId(),
|
||||
null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent"),
|
||||
"{\"sourceVersion\":\"" + version.replace("\"", "\\\"")
|
||||
+ "\",\"targetVersion\":\"" + request.targetVersion().trim().replace("\"", "\\\"") + "\"}"
|
||||
);
|
||||
|
||||
return ok("response.success.updated",
|
||||
new SkillLifecycleMutationResponse(result.skillId(), result.version().getId(), "RERELEASE_VERSION", result.version().getStatus().name()));
|
||||
}
|
||||
|
||||
private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) {
|
||||
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
|
||||
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace));
|
||||
return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
}
|
||||
|
||||
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
|
||||
return skillSlugResolutionService.resolve(
|
||||
namespaceId,
|
||||
slug,
|
||||
currentUserId,
|
||||
SkillSlugResolutionService.Preference.CURRENT_USER);
|
||||
skillLifecycleAppService.rereleaseVersion(
|
||||
namespace,
|
||||
slug,
|
||||
version,
|
||||
request,
|
||||
userId,
|
||||
userNsRoles,
|
||||
AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.filter;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
|
|
@ -34,17 +35,20 @@ public class AuthContextFilter extends OncePerRequestFilter {
|
|||
private final ApiResponseFactory apiResponseFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final boolean enforceActiveUserCheck;
|
||||
private final RouteSecurityPolicyRegistry routeSecurityPolicyRegistry;
|
||||
|
||||
public AuthContextFilter(NamespaceMemberRepository namespaceMemberRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
ApiResponseFactory apiResponseFactory,
|
||||
ObjectMapper objectMapper,
|
||||
@Value("${skillhub.auth.enforce-active-user-check:true}") boolean enforceActiveUserCheck) {
|
||||
@Value("${skillhub.auth.enforce-active-user-check:true}") boolean enforceActiveUserCheck,
|
||||
RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) {
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.apiResponseFactory = apiResponseFactory;
|
||||
this.objectMapper = objectMapper;
|
||||
this.enforceActiveUserCheck = enforceActiveUserCheck;
|
||||
this.routeSecurityPolicyRegistry = routeSecurityPolicyRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -52,6 +56,10 @@ public class AuthContextFilter extends OncePerRequestFilter {
|
|||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
if (!routeSecurityPolicyRegistry.shouldProjectRequestContext(request.getRequestURI())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
PlatformPrincipal principal = resolvePrincipal(request);
|
||||
if (principal != null) {
|
||||
if (isInactiveUser(principal.userId())) {
|
||||
|
|
|
|||
|
|
@ -1,157 +0,0 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.entity.Role;
|
||||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
import com.iflytek.skillhub.auth.repository.RoleRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import com.iflytek.skillhub.dto.AdminUserSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
import java.util.Set;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Alternative user-management aggregation service that combines user records
|
||||
* with role bindings for management-oriented views.
|
||||
*/
|
||||
@Service
|
||||
public class AdminUserManagementService {
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final UserRoleBindingRepository userRoleBindingRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
|
||||
public AdminUserManagementService(UserAccountRepository userAccountRepository,
|
||||
UserRoleBindingRepository userRoleBindingRepository,
|
||||
RoleRepository roleRepository) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<AdminUserSummaryResponse> listUsers(String keyword, String status, int page, int size) {
|
||||
UserStatus userStatus = parseStatus(status);
|
||||
Page<UserAccount> users = userAccountRepository.search(normalize(keyword), userStatus, PageRequest.of(page, size));
|
||||
List<AdminUserSummaryResponse> items = users.getContent().stream()
|
||||
.map(this::toSummary)
|
||||
.toList();
|
||||
return PageResponse.from(new PageImpl<>(items, users.getPageable(), users.getTotalElements()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserSummaryResponse updateUserRole(String userId, String roleCode, PlatformPrincipal principal) {
|
||||
UserAccount user = loadUser(userId);
|
||||
if (principal != null
|
||||
&& !principal.platformRoles().contains("SUPER_ADMIN")
|
||||
&& "SUPER_ADMIN".equalsIgnoreCase(roleCode)) {
|
||||
throw new DomainForbiddenException("error.admin.role.assign_super_admin_forbidden");
|
||||
}
|
||||
Role role = roleRepository.findByCode(roleCode)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.role.notFound", roleCode));
|
||||
|
||||
List<UserRoleBinding> existing = userRoleBindingRepository.findByUserId(userId);
|
||||
boolean alreadyAssigned = existing.stream().anyMatch(binding -> binding.getRole().getCode().equals(roleCode));
|
||||
if (!alreadyAssigned) {
|
||||
userRoleBindingRepository.save(new UserRoleBinding(userId, role));
|
||||
}
|
||||
return toSummary(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserSummaryResponse approveUser(String userId) {
|
||||
UserAccount user = loadUser(userId);
|
||||
user.setStatus(UserStatus.ACTIVE);
|
||||
return toSummary(userAccountRepository.save(user));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserSummaryResponse updateUserStatus(String userId, String status) {
|
||||
UserAccount user = loadUser(userId);
|
||||
user.setStatus(parseRequiredStatus(status));
|
||||
return toSummary(userAccountRepository.save(user));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserSummaryResponse disableUser(String userId) {
|
||||
UserAccount user = loadUser(userId);
|
||||
user.setStatus(UserStatus.DISABLED);
|
||||
return toSummary(userAccountRepository.save(user));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserSummaryResponse enableUser(String userId) {
|
||||
UserAccount user = loadUser(userId);
|
||||
user.setStatus(UserStatus.ACTIVE);
|
||||
return toSummary(userAccountRepository.save(user));
|
||||
}
|
||||
|
||||
private UserAccount loadUser(String userId) {
|
||||
return userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.user.notFound", userId));
|
||||
}
|
||||
|
||||
private AdminUserSummaryResponse toSummary(UserAccount user) {
|
||||
Set<String> roles = new LinkedHashSet<>();
|
||||
userRoleBindingRepository.findByUserId(user.getId()).stream()
|
||||
.map(binding -> binding.getRole().getCode())
|
||||
.sorted(Comparator.naturalOrder())
|
||||
.forEach(roles::add);
|
||||
roles = new LinkedHashSet<>(withDefaultUserRole(roles));
|
||||
return new AdminUserSummaryResponse(
|
||||
user.getId(),
|
||||
user.getDisplayName(),
|
||||
user.getEmail(),
|
||||
user.getStatus().name(),
|
||||
List.copyOf(roles),
|
||||
user.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private String normalize(String keyword) {
|
||||
if (keyword == null || keyword.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return keyword.trim();
|
||||
}
|
||||
|
||||
private Set<String> withDefaultUserRole(Set<String> roles) {
|
||||
Set<String> resolvedRoles = new TreeSet<>();
|
||||
if (roles != null) {
|
||||
resolvedRoles.addAll(roles);
|
||||
}
|
||||
if (resolvedRoles.isEmpty()) {
|
||||
resolvedRoles.add("USER");
|
||||
}
|
||||
return Set.copyOf(resolvedRoles);
|
||||
}
|
||||
|
||||
private UserStatus parseStatus(String status) {
|
||||
if (status == null || status.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return parseRequiredStatus(status);
|
||||
}
|
||||
|
||||
private UserStatus parseRequiredStatus(String status) {
|
||||
try {
|
||||
return UserStatus.valueOf(status.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new DomainBadRequestException("error.user.status.invalid", status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* Transport-level audit fields extracted from the current HTTP request.
|
||||
*/
|
||||
public record AuditRequestContext(
|
||||
String clientIp,
|
||||
String userAgent
|
||||
) {
|
||||
public static AuditRequestContext from(HttpServletRequest request) {
|
||||
return new AuditRequestContext(
|
||||
request != null ? request.getRemoteAddr() : null,
|
||||
request != null ? request.getHeader("User-Agent") : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.dto.MemberResponse;
|
||||
import com.iflytek.skillhub.dto.MessageResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceLifecycleRequest;
|
||||
import com.iflytek.skillhub.dto.NamespaceRequest;
|
||||
import com.iflytek.skillhub.dto.NamespaceResponse;
|
||||
import com.iflytek.skillhub.dto.UpdateMemberRoleRequest;
|
||||
import com.iflytek.skillhub.exception.ForbiddenException;
|
||||
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Command-facing namespace application service for portal endpoints.
|
||||
*/
|
||||
@Service
|
||||
public class NamespacePortalCommandAppService {
|
||||
|
||||
private final NamespaceService namespaceService;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceGovernanceService namespaceGovernanceService;
|
||||
private final NamespaceMemberService namespaceMemberService;
|
||||
|
||||
public NamespacePortalCommandAppService(NamespaceService namespaceService,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceGovernanceService namespaceGovernanceService,
|
||||
NamespaceMemberService namespaceMemberService) {
|
||||
this.namespaceService = namespaceService;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceGovernanceService = namespaceGovernanceService;
|
||||
this.namespaceMemberService = namespaceMemberService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public NamespaceResponse createNamespace(NamespaceRequest request, PlatformPrincipal principal) {
|
||||
if (principal == null) {
|
||||
throw new UnauthorizedException("error.auth.required");
|
||||
}
|
||||
if (!canCreateNamespace(principal)) {
|
||||
throw new ForbiddenException("error.namespace.create.platformAdminRequired");
|
||||
}
|
||||
|
||||
Namespace namespace = namespaceService.createNamespace(
|
||||
request.slug(),
|
||||
request.displayName(),
|
||||
request.description(),
|
||||
principal.userId()
|
||||
);
|
||||
return NamespaceResponse.from(namespace);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public NamespaceResponse updateNamespace(String slug, NamespaceRequest request, String userId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
Namespace updated = namespaceService.updateNamespace(
|
||||
namespace.getId(),
|
||||
request.displayName(),
|
||||
request.description(),
|
||||
null,
|
||||
userId
|
||||
);
|
||||
return NamespaceResponse.from(updated);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public NamespaceResponse freezeNamespace(String slug,
|
||||
NamespaceLifecycleRequest request,
|
||||
String userId,
|
||||
AuditRequestContext auditContext) {
|
||||
Namespace namespace = namespaceGovernanceService.freezeNamespace(
|
||||
slug,
|
||||
userId,
|
||||
request != null ? request.reason() : null,
|
||||
null,
|
||||
auditContext.clientIp(),
|
||||
auditContext.userAgent()
|
||||
);
|
||||
return NamespaceResponse.from(namespace);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public NamespaceResponse unfreezeNamespace(String slug, String userId, AuditRequestContext auditContext) {
|
||||
Namespace namespace = namespaceGovernanceService.unfreezeNamespace(
|
||||
slug,
|
||||
userId,
|
||||
null,
|
||||
auditContext.clientIp(),
|
||||
auditContext.userAgent()
|
||||
);
|
||||
return NamespaceResponse.from(namespace);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public NamespaceResponse archiveNamespace(String slug,
|
||||
NamespaceLifecycleRequest request,
|
||||
String userId,
|
||||
AuditRequestContext auditContext) {
|
||||
Namespace namespace = namespaceGovernanceService.archiveNamespace(
|
||||
slug,
|
||||
userId,
|
||||
request != null ? request.reason() : null,
|
||||
null,
|
||||
auditContext.clientIp(),
|
||||
auditContext.userAgent()
|
||||
);
|
||||
return NamespaceResponse.from(namespace);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public NamespaceResponse restoreNamespace(String slug, String userId, AuditRequestContext auditContext) {
|
||||
Namespace namespace = namespaceGovernanceService.restoreNamespace(
|
||||
slug,
|
||||
userId,
|
||||
null,
|
||||
auditContext.clientIp(),
|
||||
auditContext.userAgent()
|
||||
);
|
||||
return NamespaceResponse.from(namespace);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MemberResponse addMember(String slug, String memberUserId, com.iflytek.skillhub.domain.namespace.NamespaceRole role, String operatorUserId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
NamespaceMember member = namespaceMemberService.addMember(
|
||||
namespace.getId(),
|
||||
memberUserId,
|
||||
role,
|
||||
operatorUserId
|
||||
);
|
||||
return MemberResponse.from(member);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MessageResponse removeMember(String slug, String memberUserId, String operatorUserId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
namespaceMemberService.removeMember(namespace.getId(), memberUserId, operatorUserId);
|
||||
return new MessageResponse("Member removed successfully");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MemberResponse updateMemberRole(String slug,
|
||||
String userId,
|
||||
UpdateMemberRoleRequest request,
|
||||
String operatorUserId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
NamespaceMember member = namespaceMemberService.updateMemberRole(
|
||||
namespace.getId(),
|
||||
userId,
|
||||
request.role(),
|
||||
operatorUserId
|
||||
);
|
||||
return MemberResponse.from(member);
|
||||
}
|
||||
|
||||
private boolean canCreateNamespace(PlatformPrincipal principal) {
|
||||
return principal.platformRoles().contains("SKILL_ADMIN")
|
||||
|| principal.platformRoles().contains("SUPER_ADMIN");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.dto.MemberResponse;
|
||||
import com.iflytek.skillhub.dto.MyNamespaceResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Query-facing namespace application service that keeps controller methods
|
||||
* thin while preserving current response contracts.
|
||||
*/
|
||||
@Service
|
||||
public class NamespacePortalQueryAppService {
|
||||
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceService namespaceService;
|
||||
private final NamespaceMemberService namespaceMemberService;
|
||||
private final NamespaceAccessPolicy namespaceAccessPolicy;
|
||||
|
||||
public NamespacePortalQueryAppService(NamespaceRepository namespaceRepository,
|
||||
NamespaceService namespaceService,
|
||||
NamespaceMemberService namespaceMemberService,
|
||||
NamespaceAccessPolicy namespaceAccessPolicy) {
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceService = namespaceService;
|
||||
this.namespaceMemberService = namespaceMemberService;
|
||||
this.namespaceAccessPolicy = namespaceAccessPolicy;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable) {
|
||||
Page<Namespace> namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable);
|
||||
return PageResponse.from(namespaces.map(NamespaceResponse::from));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
|
||||
if (namespaceRoles.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
return namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream()
|
||||
.sorted(Comparator.comparing(Namespace::getSlug))
|
||||
.map(namespace -> MyNamespaceResponse.from(
|
||||
namespace,
|
||||
namespaceRoles.get(namespace.getId()),
|
||||
namespaceAccessPolicy))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public NamespaceResponse getNamespace(String slug, String userId, Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlugForRead(
|
||||
slug,
|
||||
userId,
|
||||
userNamespaceRoles != null ? userNamespaceRoles : Map.of());
|
||||
return NamespaceResponse.from(namespace);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<MemberResponse> listMembers(String slug, Pageable pageable, String userId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
namespaceService.assertMember(namespace.getId(), userId);
|
||||
Page<NamespaceMember> members = namespaceMemberService.listMembers(namespace.getId(), pageable);
|
||||
return PageResponse.from(members.map(MemberResponse::from));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
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.review.ReviewService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import com.iflytek.skillhub.dto.AdminSkillActionRequest;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
|
||||
import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Orchestrates skill lifecycle mutations so controllers only handle transport
|
||||
* concerns and envelope assembly.
|
||||
*/
|
||||
@Service
|
||||
public class SkillLifecycleAppService {
|
||||
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillGovernanceService skillGovernanceService;
|
||||
private final ReviewService reviewService;
|
||||
private final SkillPublishService skillPublishService;
|
||||
private final AuditLogService auditLogService;
|
||||
private final SkillSlugResolutionService skillSlugResolutionService;
|
||||
|
||||
public SkillLifecycleAppService(NamespaceRepository namespaceRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillGovernanceService skillGovernanceService,
|
||||
ReviewService reviewService,
|
||||
SkillPublishService skillPublishService,
|
||||
AuditLogService auditLogService,
|
||||
SkillSlugResolutionService skillSlugResolutionService) {
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillGovernanceService = skillGovernanceService;
|
||||
this.reviewService = reviewService;
|
||||
this.skillPublishService = skillPublishService;
|
||||
this.auditLogService = auditLogService;
|
||||
this.skillSlugResolutionService = skillSlugResolutionService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillLifecycleMutationResponse archiveSkill(String namespace,
|
||||
String slug,
|
||||
AdminSkillActionRequest request,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
AuditRequestContext auditContext) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
Skill archived = skillGovernanceService.archiveSkill(
|
||||
skill.getId(),
|
||||
userId,
|
||||
normalizeRoles(userNamespaceRoles),
|
||||
auditContext.clientIp(),
|
||||
auditContext.userAgent(),
|
||||
request != null ? request.reason() : null
|
||||
);
|
||||
return new SkillLifecycleMutationResponse(archived.getId(), null, "ARCHIVE", archived.getStatus().name());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillLifecycleMutationResponse unarchiveSkill(String namespace,
|
||||
String slug,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
AuditRequestContext auditContext) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
Skill restored = skillGovernanceService.unarchiveSkill(
|
||||
skill.getId(),
|
||||
userId,
|
||||
normalizeRoles(userNamespaceRoles),
|
||||
auditContext.clientIp(),
|
||||
auditContext.userAgent()
|
||||
);
|
||||
return new SkillLifecycleMutationResponse(restored.getId(), null, "UNARCHIVE", restored.getStatus().name());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillLifecycleMutationResponse deleteVersion(String namespace,
|
||||
String slug,
|
||||
String version,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
AuditRequestContext auditContext) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
SkillVersion skillVersion = findVersion(skill.getId(), version);
|
||||
skillGovernanceService.deleteVersion(
|
||||
skill,
|
||||
skillVersion,
|
||||
userId,
|
||||
normalizeRoles(userNamespaceRoles),
|
||||
auditContext.clientIp(),
|
||||
auditContext.userAgent()
|
||||
);
|
||||
return new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "DELETE_VERSION", version);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillLifecycleMutationResponse withdrawReview(String namespace,
|
||||
String slug,
|
||||
String version,
|
||||
String userId,
|
||||
AuditRequestContext auditContext) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
SkillVersion skillVersion = findVersion(skill.getId(), version);
|
||||
SkillVersion withdrawnVersion = reviewService.withdrawReview(skillVersion.getId(), userId);
|
||||
auditLogService.record(
|
||||
userId,
|
||||
"REVIEW_WITHDRAW",
|
||||
"SKILL_VERSION",
|
||||
skillVersion.getId(),
|
||||
null,
|
||||
auditContext.clientIp(),
|
||||
auditContext.userAgent(),
|
||||
"{\"version\":\"" + version.replace("\"", "\\\"") + "\"}"
|
||||
);
|
||||
return new SkillLifecycleMutationResponse(
|
||||
skill.getId(),
|
||||
skillVersion.getId(),
|
||||
"WITHDRAW_REVIEW",
|
||||
withdrawnVersion.getStatus().name()
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillLifecycleMutationResponse rereleaseVersion(String namespace,
|
||||
String slug,
|
||||
String version,
|
||||
SkillVersionRereleaseRequest request,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
AuditRequestContext auditContext) {
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
SkillVersion skillVersion = findVersion(skill.getId(), version);
|
||||
String targetVersion = request.targetVersion().trim();
|
||||
SkillPublishService.PublishResult result = skillPublishService.rereleasePublishedVersion(
|
||||
skill.getId(),
|
||||
skillVersion.getVersion(),
|
||||
targetVersion,
|
||||
userId,
|
||||
normalizeRoles(userNamespaceRoles)
|
||||
);
|
||||
auditLogService.record(
|
||||
userId,
|
||||
"RERELEASE_SKILL_VERSION",
|
||||
"SKILL_VERSION",
|
||||
skillVersion.getId(),
|
||||
null,
|
||||
auditContext.clientIp(),
|
||||
auditContext.userAgent(),
|
||||
"{\"sourceVersion\":\"" + version.replace("\"", "\\\"")
|
||||
+ "\",\"targetVersion\":\"" + targetVersion.replace("\"", "\\\"") + "\"}"
|
||||
);
|
||||
return new SkillLifecycleMutationResponse(
|
||||
result.skillId(),
|
||||
result.version().getId(),
|
||||
"RERELEASE_VERSION",
|
||||
result.version().getStatus().name()
|
||||
);
|
||||
}
|
||||
|
||||
private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) {
|
||||
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
|
||||
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace));
|
||||
return skillSlugResolutionService.resolve(
|
||||
namespace.getId(),
|
||||
skillSlug,
|
||||
currentUserId,
|
||||
SkillSlugResolutionService.Preference.CURRENT_USER
|
||||
);
|
||||
}
|
||||
|
||||
private SkillVersion findVersion(Long skillId, String version) {
|
||||
return skillVersionRepository.findBySkillIdAndVersion(skillId, version)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
|
||||
}
|
||||
|
||||
private Map<Long, NamespaceRole> normalizeRoles(Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
return userNamespaceRoles != null ? userNamespaceRoles : Map.of();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,9 @@ import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
|||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
|
|
@ -54,6 +56,9 @@ class NamespacePortalControllerTest {
|
|||
@MockBean
|
||||
private NamespaceGovernanceService namespaceGovernanceService;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberService namespaceMemberService;
|
||||
|
||||
@MockBean
|
||||
private com.iflytek.skillhub.domain.namespace.NamespaceRepository namespaceRepository;
|
||||
|
||||
|
|
@ -116,6 +121,33 @@ class NamespacePortalControllerTest {
|
|||
.andExpect(jsonPath("$.data.status").value("ARCHIVED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNamespace_returnsUpdatedNamespace() throws Exception {
|
||||
Namespace existing = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
Namespace updated = new Namespace("team-a", "Team A+", "owner-1");
|
||||
setField(updated, "id", 1L);
|
||||
updated.setStatus(NamespaceStatus.ACTIVE);
|
||||
updated.setType(NamespaceType.TEAM);
|
||||
updated.setDescription("Updated description");
|
||||
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(existing);
|
||||
given(namespaceService.updateNamespace(1L, "Team A+", "Updated description", null, "owner-1"))
|
||||
.willReturn(updated);
|
||||
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/api/v1/namespaces/team-a")
|
||||
.with(csrf())
|
||||
.with(auth("owner-1"))
|
||||
.requestAttr("userId", "owner-1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"slug":"team-a","displayName":"Team A+","description":"Updated description"}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.slug").value("team-a"))
|
||||
.andExpect(jsonPath("$.data.displayName").value("Team A+"))
|
||||
.andExpect(jsonPath("$.data.description").value("Updated description"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMembers_forNonMember_returns403() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
|
|
@ -152,6 +184,64 @@ class NamespacePortalControllerTest {
|
|||
.andExpect(jsonPath("$.data[0].displayName").value("alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addMember_returnsCreatedMember() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.ADMIN);
|
||||
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
|
||||
given(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.ADMIN, "owner-1"))
|
||||
.willReturn(member);
|
||||
|
||||
mockMvc.perform(post("/api/v1/namespaces/team-a/members")
|
||||
.with(csrf())
|
||||
.with(auth("owner-1"))
|
||||
.requestAttr("userId", "owner-1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"userId":"user-2","role":"ADMIN"}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userId").value("user-2"))
|
||||
.andExpect(jsonPath("$.data.role").value("ADMIN"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeMember_returnsSuccessMessage() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
|
||||
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/api/v1/namespaces/team-a/members/user-2")
|
||||
.with(csrf())
|
||||
.with(auth("owner-1"))
|
||||
.requestAttr("userId", "owner-1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.message").value("Member removed successfully"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateMemberRole_returnsUpdatedMember() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.OWNER);
|
||||
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
|
||||
given(namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.OWNER, "owner-1"))
|
||||
.willReturn(member);
|
||||
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/api/v1/namespaces/team-a/members/user-2/role")
|
||||
.with(csrf())
|
||||
.with(auth("owner-1"))
|
||||
.requestAttr("userId", "owner-1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"role":"OWNER"}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userId").value("user-2"))
|
||||
.andExpect(jsonPath("$.data.role").value("OWNER"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createNamespace_requiresPlatformAdminRole() throws Exception {
|
||||
mockMvc.perform(post("/api/v1/namespaces")
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -26,10 +26,12 @@ import java.util.Set;
|
|||
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.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringBootTest
|
||||
|
|
@ -172,4 +174,73 @@ class UserManagementControllerTest {
|
|||
.andExpect(jsonPath("$.data.userId").value("user-123"))
|
||||
.andExpect(jsonPath("$.data.status").value("DISABLED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void approveUser_delegatesToActiveStatusMutation() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN")
|
||||
);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN"))
|
||||
);
|
||||
|
||||
when(adminUserAppService.updateUserStatus("user-123", "ACTIVE"))
|
||||
.thenReturn(new AdminUserMutationResponse("user-123", null, "ACTIVE"));
|
||||
|
||||
mockMvc.perform(post("/api/v1/admin/users/user-123/approve")
|
||||
.with(authentication(auth))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userId").value("user-123"))
|
||||
.andExpect(jsonPath("$.data.status").value("ACTIVE"));
|
||||
|
||||
verify(adminUserAppService).updateUserStatus("user-123", "ACTIVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableUser_delegatesToDisabledStatusMutation() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN")
|
||||
);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN"))
|
||||
);
|
||||
|
||||
when(adminUserAppService.updateUserStatus("user-123", "DISABLED"))
|
||||
.thenReturn(new AdminUserMutationResponse("user-123", null, "DISABLED"));
|
||||
|
||||
mockMvc.perform(post("/api/v1/admin/users/user-123/disable")
|
||||
.with(authentication(auth))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userId").value("user-123"))
|
||||
.andExpect(jsonPath("$.data.status").value("DISABLED"));
|
||||
|
||||
verify(adminUserAppService).updateUserStatus("user-123", "DISABLED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void enableUser_delegatesToActiveStatusMutation() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN")
|
||||
);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN"))
|
||||
);
|
||||
|
||||
when(adminUserAppService.updateUserStatus("user-123", "ACTIVE"))
|
||||
.thenReturn(new AdminUserMutationResponse("user-123", null, "ACTIVE"));
|
||||
|
||||
mockMvc.perform(post("/api/v1/admin/users/user-123/enable")
|
||||
.with(authentication(auth))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userId").value("user-123"))
|
||||
.andExpect(jsonPath("$.data.status").value("ACTIVE"));
|
||||
|
||||
verify(adminUserAppService).updateUserStatus("user-123", "ACTIVE");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.anyMap;
|
|||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.nullable;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
|
|
@ -229,6 +230,78 @@ class SkillLifecycleControllerTest {
|
|||
.andExpect(jsonPath("$.data.status").value("PUBLISHED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void archiveSkill_acceptsAtPrefixedNamespaceSlug() throws Exception {
|
||||
Namespace namespace = new Namespace("global", "Global", "owner");
|
||||
setNamespaceId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC);
|
||||
setSkillId(skill, 1L);
|
||||
|
||||
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
|
||||
given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER))
|
||||
.willReturn(skill);
|
||||
given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup")))
|
||||
.willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED));
|
||||
|
||||
mockMvc.perform(post("/api/web/skills/@global/demo-skill/archive")
|
||||
.requestAttr("userId", "usr_1")
|
||||
.requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"cleanup\"}")
|
||||
.with(user("usr_1"))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.skillId").value(1))
|
||||
.andExpect(jsonPath("$.data.action").value("ARCHIVE"))
|
||||
.andExpect(jsonPath("$.data.status").value("ARCHIVED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rereleaseVersion_trimsTargetVersionBeforeDelegating() throws Exception {
|
||||
Namespace namespace = new Namespace("global", "Global", "owner");
|
||||
setNamespaceId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC);
|
||||
setSkillId(skill, 1L);
|
||||
SkillVersion newVersion = new SkillVersion(1L, "1.2.4", "owner");
|
||||
setSkillVersionId(newVersion, 3L);
|
||||
newVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
|
||||
given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER))
|
||||
.willReturn(skill);
|
||||
SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner");
|
||||
setSkillVersionId(sourceVersion, 2L);
|
||||
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.3")).willReturn(java.util.Optional.of(sourceVersion));
|
||||
given(skillPublishService.rereleasePublishedVersion(
|
||||
eq(1L),
|
||||
eq("1.2.3"),
|
||||
eq("1.2.4"),
|
||||
eq("usr_1"),
|
||||
anyMap()))
|
||||
.willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion));
|
||||
|
||||
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease")
|
||||
.requestAttr("userId", "usr_1")
|
||||
.requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetVersion\":\" 1.2.4 \"}")
|
||||
.with(user("usr_1"))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.versionId").value(3))
|
||||
.andExpect(jsonPath("$.data.action").value("RERELEASE_VERSION"));
|
||||
|
||||
verify(skillPublishService).rereleasePublishedVersion(
|
||||
eq(1L),
|
||||
eq("1.2.3"),
|
||||
eq("1.2.4"),
|
||||
eq("usr_1"),
|
||||
anyMap());
|
||||
}
|
||||
|
||||
private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) {
|
||||
skill.setStatus(status);
|
||||
return skill;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.iflytek.skillhub.filter;
|
|||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
|
|
@ -32,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
|||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.same;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
|
|
@ -51,7 +53,8 @@ class AuthContextFilterTest {
|
|||
userAccountRepository,
|
||||
apiResponseFactory,
|
||||
new ObjectMapper().registerModule(new JavaTimeModule()),
|
||||
true
|
||||
true,
|
||||
new RouteSecurityPolicyRegistry()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +70,7 @@ class AuthContextFilterTest {
|
|||
user.setStatus(UserStatus.DISABLED);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setRequestURI("/api/v1/auth/me");
|
||||
MockHttpSession session = (MockHttpSession) request.getSession(true);
|
||||
session.setAttribute("platformPrincipal", principal);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
|
|
@ -95,6 +99,7 @@ class AuthContextFilterTest {
|
|||
NamespaceMember member = new NamespaceMember(9L, "user-2", NamespaceRole.ADMIN);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setRequestURI("/api/v1/auth/me");
|
||||
request.getSession(true).setAttribute("platformPrincipal", principal);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, List.of())
|
||||
|
|
@ -112,4 +117,20 @@ class AuthContextFilterTest {
|
|||
assertEquals(NamespaceRole.ADMIN, ((java.util.Map<Long, NamespaceRole>) request.getAttribute("userNsRoles")).get(9L));
|
||||
verify(filterChain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousRequest_shouldPassThroughWithoutLoadingUserContext() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setRequestURI("/assets/app.js");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain filterChain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, filterChain);
|
||||
|
||||
assertNull(request.getAttribute("userId"));
|
||||
assertNull(request.getAttribute("userNsRoles"));
|
||||
verify(filterChain).doFilter(same(request), same(response));
|
||||
verify(userAccountRepository, never()).findById(org.mockito.ArgumentMatchers.anyString());
|
||||
verify(namespaceMemberRepository, never()).findByUserId(org.mockito.ArgumentMatchers.anyString());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.dto.NamespaceLifecycleRequest;
|
||||
import com.iflytek.skillhub.dto.NamespaceRequest;
|
||||
import com.iflytek.skillhub.exception.ForbiddenException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
class NamespacePortalCommandAppServiceTest {
|
||||
|
||||
private final NamespaceService namespaceService = mock(NamespaceService.class);
|
||||
private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class);
|
||||
private final NamespaceGovernanceService namespaceGovernanceService = mock(NamespaceGovernanceService.class);
|
||||
private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class);
|
||||
private final NamespacePortalCommandAppService service = new NamespacePortalCommandAppService(
|
||||
namespaceService,
|
||||
namespaceRepository,
|
||||
namespaceGovernanceService,
|
||||
namespaceMemberService
|
||||
);
|
||||
|
||||
@Test
|
||||
void createNamespace_requiresPlatformAdminRole() {
|
||||
NamespaceRequest request = new NamespaceRequest("team-alpha", "Team Alpha", null);
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-1", "user-1", "user-1@example.com", "", "github", Set.of("USER")
|
||||
);
|
||||
|
||||
assertThrows(ForbiddenException.class, () -> service.createNamespace(request, principal));
|
||||
}
|
||||
|
||||
@Test
|
||||
void freezeNamespace_mapsAuditContextAndReturnsResponse() {
|
||||
Namespace namespace = namespace(7L, "team-alpha");
|
||||
namespace.setStatus(NamespaceStatus.FROZEN);
|
||||
when(namespaceGovernanceService.freezeNamespace("team-alpha", "owner-1", "cleanup", null, "127.0.0.1", "JUnit"))
|
||||
.thenReturn(namespace);
|
||||
|
||||
var response = service.freezeNamespace(
|
||||
"team-alpha",
|
||||
new NamespaceLifecycleRequest("cleanup"),
|
||||
"owner-1",
|
||||
new AuditRequestContext("127.0.0.1", "JUnit")
|
||||
);
|
||||
|
||||
assertThat(response.id()).isEqualTo(7L);
|
||||
assertThat(response.slug()).isEqualTo("team-alpha");
|
||||
assertThat(response.status()).isEqualTo(NamespaceStatus.FROZEN);
|
||||
verify(namespaceGovernanceService).freezeNamespace("team-alpha", "owner-1", "cleanup", null, "127.0.0.1", "JUnit");
|
||||
}
|
||||
|
||||
private Namespace namespace(Long id, String slug) {
|
||||
Namespace namespace = new Namespace(slug, "Team Alpha", "owner-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", id);
|
||||
namespace.setType(NamespaceType.TEAM);
|
||||
return namespace;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
class NamespacePortalQueryAppServiceTest {
|
||||
|
||||
private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class);
|
||||
private final NamespaceService namespaceService = mock(NamespaceService.class);
|
||||
private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class);
|
||||
private final NamespaceAccessPolicy namespaceAccessPolicy = mock(NamespaceAccessPolicy.class);
|
||||
private final NamespacePortalQueryAppService service = new NamespacePortalQueryAppService(
|
||||
namespaceRepository,
|
||||
namespaceService,
|
||||
namespaceMemberService,
|
||||
namespaceAccessPolicy
|
||||
);
|
||||
|
||||
@Test
|
||||
void listMyNamespaces_sortsBySlugAndProjectsRoleCapabilities() {
|
||||
Namespace zeta = namespace(2L, "zeta");
|
||||
Namespace alpha = namespace(1L, "alpha");
|
||||
when(namespaceRepository.findByIdIn(anyList())).thenReturn(List.of(zeta, alpha));
|
||||
when(namespaceAccessPolicy.isImmutable(alpha)).thenReturn(false);
|
||||
when(namespaceAccessPolicy.canFreeze(alpha, NamespaceRole.OWNER)).thenReturn(true);
|
||||
when(namespaceAccessPolicy.canUnfreeze(alpha, NamespaceRole.OWNER)).thenReturn(false);
|
||||
when(namespaceAccessPolicy.canArchive(alpha, NamespaceRole.OWNER)).thenReturn(true);
|
||||
when(namespaceAccessPolicy.canRestore(alpha, NamespaceRole.OWNER)).thenReturn(false);
|
||||
when(namespaceAccessPolicy.isImmutable(zeta)).thenReturn(false);
|
||||
when(namespaceAccessPolicy.canFreeze(zeta, NamespaceRole.ADMIN)).thenReturn(true);
|
||||
when(namespaceAccessPolicy.canUnfreeze(zeta, NamespaceRole.ADMIN)).thenReturn(false);
|
||||
when(namespaceAccessPolicy.canArchive(zeta, NamespaceRole.ADMIN)).thenReturn(true);
|
||||
when(namespaceAccessPolicy.canRestore(zeta, NamespaceRole.ADMIN)).thenReturn(false);
|
||||
|
||||
var response = service.listMyNamespaces(Map.of(
|
||||
2L, NamespaceRole.ADMIN,
|
||||
1L, NamespaceRole.OWNER
|
||||
));
|
||||
|
||||
assertThat(response).hasSize(2);
|
||||
assertThat(response.get(0).slug()).isEqualTo("alpha");
|
||||
assertThat(response.get(0).currentUserRole()).isEqualTo(NamespaceRole.OWNER);
|
||||
assertThat(response.get(1).slug()).isEqualTo("zeta");
|
||||
assertThat(response.get(1).currentUserRole()).isEqualTo(NamespaceRole.ADMIN);
|
||||
}
|
||||
|
||||
private Namespace namespace(Long id, String slug) {
|
||||
Namespace namespace = new Namespace(slug, slug, "owner-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", id);
|
||||
namespace.setStatus(NamespaceStatus.ACTIVE);
|
||||
namespace.setType(NamespaceType.TEAM);
|
||||
return namespace;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.nullable;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
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.review.ReviewService;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import com.iflytek.skillhub.dto.AdminSkillActionRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
class SkillLifecycleAppServiceTest {
|
||||
|
||||
private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class);
|
||||
private final SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class);
|
||||
private final SkillGovernanceService skillGovernanceService = mock(SkillGovernanceService.class);
|
||||
private final ReviewService reviewService = mock(ReviewService.class);
|
||||
private final SkillPublishService skillPublishService = mock(SkillPublishService.class);
|
||||
private final AuditLogService auditLogService = mock(AuditLogService.class);
|
||||
private final SkillSlugResolutionService skillSlugResolutionService = mock(SkillSlugResolutionService.class);
|
||||
private final SkillLifecycleAppService service = new SkillLifecycleAppService(
|
||||
namespaceRepository,
|
||||
skillVersionRepository,
|
||||
skillGovernanceService,
|
||||
reviewService,
|
||||
skillPublishService,
|
||||
auditLogService,
|
||||
skillSlugResolutionService
|
||||
);
|
||||
|
||||
@Test
|
||||
void archiveSkill_resolvesNamespaceAndDelegatesLifecycleMutation() {
|
||||
Namespace namespace = new Namespace("global", "Global", "owner-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", 7L);
|
||||
Skill skill = new Skill(7L, "demo-skill", "owner-1", SkillVisibility.PUBLIC);
|
||||
ReflectionTestUtils.setField(skill, "id", 11L);
|
||||
skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED);
|
||||
|
||||
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
|
||||
when(skillSlugResolutionService.resolve(7L, "demo-skill", "owner-1", SkillSlugResolutionService.Preference.CURRENT_USER))
|
||||
.thenReturn(skill);
|
||||
when(skillGovernanceService.archiveSkill(eq(11L), eq("owner-1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup")))
|
||||
.thenReturn(skill);
|
||||
|
||||
var response = service.archiveSkill(
|
||||
"global",
|
||||
"demo-skill",
|
||||
new AdminSkillActionRequest("cleanup"),
|
||||
"owner-1",
|
||||
Map.of(7L, NamespaceRole.OWNER),
|
||||
new AuditRequestContext("127.0.0.1", "JUnit")
|
||||
);
|
||||
|
||||
assertThat(response.skillId()).isEqualTo(11L);
|
||||
assertThat(response.action()).isEqualTo("ARCHIVE");
|
||||
assertThat(response.status()).isEqualTo("ARCHIVED");
|
||||
verify(skillGovernanceService).archiveSkill(11L, "owner-1", Map.of(7L, NamespaceRole.OWNER), "127.0.0.1", "JUnit", "cleanup");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,17 +5,18 @@ import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler;
|
|||
import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
|
||||
import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver;
|
||||
import com.iflytek.skillhub.auth.mock.MockAuthFilter;
|
||||
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
|
|
@ -58,6 +59,7 @@ public class SecurityConfig {
|
|||
private final AuthenticationEntryPoint apiAuthenticationEntryPoint;
|
||||
private final AccessDeniedHandler apiAccessDeniedHandler;
|
||||
private final ObjectProvider<MockAuthFilter> mockAuthFilterProvider;
|
||||
private final RouteSecurityPolicyRegistry routeSecurityPolicyRegistry;
|
||||
|
||||
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
|
||||
SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver,
|
||||
|
|
@ -67,7 +69,8 @@ public class SecurityConfig {
|
|||
ApiTokenScopeFilter apiTokenScopeFilter,
|
||||
AuthenticationEntryPoint apiAuthenticationEntryPoint,
|
||||
AccessDeniedHandler apiAccessDeniedHandler,
|
||||
ObjectProvider<MockAuthFilter> mockAuthFilterProvider) {
|
||||
ObjectProvider<MockAuthFilter> mockAuthFilterProvider,
|
||||
RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) {
|
||||
this.customOAuth2UserService = customOAuth2UserService;
|
||||
this.authorizationRequestResolver = authorizationRequestResolver;
|
||||
this.successHandler = successHandler;
|
||||
|
|
@ -77,6 +80,7 @@ public class SecurityConfig {
|
|||
this.apiAuthenticationEntryPoint = apiAuthenticationEntryPoint;
|
||||
this.apiAccessDeniedHandler = apiAccessDeniedHandler;
|
||||
this.mockAuthFilterProvider = mockAuthFilterProvider;
|
||||
this.routeSecurityPolicyRegistry = routeSecurityPolicyRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -93,15 +97,7 @@ public class SecurityConfig {
|
|||
RequestMatcher csrfIgnoreMatcher = request -> {
|
||||
String path = request.getRequestURI();
|
||||
String authorization = request.getHeader("Authorization");
|
||||
if (authorization != null && authorization.startsWith("Bearer ")) {
|
||||
return true;
|
||||
}
|
||||
if (path == null) {
|
||||
return false;
|
||||
}
|
||||
return path.startsWith("/api/")
|
||||
|| path.equals("/api/v1/publish")
|
||||
|| path.startsWith("/api/v1/auth/device/");
|
||||
return routeSecurityPolicyRegistry.shouldIgnoreCsrf(path, authorization);
|
||||
};
|
||||
|
||||
http
|
||||
|
|
@ -110,75 +106,10 @@ public class SecurityConfig {
|
|||
.csrfTokenRequestHandler(csrfHandler)
|
||||
.ignoringRequestMatchers(csrfIgnoreMatcher)
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
"/api/v1/health",
|
||||
"/api/v1/search",
|
||||
"/api/v1/resolve/**",
|
||||
"/api/v1/download/**",
|
||||
"/api/v1/auth/providers",
|
||||
"/api/v1/auth/methods",
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/auth/session/bootstrap",
|
||||
"/api/v1/auth/direct/login",
|
||||
"/api/v1/auth/local/**",
|
||||
"/api/v1/auth/device/**",
|
||||
"/api/v1/check",
|
||||
"/actuator/health",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/.well-known/**",
|
||||
"/api/v1/search",
|
||||
"/api/v1/resolve/**",
|
||||
"/api/v1/download/**"
|
||||
).permitAll()
|
||||
.requestMatchers("/actuator/prometheus").hasAnyRole("SUPER_ADMIN", "AUDITOR")
|
||||
.requestMatchers(
|
||||
HttpMethod.GET,
|
||||
"/api/v1/skills/*/star",
|
||||
"/api/v1/skills/*/rating",
|
||||
"/api/web/skills/*/star",
|
||||
"/api/web/skills/*/rating"
|
||||
).authenticated()
|
||||
.requestMatchers(
|
||||
HttpMethod.GET,
|
||||
"/api/v1/skills",
|
||||
"/api/v1/skills/*/*",
|
||||
"/api/v1/skills/*/*/versions",
|
||||
"/api/v1/skills/*/*/versions/*",
|
||||
"/api/v1/skills/*/*/download",
|
||||
"/api/v1/skills/*/*/versions/*/download",
|
||||
"/api/v1/skills/*/*/versions/*/files",
|
||||
"/api/v1/skills/*/*/versions/*/file",
|
||||
"/api/v1/skills/*/*/resolve",
|
||||
"/api/v1/skills/*/*/tags",
|
||||
"/api/v1/skills/*/*/tags/*/download",
|
||||
"/api/v1/skills/*/*/tags/*/files",
|
||||
"/api/v1/skills/*/*/tags/*/file",
|
||||
"/api/web/skills",
|
||||
"/api/web/skills/*/*",
|
||||
"/api/web/skills/*/*/versions",
|
||||
"/api/web/skills/*/*/versions/*",
|
||||
"/api/web/skills/*/*/download",
|
||||
"/api/web/skills/*/*/versions/*/download",
|
||||
"/api/web/skills/*/*/versions/*/files",
|
||||
"/api/web/skills/*/*/versions/*/file",
|
||||
"/api/web/skills/*/*/resolve",
|
||||
"/api/web/skills/*/*/tags",
|
||||
"/api/web/skills/*/*/tags/*/download",
|
||||
"/api/web/skills/*/*/tags/*/files",
|
||||
"/api/web/skills/*/*/tags/*/file"
|
||||
).permitAll()
|
||||
.requestMatchers(
|
||||
HttpMethod.GET,
|
||||
"/api/v1/namespaces",
|
||||
"/api/v1/namespaces/*",
|
||||
"/api/web/namespaces",
|
||||
"/api/web/namespaces/*"
|
||||
).permitAll()
|
||||
.requestMatchers("/api/v1/admin/**").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.authorizeHttpRequests(auth -> {
|
||||
configureRoutePolicies(auth);
|
||||
auth.anyRequest().authenticated();
|
||||
})
|
||||
.oauth2Login(oauth2 -> oauth2
|
||||
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))
|
||||
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
|
||||
|
|
@ -229,4 +160,14 @@ public class SecurityConfig {
|
|||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder(12);
|
||||
}
|
||||
|
||||
private void configureRoutePolicies(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry auth) {
|
||||
for (RouteSecurityPolicyRegistry.RouteAuthorizationPolicy policy : routeSecurityPolicyRegistry.authorizationPolicies()) {
|
||||
switch (policy.accessLevel()) {
|
||||
case PERMIT_ALL -> auth.requestMatchers(policy.toRequestMatcher()).permitAll();
|
||||
case AUTHENTICATED -> auth.requestMatchers(policy.toRequestMatcher()).authenticated();
|
||||
case ROLE_PROTECTED -> auth.requestMatchers(policy.toRequestMatcher()).hasAnyRole(policy.roles());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
package com.iflytek.skillhub.auth.policy;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
|
||||
/**
|
||||
* Authoritative route-policy catalog shared by web security configuration,
|
||||
* API-token scope checks, and request-context projection.
|
||||
*/
|
||||
@Component
|
||||
public class RouteSecurityPolicyRegistry {
|
||||
|
||||
private static final List<RouteAuthorizationPolicy> AUTHORIZATION_POLICIES = List.of(
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/health"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/search"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/resolve/**"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/download/**"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/providers"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/methods"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/me"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/session/bootstrap"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/direct/login"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/local/**"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/device/**"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/check"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/actuator/health"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/v3/api-docs/**"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/swagger-ui/**"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/.well-known/**"),
|
||||
RouteAuthorizationPolicy.roles(null, "/actuator/prometheus", "SUPER_ADMIN", "AUDITOR"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/*/star"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/*/rating"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/*/star"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/*/rating"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions/*"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/download"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions/*/download"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions/*/files"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions/*/file"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/resolve"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/download"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/files"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/file"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions/*"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/download"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions/*/download"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions/*/files"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions/*/file"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/resolve"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/download"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/files"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/file"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces/*"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces/*"),
|
||||
RouteAuthorizationPolicy.authenticated(null, "/api/v1/admin/**")
|
||||
);
|
||||
|
||||
private static final List<ApiTokenPolicy> API_TOKEN_POLICIES = List.of(
|
||||
ApiTokenPolicy.allow(null, "/api/v1/health"),
|
||||
ApiTokenPolicy.allow(null, "/api/v1/auth/providers"),
|
||||
ApiTokenPolicy.allow(null, "/api/v1/auth/me"),
|
||||
ApiTokenPolicy.allow(null, "/api/v1/auth/device/**"),
|
||||
ApiTokenPolicy.allow(null, "/api/v1/check"),
|
||||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/whoami"),
|
||||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/search"),
|
||||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/skills"),
|
||||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/skills/**"),
|
||||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills"),
|
||||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/skills/**"),
|
||||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces"),
|
||||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*"),
|
||||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces"),
|
||||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*"),
|
||||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resolve/**"),
|
||||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/download"),
|
||||
ApiTokenPolicy.allow(null, "/.well-known/**"),
|
||||
ApiTokenPolicy.allow(null, "/actuator/health"),
|
||||
ApiTokenPolicy.allow(null, "/v3/api-docs/**"),
|
||||
ApiTokenPolicy.allow(null, "/swagger-ui/**"),
|
||||
ApiTokenPolicy.require(null, "/api/v1/tokens", "token:manage"),
|
||||
ApiTokenPolicy.require(null, "/api/v1/tokens/**", "token:manage"),
|
||||
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills", "skill:publish"),
|
||||
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/publish", "skill:publish"),
|
||||
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/publish", "skill:publish"),
|
||||
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/publish", "skill:publish")
|
||||
);
|
||||
|
||||
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||
|
||||
public List<RouteAuthorizationPolicy> authorizationPolicies() {
|
||||
return AUTHORIZATION_POLICIES;
|
||||
}
|
||||
|
||||
public ApiTokenAuthorizationDecision authorizeApiToken(String method, String path, Set<String> tokenScopes) {
|
||||
if (!isApiPath(path)) {
|
||||
return ApiTokenAuthorizationDecision.allow();
|
||||
}
|
||||
|
||||
for (ApiTokenPolicy policy : API_TOKEN_POLICIES) {
|
||||
if (!policy.matches(method, path, pathMatcher)) {
|
||||
continue;
|
||||
}
|
||||
if (policy.requiredScope() == null || tokenScopes.contains(policy.requiredScope())) {
|
||||
return ApiTokenAuthorizationDecision.allow();
|
||||
}
|
||||
return ApiTokenAuthorizationDecision.missingScope(policy.requiredScope());
|
||||
}
|
||||
|
||||
return ApiTokenAuthorizationDecision.unsupported(path);
|
||||
}
|
||||
|
||||
public boolean shouldIgnoreCsrf(String path, String authorizationHeader) {
|
||||
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
|
||||
return true;
|
||||
}
|
||||
if (path == null) {
|
||||
return false;
|
||||
}
|
||||
return path.startsWith("/api/")
|
||||
|| path.equals("/api/v1/publish")
|
||||
|| path.startsWith("/api/v1/auth/device/");
|
||||
}
|
||||
|
||||
public boolean shouldProjectRequestContext(String path) {
|
||||
return path != null && (path.startsWith("/api/v1/")
|
||||
|| path.startsWith("/api/web/")
|
||||
|| path.startsWith("/api/"));
|
||||
}
|
||||
|
||||
private boolean isApiPath(String path) {
|
||||
return shouldProjectRequestContext(path);
|
||||
}
|
||||
|
||||
public record ApiTokenAuthorizationDecision(boolean allowed, String requiredScope, String message) {
|
||||
public static ApiTokenAuthorizationDecision allow() {
|
||||
return new ApiTokenAuthorizationDecision(true, null, null);
|
||||
}
|
||||
|
||||
public static ApiTokenAuthorizationDecision missingScope(String requiredScope) {
|
||||
return new ApiTokenAuthorizationDecision(false, requiredScope, "Missing API token scope: " + requiredScope);
|
||||
}
|
||||
|
||||
public static ApiTokenAuthorizationDecision unsupported(String path) {
|
||||
return new ApiTokenAuthorizationDecision(false, null, "API token cannot access endpoint: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
public enum AccessLevel {
|
||||
PERMIT_ALL,
|
||||
AUTHENTICATED,
|
||||
ROLE_PROTECTED
|
||||
}
|
||||
|
||||
public record RouteAuthorizationPolicy(HttpMethod method, String pattern, AccessLevel accessLevel, String[] roles) {
|
||||
public static RouteAuthorizationPolicy permitAll(HttpMethod method, String pattern) {
|
||||
return new RouteAuthorizationPolicy(method, pattern, AccessLevel.PERMIT_ALL, new String[0]);
|
||||
}
|
||||
|
||||
public static RouteAuthorizationPolicy authenticated(HttpMethod method, String pattern) {
|
||||
return new RouteAuthorizationPolicy(method, pattern, AccessLevel.AUTHENTICATED, new String[0]);
|
||||
}
|
||||
|
||||
public static RouteAuthorizationPolicy roles(HttpMethod method, String pattern, String... roles) {
|
||||
return new RouteAuthorizationPolicy(method, pattern, AccessLevel.ROLE_PROTECTED, roles);
|
||||
}
|
||||
|
||||
public RequestMatcher toRequestMatcher() {
|
||||
return method == null
|
||||
? new AntPathRequestMatcher(pattern)
|
||||
: new AntPathRequestMatcher(pattern, method.name());
|
||||
}
|
||||
}
|
||||
|
||||
private record ApiTokenPolicy(HttpMethod method, String pattern, String requiredScope) {
|
||||
static ApiTokenPolicy allow(HttpMethod method, String pattern) {
|
||||
return new ApiTokenPolicy(method, pattern, null);
|
||||
}
|
||||
|
||||
static ApiTokenPolicy require(HttpMethod method, String pattern, String requiredScope) {
|
||||
return new ApiTokenPolicy(method, pattern, requiredScope);
|
||||
}
|
||||
|
||||
boolean matches(String requestMethod, String requestPath, AntPathMatcher matcher) {
|
||||
if (method != null && (requestMethod == null || !method.name().equalsIgnoreCase(requestMethod))) {
|
||||
return false;
|
||||
}
|
||||
return matcher.match(pattern, requestPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@ package com.iflytek.skillhub.auth.token;
|
|||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
|
|
@ -19,47 +19,13 @@ public class ApiTokenScopeService {
|
|||
private static final TypeReference<List<String>> STRING_LIST = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private static final List<ScopeRule> UNSCOPED_ALLOWED_RULES = List.of(
|
||||
ScopeRule.allow(null, "/api/v1/health"),
|
||||
ScopeRule.allow(null, "/api/v1/auth/providers"),
|
||||
ScopeRule.allow(null, "/api/v1/auth/me"),
|
||||
ScopeRule.allow(null, "/api/v1/auth/device/**"),
|
||||
ScopeRule.allow(null, "/api/v1/check"),
|
||||
ScopeRule.allow("GET", "/api/v1/whoami"),
|
||||
ScopeRule.allow("GET", "/api/v1/search"),
|
||||
ScopeRule.allow("GET", "/api/v1/skills"),
|
||||
ScopeRule.allow("GET", "/api/v1/skills/**"),
|
||||
ScopeRule.allow("GET", "/api/web/skills"),
|
||||
ScopeRule.allow("GET", "/api/web/skills/**"),
|
||||
ScopeRule.allow("GET", "/api/v1/namespaces"),
|
||||
ScopeRule.allow("GET", "/api/v1/namespaces/*"),
|
||||
ScopeRule.allow("GET", "/api/web/namespaces"),
|
||||
ScopeRule.allow("GET", "/api/web/namespaces/*"),
|
||||
ScopeRule.allow("GET", "/api/v1/search"),
|
||||
ScopeRule.allow("GET", "/api/v1/resolve/**"),
|
||||
ScopeRule.allow("GET", "/api/v1/whoami"),
|
||||
ScopeRule.allow("GET", "/api/v1/download"),
|
||||
ScopeRule.allow(null, "/.well-known/**"),
|
||||
ScopeRule.allow(null, "/actuator/health"),
|
||||
ScopeRule.allow(null, "/v3/api-docs/**"),
|
||||
ScopeRule.allow(null, "/swagger-ui/**")
|
||||
);
|
||||
|
||||
private static final List<ScopeRule> REQUIRED_SCOPE_RULES = List.of(
|
||||
ScopeRule.require(null, "/api/v1/tokens", "token:manage"),
|
||||
ScopeRule.require(null, "/api/v1/tokens/**", "token:manage"),
|
||||
ScopeRule.require("POST", "/api/v1/skills", "skill:publish"),
|
||||
ScopeRule.require("POST", "/api/v1/skills/*/publish", "skill:publish"),
|
||||
ScopeRule.require("POST", "/api/web/skills/*/publish", "skill:publish"),
|
||||
ScopeRule.require("POST", "/api/v1/publish", "skill:publish"),
|
||||
ScopeRule.require("POST", "/api/v1/publish", "skill:publish")
|
||||
);
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||
private final RouteSecurityPolicyRegistry routeSecurityPolicyRegistry;
|
||||
|
||||
public ApiTokenScopeService(ObjectMapper objectMapper) {
|
||||
public ApiTokenScopeService(ObjectMapper objectMapper,
|
||||
RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.routeSecurityPolicyRegistry = routeSecurityPolicyRegistry;
|
||||
}
|
||||
|
||||
public Set<String> parseScopes(String scopeJson) {
|
||||
|
|
@ -85,32 +51,17 @@ public class ApiTokenScopeService {
|
|||
}
|
||||
|
||||
public AuthorizationDecision authorize(String method, String path, Set<String> tokenScopes) {
|
||||
if (!isApiPath(path)) {
|
||||
RouteSecurityPolicyRegistry.ApiTokenAuthorizationDecision decision =
|
||||
routeSecurityPolicyRegistry.authorizeApiToken(method, path, tokenScopes);
|
||||
if (decision.allowed()) {
|
||||
return AuthorizationDecision.allow();
|
||||
}
|
||||
|
||||
for (ScopeRule rule : UNSCOPED_ALLOWED_RULES) {
|
||||
if (rule.matches(method, path, pathMatcher)) {
|
||||
return AuthorizationDecision.allow();
|
||||
}
|
||||
if (decision.requiredScope() != null) {
|
||||
return AuthorizationDecision.missingScope(decision.requiredScope());
|
||||
}
|
||||
|
||||
for (ScopeRule rule : REQUIRED_SCOPE_RULES) {
|
||||
if (rule.matches(method, path, pathMatcher)) {
|
||||
if (tokenScopes.contains(rule.requiredScope())) {
|
||||
return AuthorizationDecision.allow();
|
||||
}
|
||||
return AuthorizationDecision.missingScope(rule.requiredScope());
|
||||
}
|
||||
}
|
||||
|
||||
return AuthorizationDecision.unsupported(path);
|
||||
}
|
||||
|
||||
private boolean isApiPath(String path) {
|
||||
return path != null && (path.startsWith("/api/v1/") || path.startsWith("/api/web/") || path.startsWith("/api/"));
|
||||
}
|
||||
|
||||
public record AuthorizationDecision(boolean allowed, String requiredScope, String message) {
|
||||
public static AuthorizationDecision allow() {
|
||||
return new AuthorizationDecision(true, null, null);
|
||||
|
|
@ -124,21 +75,4 @@ public class ApiTokenScopeService {
|
|||
return new AuthorizationDecision(false, null, "API token cannot access endpoint: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
private record ScopeRule(String method, String pattern, String requiredScope) {
|
||||
static ScopeRule allow(String method, String pattern) {
|
||||
return new ScopeRule(method, pattern, null);
|
||||
}
|
||||
|
||||
static ScopeRule require(String method, String pattern, String requiredScope) {
|
||||
return new ScopeRule(method, pattern, requiredScope);
|
||||
}
|
||||
|
||||
boolean matches(String requestMethod, String requestPath, AntPathMatcher matcher) {
|
||||
if (method != null && !method.equalsIgnoreCase(requestMethod)) {
|
||||
return false;
|
||||
}
|
||||
return matcher.match(pattern, requestPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
package com.iflytek.skillhub.auth.policy;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RouteSecurityPolicyRegistryTest {
|
||||
|
||||
private final RouteSecurityPolicyRegistry registry = new RouteSecurityPolicyRegistry();
|
||||
|
||||
@Test
|
||||
void authorizeApiToken_requiresPublishScopeForPublishEndpoints() {
|
||||
var denied = registry.authorizeApiToken("POST", "/api/web/skills/global/publish", Set.of("skill:read"));
|
||||
var allowed = registry.authorizeApiToken("POST", "/api/web/skills/global/publish", Set.of("skill:publish"));
|
||||
|
||||
assertFalse(denied.allowed());
|
||||
assertEquals("skill:publish", denied.requiredScope());
|
||||
assertTrue(allowed.allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreCsrf_forBearerAndApiPaths() {
|
||||
assertTrue(registry.shouldIgnoreCsrf("/api/v1/admin/users", null));
|
||||
assertTrue(registry.shouldIgnoreCsrf("/not-api", "Bearer token"));
|
||||
assertFalse(registry.shouldIgnoreCsrf("/ui/settings", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldProjectRequestContext_onlyForApiRoutes() {
|
||||
assertTrue(registry.shouldProjectRequestContext("/api/web/namespaces/team-a"));
|
||||
assertFalse(registry.shouldProjectRequestContext("/assets/index.css"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.auth.token;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
|
||||
import com.iflytek.skillhub.auth.entity.ApiToken;
|
||||
import com.iflytek.skillhub.auth.entity.Role;
|
||||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
|
|
@ -31,7 +32,8 @@ class ApiTokenAuthenticationFilterTest {
|
|||
private final ApiTokenService apiTokenService = mock(ApiTokenService.class);
|
||||
private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class);
|
||||
private final UserRoleBindingRepository roleBindingRepository = mock(UserRoleBindingRepository.class);
|
||||
private final ApiTokenScopeService scopeService = new ApiTokenScopeService(new ObjectMapper());
|
||||
private final ApiTokenScopeService scopeService =
|
||||
new ApiTokenScopeService(new ObjectMapper(), new RouteSecurityPolicyRegistry());
|
||||
private final ApiTokenAuthenticationFilter filter = new ApiTokenAuthenticationFilter(
|
||||
apiTokenService,
|
||||
userAccountRepository,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.auth.token;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
|
@ -27,7 +28,8 @@ import static org.mockito.Mockito.verify;
|
|||
|
||||
class ApiTokenScopeFilterTest {
|
||||
|
||||
private final ApiTokenScopeService scopeService = new ApiTokenScopeService(new ObjectMapper());
|
||||
private final ApiTokenScopeService scopeService =
|
||||
new ApiTokenScopeService(new ObjectMapper(), new RouteSecurityPolicyRegistry());
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.auth.token;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Set;
|
||||
|
|
@ -11,7 +12,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||
|
||||
class ApiTokenScopeServiceTest {
|
||||
|
||||
private final ApiTokenScopeService scopeService = new ApiTokenScopeService(new ObjectMapper());
|
||||
private final ApiTokenScopeService scopeService =
|
||||
new ApiTokenScopeService(new ObjectMapper(), new RouteSecurityPolicyRegistry());
|
||||
|
||||
@Test
|
||||
void parseScopesShouldNormalizeJsonArray() {
|
||||
|
|
@ -74,4 +76,26 @@ class ApiTokenScopeServiceTest {
|
|||
assertFalse(decision.allowed());
|
||||
assertEquals("API token cannot access endpoint: /api/v1/me/skills", decision.message());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizeShouldAllowPublicNamespaceReadWithoutScope() {
|
||||
ApiTokenScopeService.AuthorizationDecision decision = scopeService.authorize(
|
||||
"GET",
|
||||
"/api/v1/namespaces/team-a",
|
||||
Set.of()
|
||||
);
|
||||
|
||||
assertTrue(decision.allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizeShouldPermitAuthMeWithoutScope() {
|
||||
ApiTokenScopeService.AuthorizationDecision decision = scopeService.authorize(
|
||||
"GET",
|
||||
"/api/v1/auth/me",
|
||||
Set.of()
|
||||
);
|
||||
|
||||
assertTrue(decision.allowed());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
"scripts": {
|
||||
"install:ci": "pnpm install --frozen-lockfile",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build && node scripts/inject-docs.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
@ -48,7 +48,6 @@
|
|||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"marked": "^15.0.12",
|
||||
"openapi-typescript": "^7.6.1",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
|
|
|
|||
10
web/pnpm-lock.yaml
generated
10
web/pnpm-lock.yaml
generated
|
|
@ -105,9 +105,6 @@ importers:
|
|||
eslint-plugin-react-refresh:
|
||||
specifier: ^0.4.5
|
||||
version: 0.4.26(eslint@8.57.1)
|
||||
marked:
|
||||
specifier: ^15.0.12
|
||||
version: 15.0.12
|
||||
openapi-typescript:
|
||||
specifier: ^7.6.1
|
||||
version: 7.13.0(typescript@5.9.3)
|
||||
|
|
@ -1667,11 +1664,6 @@ packages:
|
|||
markdown-table@3.0.4:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
marked@15.0.12:
|
||||
resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
|
||||
engines: {node: '>= 18'}
|
||||
hasBin: true
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
|
||||
|
||||
|
|
@ -3985,8 +3977,6 @@ snapshots:
|
|||
|
||||
markdown-table@3.0.4: {}
|
||||
|
||||
marked@15.0.12: {}
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
|
|
|
|||
3
web/public/robots.txt
Normal file
3
web/public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: https://skill.xfyun.cn/sitemap.xml
|
||||
9
web/public/sitemap.xml
Normal file
9
web/public/sitemap.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://skill.xfyun.cn/</loc>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://skill.xfyun.cn/registry/skill.md</loc>
|
||||
</url>
|
||||
</urlset>
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { marked } from 'marked';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Paths
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const docPath = join(projectRoot, 'docs/openclaw-integration-en.md');
|
||||
const distIndexPath = join(__dirname, '../dist/index.html');
|
||||
|
||||
console.log('📄 Reading markdown document...');
|
||||
const markdownContent = readFileSync(docPath, 'utf-8');
|
||||
|
||||
console.log('🔄 Converting markdown to HTML...');
|
||||
const htmlContent = marked.parse(markdownContent);
|
||||
|
||||
console.log('📝 Reading dist/index.html...');
|
||||
const indexHtml = readFileSync(distIndexPath, 'utf-8');
|
||||
|
||||
// Create the hidden SEO container
|
||||
const seoContainer = `
|
||||
<!-- SEO: Hidden documentation for web crawlers -->
|
||||
<div id="seo-docs" style="position:absolute;left:-9999px;top:-9999px;overflow:hidden;width:1px;height:1px;" aria-hidden="true">
|
||||
${htmlContent}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Inject before <div id="root">
|
||||
const injectedHtml = indexHtml.replace(
|
||||
/<div id="root">/,
|
||||
`${seoContainer}\n <div id="root">`
|
||||
);
|
||||
|
||||
console.log('💾 Writing updated index.html...');
|
||||
writeFileSync(distIndexPath, injectedHtml, 'utf-8');
|
||||
|
||||
console.log('✅ Documentation injected successfully!');
|
||||
console.log(` - Source: ${docPath}`);
|
||||
console.log(` - Target: ${distIndexPath}`);
|
||||
console.log(` - Content size: ~${Math.round(htmlContent.length / 1024)}KB`);
|
||||
|
|
@ -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