Merge remote-tracking branch 'origin/main' into feature/project-fixbug319

This commit is contained in:
yun-zhi-ztl 2026-03-19 16:35:32 +08:00
commit ae91e7ead2
42 changed files with 1886 additions and 639 deletions

View file

@ -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 | |

View file

@ -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 | |

View file

@ -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:

View file

@ -11,8 +11,9 @@ description: 搜索和筛选技能
在搜索框输入关键词SkillHub 会在以下字段中搜索:
- 技能名称
- 技能描述
- SKILL.md 正文内容
- 关键词
- 技能 slug
- frontmatter 中除 `name``description``version` 外的其他字段
- `keywords` / `tags` 等关键词字段
## 筛选条件

View file

@ -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

View file

@ -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);
}
}

View file

@ -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));
}
}

View file

@ -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)));
}
}

View file

@ -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())) {

View file

@ -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);
}
}
}

View file

@ -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
);
}
}

View file

@ -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");
}
}

View file

@ -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));
}
}

View file

@ -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();
}
}

View file

@ -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")

View file

@ -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));
}
}

View file

@ -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");
}
}

View file

@ -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;

View file

@ -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());
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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");
}
}

View file

@ -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());
}
}
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}
}

View file

@ -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"));
}
}

View file

@ -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,

View file

@ -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() {

View file

@ -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());
}
}

View file

@ -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) {
}
}

View file

@ -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");
}
}

View file

@ -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
View file

@ -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
View file

@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://skill.xfyun.cn/sitemap.xml

9
web/public/sitemap.xml Normal file
View 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>

View file

@ -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`);

View file

@ -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 = {

View file

@ -49,3 +49,9 @@ export function useMarkGovernanceNotificationRead() {
},
})
}
export function useRebuildSearchIndex() {
return useMutation({
mutationFn: () => governanceApi.rebuildSearchIndex(),
})
}

View file

@ -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",

View file

@ -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": "成员管理",

View file

@ -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)

View file

@ -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>
)
}