From 09cf3b5dccc824082a86ff8f49df9b5e7e9aa953 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 26 Jun 2026 12:16:27 +0800 Subject: [PATCH] feat: [6ec22314-8660-4ac1-99cd-11c5598c3dea] add compliance version mapping Signed-off-by: dongmucat <1127093059@qq.com> --- .../compat/ClawHubCompatAppService.java | 46 +++- .../controller/cli/CliSkillController.java | 6 +- .../controller/portal/SkillController.java | 12 +- .../portal/SkillPublishController.java | 39 ++- .../portal/SkillSearchController.java | 2 + .../dto/SkillComplianceMappingResponse.java | 9 + .../dto/SkillVersionDetailResponse.java | 4 +- .../service/ReviewPortalAppService.java | 32 ++- .../service/SkillLifecycleAppService.java | 21 +- .../service/SkillSearchAppService.java | 48 +++- .../service/cli/CliSkillAppService.java | 42 ++- .../V44__skill_version_compliance_index.sql | 3 + .../compat/ClawHubCompatAppServiceTest.java | 171 ++++++++++++ .../controller/SkillControllerTest.java | 20 +- .../controller/SkillSearchControllerTest.java | 27 ++ .../cli/CliSkillControllerTest.java | 2 +- .../portal/SkillPublishControllerTest.java | 88 ++++++ .../service/ReviewPortalAppServiceTest.java | 103 +++++++ .../service/SkillLifecycleAppServiceTest.java | 65 +++++ .../service/SkillSearchAppServiceTest.java | 22 ++ .../service/cli/CliSkillAppServiceTest.java | 73 ++++- .../skill/metadata/ComplianceStandard.java | 44 +++ .../SkillComplianceAuditDetailFactory.java | 58 ++++ .../metadata/SkillComplianceMapping.java | 12 + .../SkillComplianceMetadataService.java | 260 ++++++++++++++++++ .../skill/service/SkillGovernanceService.java | 19 +- .../skill/service/SkillQueryService.java | 9 +- .../validation/SkillPackageValidator.java | 27 +- .../SkillComplianceMetadataServiceTest.java | 117 ++++++++ .../service/SkillGovernanceServiceTest.java | 35 ++- .../validation/SkillPackageValidatorTest.java | 24 ++ .../iflytek/skillhub/search/SearchQuery.java | 17 +- .../PostgresFullTextQueryService.java | 20 +- .../PostgresFullTextQueryServiceTest.java | 36 +++ web/src/api/generated/schema.d.ts | 9 + web/src/api/types.ts | 10 + web/src/app/router.tsx | 6 +- web/src/i18n/locales/en.json | 17 ++ web/src/i18n/locales/zh.json | 17 ++ web/src/pages/search.test.tsx | 27 ++ web/src/pages/search.tsx | 88 +++++- web/src/pages/skill-detail.test.tsx | 81 +++++- web/src/pages/skill-detail.tsx | 91 +++++- .../shared/hooks/skill-query-helpers.test.ts | 3 +- web/src/shared/hooks/skill-query-helpers.ts | 4 + 45 files changed, 1801 insertions(+), 65 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillComplianceMappingResponse.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V44__skill_version_compliance_index.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewPortalAppServiceTest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/ComplianceStandard.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceAuditDetailFactory.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMapping.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataService.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataServiceTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java index b6fc5b1f..f82b70f2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java @@ -16,7 +16,9 @@ import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceAuditDetailFactory; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.social.SkillStarService; @@ -24,6 +26,7 @@ import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.service.SkillSearchAppService; import java.io.IOException; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.slf4j.MDC; @@ -49,6 +52,8 @@ public class ClawHubCompatAppService { private final AuditLogService auditLogService; private final CompatSkillLookupService compatSkillLookupService; private final SkillStarService skillStarService; + private final SkillComplianceAuditDetailFactory complianceAuditDetailFactory = + new SkillComplianceAuditDetailFactory(); public ClawHubCompatAppService(CanonicalSlugMapper mapper, SkillSearchAppService skillSearchAppService, @@ -302,8 +307,14 @@ public class ClawHubCompatAppService { principal.platformRoles(), confirmWarnings ); - recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent, - "{\"namespace\":\"" + namespace + "\",\"slug\":\"" + extracted.payload().slug() + "\"}"); + recordCompatPublishAudit( + principal.userId(), + result.version(), + namespace, + result.slug(), + clientIp, + userAgent + ); return new ClawHubPublishResponse(result.skillId().toString(), result.version().getId().toString()); } @@ -321,8 +332,14 @@ public class ClawHubCompatAppService { principal.platformRoles(), confirmWarnings ); - recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent, - "{\"namespace\":\"" + namespace + "\"}"); + recordCompatPublishAudit( + principal.userId(), + result.version(), + namespace, + result.slug(), + clientIp, + userAgent + ); return new ClawHubPublishResponse(result.skillId().toString(), result.version().getId().toString()); } @@ -421,20 +438,31 @@ public class ClawHubCompatAppService { } private void recordCompatPublishAudit(String userId, - Long versionId, + SkillVersion version, + String namespace, + String slug, String clientIp, - String userAgent, - String detailJson) { + String userAgent) { auditLogService.record( userId, "COMPAT_PUBLISH", "SKILL_VERSION", - versionId, + version.getId(), MDC.get("requestId"), clientIp, userAgent, - detailJson + compatPublishAuditDetail(version, namespace, slug) ); } + private String compatPublishAuditDetail(SkillVersion version, String namespace, String slug) { + if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + LinkedHashMap extras = new LinkedHashMap<>(); + extras.put("namespace", namespace); + extras.put("slug", slug); + return complianceAuditDetailFactory.latestPublishedEntered(version, extras); + } + return "{\"namespace\":\"" + namespace + "\",\"slug\":\"" + slug + "\"}"; + } + } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliSkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliSkillController.java index 0752559e..833d9c5f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliSkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliSkillController.java @@ -125,7 +125,8 @@ public class CliSkillController extends BaseApiController { @PathVariable String namespace, @RequestPart("file") MultipartFile file, @RequestPart(value = "visibility", required = false) String visibility, - @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) throws IOException { List entries; try { entries = archiveExtractor.extract(file); @@ -135,7 +136,8 @@ public class CliSkillController extends BaseApiController { var result = cliSkillAppService.publish( namespace, entries, principal.userId(), SkillVisibility.valueOf((visibility != null ? visibility : "PUBLIC").toUpperCase()), - principal.platformRoles()); + principal.platformRoles(), + AuditRequestContext.from(request)); return ok("response.success.published", result); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 7c0df9c9..4f9a120c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -11,6 +11,7 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.ResolveVersionResponse; +import com.iflytek.skillhub.dto.SkillComplianceMappingResponse; import com.iflytek.skillhub.dto.SkillDetailResponse; import com.iflytek.skillhub.dto.SkillFileResponse; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; @@ -173,7 +174,16 @@ public class SkillController extends BaseApiController { detail.totalSize(), detail.publishedAt(), detail.parsedMetadataJson(), - detail.manifestJson() + detail.manifestJson(), + detail.complianceMappings().stream() + .map(mapping -> new SkillComplianceMappingResponse( + mapping.standard().value(), + mapping.standardVersion(), + mapping.controlId(), + mapping.controlTitle(), + mapping.evidenceUrl() + )) + .toList() ); return ok("response.success.read", response); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index a061afc1..38caaebb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -1,15 +1,19 @@ package com.iflytek.skillhub.controller.portal; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceAuditDetailFactory; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; +import jakarta.servlet.http.HttpServletRequest; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -17,6 +21,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.util.LinkedHashMap; import java.util.List; /** @@ -32,15 +37,20 @@ public class SkillPublishController extends BaseApiController { private final SkillPublishService skillPublishService; private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; private final SkillHubMetrics skillHubMetrics; + private final AuditLogService auditLogService; + private final SkillComplianceAuditDetailFactory complianceAuditDetailFactory = + new SkillComplianceAuditDetailFactory(); public SkillPublishController(SkillPublishService skillPublishService, SkillPackageArchiveExtractor skillPackageArchiveExtractor, ApiResponseFactory responseFactory, - SkillHubMetrics skillHubMetrics) { + SkillHubMetrics skillHubMetrics, + AuditLogService auditLogService) { super(responseFactory); this.skillPublishService = skillPublishService; this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; this.skillHubMetrics = skillHubMetrics; + this.auditLogService = auditLogService; } /** @@ -54,7 +64,8 @@ public class SkillPublishController extends BaseApiController { @RequestParam("file") MultipartFile file, @RequestParam("visibility") String visibility, @RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings, - @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); @@ -93,8 +104,32 @@ public class SkillPublishController extends BaseApiController { publishResult.version().getFileCount(), publishResult.version().getTotalSize() ); + recordPublishAuditIfLatestPublished(principal.userId(), namespace, publishResult, request); skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name()); return ok("response.success.published", response); } + + private void recordPublishAuditIfLatestPublished(String userId, + String namespace, + SkillPublishService.PublishResult publishResult, + HttpServletRequest request) { + if (publishResult.version().getStatus() != SkillVersionStatus.PUBLISHED) { + return; + } + + LinkedHashMap extras = new LinkedHashMap<>(); + extras.put("namespace", namespace); + extras.put("slug", publishResult.slug()); + auditLogService.record( + userId, + "PUBLISH", + "SKILL_VERSION", + publishResult.version().getId(), + null, + request != null ? request.getRemoteAddr() : null, + request != null ? request.getHeader("User-Agent") : null, + complianceAuditDetailFactory.latestPublishedEntered(publishResult.version(), extras) + ); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java index 36227b31..1f5af863 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java @@ -40,6 +40,7 @@ public class SkillSearchController extends BaseApiController { @RequestParam(required = false) String q, @RequestParam(required = false) String namespace, @RequestParam(name = "label", required = false) java.util.List labels, + @RequestParam(required = false) String complianceStandard, @Parameter(schema = @Schema(defaultValue = DEFAULT_SORT)) @RequestParam(required = false) String sort, @Parameter(schema = @Schema(type = "integer", defaultValue = "0", minimum = "0")) @@ -56,6 +57,7 @@ public class SkillSearchController extends BaseApiController { parseNonNegativeInt(page, DEFAULT_PAGE), parsePositiveInt(size, DEFAULT_SIZE), labels, + complianceStandard, userId, userNsRoles ); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillComplianceMappingResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillComplianceMappingResponse.java new file mode 100644 index 00000000..96d4c3bc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillComplianceMappingResponse.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +public record SkillComplianceMappingResponse( + String standard, + String standardVersion, + String controlId, + String controlTitle, + String evidenceUrl +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java index 8c01be78..457a8c67 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.dto; import java.time.Instant; +import java.util.List; public record SkillVersionDetailResponse( Long id, @@ -11,5 +12,6 @@ public record SkillVersionDetailResponse( long totalSize, Instant publishedAt, String parsedMetadataJson, - String manifestJson + String manifestJson, + List complianceMappings ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java index 47d8eed5..9c3af2b4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java @@ -11,10 +11,14 @@ import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceAuditDetailFactory; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.repository.GovernanceQueryRepository; import java.util.List; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import org.slf4j.MDC; @@ -34,19 +38,24 @@ public class ReviewPortalAppService { private final GovernanceQueryRepository governanceQueryRepository; private final RbacService rbacService; private final AuditLogService auditLogService; + private final SkillVersionRepository skillVersionRepository; + private final SkillComplianceAuditDetailFactory complianceAuditDetailFactory = + new SkillComplianceAuditDetailFactory(); public ReviewPortalAppService(ReviewService reviewService, ReviewTaskRepository reviewTaskRepository, NamespaceRepository namespaceRepository, GovernanceQueryRepository governanceQueryRepository, RbacService rbacService, - AuditLogService auditLogService) { + AuditLogService auditLogService, + SkillVersionRepository skillVersionRepository) { this.reviewService = reviewService; this.reviewTaskRepository = reviewTaskRepository; this.namespaceRepository = namespaceRepository; this.governanceQueryRepository = governanceQueryRepository; this.rbacService = rbacService; this.auditLogService = auditLogService; + this.skillVersionRepository = skillVersionRepository; } public ReviewTaskResponse submitReview(Long skillVersionId, @@ -75,7 +84,13 @@ public class ReviewPortalAppService { normalizeRoles(userNsRoles), platformRoles(userId) ); - recordAudit("REVIEW_APPROVE", userId, task.getId(), auditContext, detailWithComment(comment)); + recordAudit( + "REVIEW_APPROVE", + userId, + task.getId(), + auditContext, + detailWithCommentAndSnapshot(comment, task.getSkillVersionId()) + ); return governanceQueryRepository.getReviewTaskResponse(task); } @@ -269,4 +284,17 @@ public class ReviewPortalAppService { } return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; } + + private String detailWithCommentAndSnapshot(String comment, Long skillVersionId) { + SkillVersion version = skillVersionRepository.findById(skillVersionId).orElse(null); + if (version == null) { + return detailWithComment(comment); + } + + LinkedHashMap extras = new LinkedHashMap<>(); + if (comment != null && !comment.isBlank()) { + extras.put("comment", comment); + } + return complianceAuditDetailFactory.latestPublishedEntered(version, extras); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java index e7f86d45..8f939c03 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java @@ -9,6 +9,8 @@ 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.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceAuditDetailFactory; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService; @@ -16,6 +18,7 @@ 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.LinkedHashMap; import java.util.Map; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +38,8 @@ public class SkillLifecycleAppService { private final SkillReviewSubmitService skillReviewSubmitService; private final AuditLogService auditLogService; private final SkillSlugResolutionService skillSlugResolutionService; + private final SkillComplianceAuditDetailFactory complianceAuditDetailFactory = + new SkillComplianceAuditDetailFactory(); public SkillLifecycleAppService(NamespaceRepository namespaceRepository, SkillVersionRepository skillVersionRepository, @@ -165,8 +170,7 @@ public class SkillLifecycleAppService { null, auditContext.clientIp(), auditContext.userAgent(), - "{\"sourceVersion\":\"" + version.replace("\"", "\\\"") - + "\",\"targetVersion\":\"" + targetVersion.replace("\"", "\\\"") + "\"}" + rereleaseAuditDetail(version, targetVersion, result.version()) ); return new SkillLifecycleMutationResponse( result.skillId(), @@ -234,7 +238,7 @@ public class SkillLifecycleAppService { null, auditContext.clientIp(), auditContext.userAgent(), - "{\"version\":\"" + version.replace("\"", "\\\"") + "\"}" + complianceAuditDetailFactory.latestPublishedEntered(skillVersion) ); return new SkillLifecycleMutationResponse( skill.getId(), @@ -264,4 +268,15 @@ public class SkillLifecycleAppService { private Map normalizeRoles(Map userNamespaceRoles) { return userNamespaceRoles != null ? userNamespaceRoles : Map.of(); } + + private String rereleaseAuditDetail(String sourceVersion, String targetVersion, SkillVersion publishedVersion) { + if (publishedVersion.getStatus() == SkillVersionStatus.PUBLISHED) { + LinkedHashMap extras = new LinkedHashMap<>(); + extras.put("sourceVersion", sourceVersion); + extras.put("targetVersion", targetVersion); + return complianceAuditDetailFactory.latestPublishedEntered(publishedVersion, extras); + } + return "{\"sourceVersion\":\"" + sourceVersion.replace("\"", "\\\"") + + "\",\"targetVersion\":\"" + targetVersion.replace("\"", "\\\"") + "\"}"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 63410678..f62c1905 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -68,7 +68,35 @@ public class SkillSearchAppService { int size, String userId, Map userNsRoles) { - return search(keyword, namespaceSlug, sortBy, page, size, List.of(), userId, userNsRoles); + return search(keyword, namespaceSlug, sortBy, page, size, List.of(), null, userId, userNsRoles); + } + + public SearchResponse search( + String keyword, + String namespaceSlug, + String sortBy, + int page, + int size, + List labelSlugs, + String complianceStandard, + String userId, + Map userNsRoles) { + + Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles); + + SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles); + + return searchVisibleSkills( + keyword, + namespaceId, + sortBy != null ? sortBy : "newest", + page, + size, + labelSlugs, + normalizeComplianceStandard(complianceStandard), + scope, + false + ); } public SearchResponse search( @@ -80,12 +108,7 @@ public class SkillSearchAppService { List labelSlugs, String userId, Map userNsRoles) { - - Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles); - - SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles); - - return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope, false); + return search(keyword, namespaceSlug, sortBy, page, size, labelSlugs, null, userId, userNsRoles); } public SearchResponse searchInstallableLatest( @@ -98,7 +121,7 @@ public class SkillSearchAppService { Map userNsRoles) { Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles); SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles); - return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, List.of(), scope, true); + return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, List.of(), null, scope, true); } private Long resolveNamespaceId(String namespaceSlug, String userId, Map userNsRoles) { @@ -146,6 +169,7 @@ public class SkillSearchAppService { int page, int size, List labelSlugs, + String complianceStandard, SearchVisibilityScope scope, boolean requireInstallableLatest) { SearchResult result = searchQueryService.search(new SearchQuery( @@ -156,6 +180,7 @@ public class SkillSearchAppService { page, size, normalizeLabelSlugs(labelSlugs), + complianceStandard, requireInstallableLatest )); List pageItems = mapVisibleSkillSummaries(result.skillIds()); @@ -173,6 +198,13 @@ public class SkillSearchAppService { .toList(); } + private String normalizeComplianceStandard(String complianceStandard) { + if (complianceStandard == null || complianceStandard.isBlank()) { + return null; + } + return complianceStandard.trim().toLowerCase(java.util.Locale.ROOT); + } + private List mapVisibleSkillSummaries(List skillIds) { if (skillIds.isEmpty()) { return List.of(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java index 1fcd2e25..4c5e88f8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java @@ -1,7 +1,10 @@ package com.iflytek.skillhub.service.cli; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceAuditDetailFactory; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; @@ -21,6 +24,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -33,18 +37,23 @@ public class CliSkillAppService { private final SkillDownloadService skillDownloadService; private final SkillDeleteAppService skillDeleteAppService; private final SkillPublishService skillPublishService; + private final AuditLogService auditLogService; + private final SkillComplianceAuditDetailFactory complianceAuditDetailFactory = + new SkillComplianceAuditDetailFactory(); public CliSkillAppService( SkillSearchAppService skillSearchAppService, SkillQueryService skillQueryService, SkillDownloadService skillDownloadService, SkillDeleteAppService skillDeleteAppService, - SkillPublishService skillPublishService) { + SkillPublishService skillPublishService, + AuditLogService auditLogService) { this.skillSearchAppService = skillSearchAppService; this.skillQueryService = skillQueryService; this.skillDownloadService = skillDownloadService; this.skillDeleteAppService = skillDeleteAppService; this.skillPublishService = skillPublishService; + this.auditLogService = auditLogService; } public record CliSearchItem(String namespace, String slug, String latestVersion, String summary) {} @@ -132,10 +141,16 @@ public class CliSkillAppService { ); } - public CliPublishResponse publish(String namespace, List entries, String publisherId, SkillVisibility visibility, Set platformRoles) { + public CliPublishResponse publish(String namespace, + List entries, + String publisherId, + SkillVisibility visibility, + Set platformRoles, + AuditRequestContext auditContext) { SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( namespace, entries, publisherId, visibility, platformRoles, false ); + recordPublishAuditIfLatestPublished(publisherId, namespace, result, auditContext); return new CliPublishResponse( namespace, @@ -145,6 +160,29 @@ public class CliSkillAppService { ); } + private void recordPublishAuditIfLatestPublished(String publisherId, + String namespace, + SkillPublishService.PublishResult result, + AuditRequestContext auditContext) { + if (result.version().getStatus() != SkillVersionStatus.PUBLISHED) { + return; + } + + LinkedHashMap extras = new LinkedHashMap<>(); + extras.put("namespace", namespace); + extras.put("slug", result.slug()); + auditLogService.record( + publisherId, + "CLI_PUBLISH", + "SKILL_VERSION", + result.version().getId(), + null, + auditContext != null ? auditContext.clientIp() : null, + auditContext != null ? auditContext.userAgent() : null, + complianceAuditDetailFactory.latestPublishedEntered(result.version(), extras) + ); + } + private ResponseEntity buildDownloadResponse(SkillDownloadService.DownloadResult result) { if (result.presignedUrl() != null) { return ResponseEntity.status(302) diff --git a/server/skillhub-app/src/main/resources/db/migration/V44__skill_version_compliance_index.sql b/server/skillhub-app/src/main/resources/db/migration/V44__skill_version_compliance_index.sql new file mode 100644 index 00000000..e69d8458 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V44__skill_version_compliance_index.sql @@ -0,0 +1,3 @@ +CREATE INDEX IF NOT EXISTS idx_skill_version_compliance_mappings +ON skill_version +USING GIN ((parsed_metadata_json -> 'frontmatter' -> 'x-astron-compliance')); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java index c5921f59..f7943d02 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java @@ -2,23 +2,36 @@ package com.iflytek.skillhub.compat; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; 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.controller.support.MultipartPackageExtractor; import com.iflytek.skillhub.controller.support.ZipPackageExtractor; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.domain.social.SkillStarService; import com.iflytek.skillhub.service.SkillSearchAppService; +import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; class ClawHubCompatAppServiceTest { @@ -77,4 +90,162 @@ class ClawHubCompatAppServiceTest { assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/download"); } + + @Test + void publishSkill_recordsComplianceSnapshotWhenLatestPublished() throws IOException { + MockMultipartFile file = new MockMultipartFile( + "files", + "SKILL.md", + "text/markdown", + "name: demo".getBytes() + ); + MockMultipartFile[] files = new MockMultipartFile[]{file}; + MultipartPackageExtractor.PublishPayload payload = new MultipartPackageExtractor.PublishPayload( + "team-ai", + "demo-skill", + "Demo Skill", + "1.0.0", + null, + true, + List.of(), + null + ); + List entries = List.of( + new PackageEntry("SKILL.md", "name: demo".getBytes(), 10, "text/markdown") + ); + when(multipartPackageExtractor.extract(files, "{\"slug\":\"demo-skill\"}")) + .thenReturn(new MultipartPackageExtractor.ExtractedPackage(payload, entries)); + + SkillVersion version = publishedVersionWithCompliance(22L, "1.0.0", "CC6.1"); + when(skillPublishService.publishFromEntries( + "team-ai", + entries, + "user-1", + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + true + )).thenReturn(new SkillPublishService.PublishResult(7L, "demo-skill", version)); + + PlatformPrincipal principal = new PlatformPrincipal( + "user-1", + "publisher", + "publisher@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + + service.publishSkill( + "{\"slug\":\"demo-skill\"}", + files, + true, + principal, + "127.0.0.1", + "JUnit" + ); + + verify(auditLogService).record( + eq("user-1"), + eq("COMPAT_PUBLISH"), + eq("SKILL_VERSION"), + eq(22L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"snapshotKind\":\"latest_published_entered\"") + ); + verify(auditLogService).record( + eq("user-1"), + eq("COMPAT_PUBLISH"), + eq("SKILL_VERSION"), + eq(22L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"controlId\":\"CC6.1\"") + ); + } + + @Test + void publish_recordsComplianceSnapshotWhenLatestPublished() throws IOException { + MockMultipartFile file = new MockMultipartFile( + "file", + "demo.zip", + "application/zip", + "zip".getBytes() + ); + List entries = List.of( + new PackageEntry("SKILL.md", "name: demo".getBytes(), 10, "text/markdown") + ); + when(zipPackageExtractor.extract(file)).thenReturn(entries); + + SkillVersion version = publishedVersionWithCompliance(23L, "1.2.0", "Article-17"); + when(skillPublishService.publishFromEntries( + "global", + entries, + "user-1", + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + false + )).thenReturn(new SkillPublishService.PublishResult(8L, "demo-skill", version)); + + PlatformPrincipal principal = new PlatformPrincipal( + "user-1", + "publisher", + "publisher@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + + service.publish( + file, + "global", + false, + principal, + "127.0.0.1", + "JUnit" + ); + + verify(auditLogService).record( + eq("user-1"), + eq("COMPAT_PUBLISH"), + eq("SKILL_VERSION"), + eq(23L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"snapshotKind\":\"latest_published_entered\"") + ); + verify(auditLogService).record( + eq("user-1"), + eq("COMPAT_PUBLISH"), + eq("SKILL_VERSION"), + eq(23L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"controlId\":\"Article-17\"") + ); + } + + private SkillVersion publishedVersionWithCompliance(Long versionId, String versionNumber, String controlId) { + SkillVersion version = new SkillVersion(7L, versionNumber, "owner-1"); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "gdpr", + "standardVersion": "2024", + "controlId": "%s" + } + ] + } + } + """.formatted(controlId)); + ReflectionTestUtils.setField(version, "id", versionId); + return version; + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index ce622374..6e1244b9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -5,6 +5,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceMapping; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; @@ -67,7 +69,14 @@ class SkillControllerTest { 128L, Instant.parse("2026-03-12T12:00:00Z"), "{\"name\":\"demo\"}", - "[{\"path\":\"SKILL.md\"}]" + "[{\"path\":\"SKILL.md\"}]", + List.of(new SkillComplianceMapping( + ComplianceStandard.GDPR, + "2024", + "Article-17", + "Right to erasure", + "https://example.com/gdpr" + )) )); mockMvc.perform(get("/api/v1/skills/team/demo/versions/1.0.0")) @@ -76,6 +85,9 @@ class SkillControllerTest { .andExpect(jsonPath("$.data.version").value("1.0.0")) .andExpect(jsonPath("$.data.parsedMetadataJson").value("{\"name\":\"demo\"}")) .andExpect(jsonPath("$.data.manifestJson").value("[{\"path\":\"SKILL.md\"}]")) + .andExpect(jsonPath("$.data.complianceMappings[0].standard").value("gdpr")) + .andExpect(jsonPath("$.data.complianceMappings[0].standardVersion").value("2024")) + .andExpect(jsonPath("$.data.complianceMappings[0].controlId").value("Article-17")) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } @@ -97,7 +109,8 @@ class SkillControllerTest { 128L, Instant.parse("2026-03-12T12:00:00Z"), "{\"name\":\"demo\"}", - "[{\"path\":\"SKILL.md\"}]" + "[{\"path\":\"SKILL.md\"}]", + List.of() )); TimeZone original = TimeZone.getDefault(); @@ -106,7 +119,8 @@ class SkillControllerTest { TimeZone.setDefault(TimeZone.getTimeZone(zoneId)); mockMvc.perform(get("/api/v1/skills/team/demo/versions/1.0.0")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.publishedAt").value("2026-03-12T12:00:00Z")); + .andExpect(jsonPath("$.data.publishedAt").value("2026-03-12T12:00:00Z")) + .andExpect(jsonPath("$.data.complianceMappings").isArray()); } } finally { TimeZone.setDefault(original); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java index bed761ad..1c1512e1 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java @@ -43,6 +43,7 @@ class SkillSearchControllerTest { eq(0), eq(20), eq(null), + eq(null), any(), any())) .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); @@ -67,6 +68,7 @@ class SkillSearchControllerTest { eq(0), eq(12), eq(null), + eq(null), any(), any())) .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 12)); @@ -89,6 +91,7 @@ class SkillSearchControllerTest { eq(0), eq(20), eq(List.of("code-generation", "official")), + eq(null), any(), any())) .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); @@ -110,6 +113,7 @@ class SkillSearchControllerTest { eq(0), eq(20), eq(null), + eq(null), any(), any())) .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); @@ -132,6 +136,7 @@ class SkillSearchControllerTest { eq(0), eq(20), eq(null), + eq(null), any(), any())) .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); @@ -143,4 +148,26 @@ class SkillSearchControllerTest { .andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.size").value(20)); } + + @Test + void searchShouldPassComplianceStandardFilter() throws Exception { + when(skillSearchAppService.search( + eq("review"), + eq(null), + eq("newest"), + eq(0), + eq(20), + eq(List.of("official")), + eq("gdpr"), + any(), + any())) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); + + mockMvc.perform(get("/api/web/skills") + .param("q", "review") + .param("label", "official") + .param("complianceStandard", "gdpr")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isArray()); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java index 9f6fe695..9b028ba0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java @@ -69,7 +69,7 @@ class CliSkillControllerTest { @Test void publishConsumesMultipartFormData() throws Exception { Method publish = CliSkillController.class.getMethod( - "publish", String.class, MultipartFile.class, String.class, PlatformPrincipal.class); + "publish", String.class, MultipartFile.class, String.class, PlatformPrincipal.class, HttpServletRequest.class); PostMapping mapping = publish.getAnnotation(PostMapping.class); assertNotNull(mapping); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java index 8b8c714a..0c5e8765 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java @@ -1,12 +1,15 @@ package com.iflytek.skillhub.controller.portal; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.audit.AuditLogService; import org.mockito.ArgumentMatchers; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @@ -63,6 +66,9 @@ class SkillPublishControllerTest { @MockBean private SkillHubMetrics skillHubMetrics; + @MockBean + private AuditLogService auditLogService; + @Test void publish_recordsMetricsAfterSuccess() throws Exception { SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); @@ -114,6 +120,88 @@ class SkillPublishControllerTest { verify(skillHubMetrics).incrementSkillPublish("global", "PENDING_REVIEW"); } + @Test + void publish_recordsComplianceSnapshotWhenLatestPublished() throws Exception { + SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setFileCount(1); + version.setTotalSize(128L); + version.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "soc2", + "standardVersion": "2017", + "controlId": "CC6.1" + } + ] + } + } + """); + ReflectionTestUtils.setField(version, "id", 34L); + + given(skillPublishService.publishFromEntries( + eq("global"), + ArgumentMatchers.>any(), + eq("usr_1"), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(false))) + .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); + + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "publisher", + "publisher@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "skill.zip", + "application/zip", + buildZipBytes() + ); + + mockMvc.perform(multipart("/api/v1/skills/global/publish") + .file(file) + .param("visibility", "PUBLIC") + .header("User-Agent", "JUnit") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(auditLogService).record( + eq("usr_1"), + eq("PUBLISH"), + eq("SKILL_VERSION"), + eq(34L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"snapshotKind\":\"latest_published_entered\"") + ); + verify(auditLogService).record( + eq("usr_1"), + eq("PUBLISH"), + eq("SKILL_VERSION"), + eq(34L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"controlId\":\"CC6.1\"") + ); + } + @Test void publish_passesWarningConfirmationFlag() throws Exception { SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewPortalAppServiceTest.java new file mode 100644 index 00000000..675554ff --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewPortalAppServiceTest.java @@ -0,0 +1,103 @@ +package com.iflytek.skillhub.service; + +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.review.ReviewService; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.dto.ReviewTaskResponse; +import com.iflytek.skillhub.repository.GovernanceQueryRepository; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class ReviewPortalAppServiceTest { + + private final ReviewService reviewService = mock(ReviewService.class); + private final ReviewTaskRepository reviewTaskRepository = mock(ReviewTaskRepository.class); + private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + private final GovernanceQueryRepository governanceQueryRepository = mock(GovernanceQueryRepository.class); + private final RbacService rbacService = mock(RbacService.class); + private final AuditLogService auditLogService = mock(AuditLogService.class); + private final SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + + private final ReviewPortalAppService service = new ReviewPortalAppService( + reviewService, + reviewTaskRepository, + namespaceRepository, + governanceQueryRepository, + rbacService, + auditLogService, + skillVersionRepository + ); + + @Test + void approveReview_recordsComplianceSnapshotInAuditDetail() { + ReviewTask task = new ReviewTask(22L, 5L, "owner-1"); + ReflectionTestUtils.setField(task, "id", 9L); + + SkillVersion version = new SkillVersion(7L, "1.0.0", "owner-1"); + ReflectionTestUtils.setField(version, "id", 22L); + version.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "gdpr", + "standardVersion": "2024", + "controlId": "Article-17" + } + ] + } + } + """); + + when(reviewService.approveReview(9L, "reviewer-1", "looks good", Map.of(), Set.of("SKILL_ADMIN"))) + .thenReturn(task); + when(rbacService.getUserRoleCodes("reviewer-1")).thenReturn(Set.of("SKILL_ADMIN")); + when(skillVersionRepository.findById(22L)).thenReturn(Optional.of(version)); + when(governanceQueryRepository.getReviewTaskResponse(task)) + .thenReturn(new ReviewTaskResponse(9L, 22L, "team", "demo", "1.0.0", "APPROVED", "owner-1", null, "reviewer-1", null, "looks good", null, null)); + + service.approveReview( + 9L, + "looks good", + "reviewer-1", + Map.of(), + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + verify(auditLogService).record( + eq("reviewer-1"), + eq("REVIEW_APPROVE"), + eq("REVIEW_TASK"), + eq(9L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"snapshotKind\":\"latest_published_entered\"") + ); + verify(auditLogService).record( + eq("reviewer-1"), + eq("REVIEW_APPROVE"), + eq("REVIEW_TASK"), + eq(9L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"controlId\":\"Article-17\"") + ); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java index e2c10146..2ed2d372 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java @@ -14,6 +14,7 @@ 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.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; @@ -27,6 +28,9 @@ import org.springframework.test.util.ReflectionTestUtils; import java.util.Map; import java.util.Optional; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.isNull; + class SkillLifecycleAppServiceTest { private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); @@ -76,4 +80,65 @@ class SkillLifecycleAppServiceTest { assertThat(response.status()).isEqualTo("ARCHIVED"); verify(skillGovernanceService).archiveSkill(11L, "owner-1", Map.of(7L, NamespaceRole.OWNER), "127.0.0.1", "JUnit", "cleanup"); } + + @Test + void confirmPublish_recordsComplianceSnapshot() { + Namespace namespace = new Namespace("global", "Global", "owner-1"); + ReflectionTestUtils.setField(namespace, "id", 7L); + Skill skill = new Skill(7L, "demo-skill", "owner-1", SkillVisibility.PRIVATE); + ReflectionTestUtils.setField(skill, "id", 11L); + SkillVersion version = new SkillVersion(11L, "1.0.0", "owner-1"); + ReflectionTestUtils.setField(version, "id", 22L); + version.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "soc2", + "standardVersion": "2017", + "controlId": "CC6.1" + } + ] + } + } + """); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillSlugResolutionService.resolve(7L, "demo-skill", "owner-1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .thenReturn(skill); + when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version)); + + var response = service.confirmPublish( + "global", + "demo-skill", + "1.0.0", + "owner-1", + Map.of(7L, NamespaceRole.OWNER), + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + assertThat(response.versionId()).isEqualTo(22L); + assertThat(response.status()).isEqualTo("PUBLISHED"); + verify(skillReviewSubmitService).confirmPublish(11L, 22L, "owner-1", Map.of(7L, NamespaceRole.OWNER)); + verify(auditLogService).record( + eq("owner-1"), + eq("CONFIRM_PUBLISH"), + eq("SKILL_VERSION"), + eq(22L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"snapshotKind\":\"latest_published_entered\"") + ); + verify(auditLogService).record( + eq("owner-1"), + eq("CONFIRM_PUBLISH"), + eq("SKILL_VERSION"), + eq(22L), + isNull(), + eq("127.0.0.1"), + eq("JUnit"), + contains("\"controlId\":\"CC6.1\"") + ); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index 3cd408e4..c99e767e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -294,6 +294,28 @@ class SkillSearchAppServiceTest { assertEquals(List.of("code-generation", "official"), captor.getValue().labelSlugs()); } + @Test + void search_shouldNormalizeAndPassComplianceStandard() { + when(searchQueryService.search(any())) + .thenReturn(new SearchResult(List.of(), 0, 0, 20)); + + service.search( + "skill", + null, + "newest", + 0, + 20, + List.of("official"), + " GDPR ", + "user-9", + Map.of(7L, NamespaceRole.MEMBER) + ); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SearchQuery.class); + verify(searchQueryService).search(captor.capture()); + assertEquals("gdpr", captor.getValue().complianceStandard()); + } + @Test void search_shouldIncludeMemberNamespacesInVisibilityScope() { when(searchQueryService.search(any())) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java index 0c75ca34..b106fb69 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.service.cli; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; @@ -40,8 +41,11 @@ import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; @ExtendWith(MockitoExtension.class) class CliSkillAppServiceTest { @@ -56,6 +60,7 @@ class CliSkillAppServiceTest { @Mock SkillVersionRepository skillVersionRepository; @Mock NamespaceService namespaceService; @Mock RbacService rbacService; + @Mock AuditLogService auditLogService; private CliSkillAppService service; @@ -63,7 +68,7 @@ class CliSkillAppServiceTest { void setUp() { service = new CliSkillAppService( skillSearchAppService, skillQueryService, - skillDownloadService, skillDeleteAppService, skillPublishService); + skillDownloadService, skillDeleteAppService, skillPublishService, auditLogService); } @Test @@ -166,7 +171,8 @@ class CliSkillAppServiceTest { skillQueryService, skillDownloadService, skillDeleteAppService, - skillPublishService + skillPublishService, + auditLogService ); Skill installableSecondMatch = new Skill(1L, "ready-second", "owner-1", SkillVisibility.PUBLIC); @@ -247,12 +253,73 @@ class CliSkillAppServiceTest { given(skillPublishService.publishFromEntries("global", entries, "user-1", SkillVisibility.PUBLIC, Set.of("USER"), false)) .willReturn(new SkillPublishService.PublishResult(1L, "test-skill", mockVersion)); - CliPublishResponse response = service.publish("global", entries, "user-1", SkillVisibility.PUBLIC, Set.of("USER")); + CliPublishResponse response = service.publish( + "global", + entries, + "user-1", + SkillVisibility.PUBLIC, + Set.of("USER"), + new AuditRequestContext("127.0.0.1", "CLI/1.0") + ); assertEquals("global", response.namespace()); assertEquals("test-skill", response.slug()); assertEquals("1.0.0", response.version()); assertEquals("PUBLIC", response.visibility()); + verifyNoInteractions(auditLogService); + } + + @Test + void publish_recordsComplianceSnapshotWhenLatestPublished() { + List entries = List.of( + new PackageEntry("SKILL.md", "name: test".getBytes(), 10, "text/markdown") + ); + SkillVersion version = publishedVersion(1L, 42L, "1.0.0"); + version.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "soc2", + "standardVersion": "2017", + "controlId": "CC6.1" + } + ] + } + } + """); + given(skillPublishService.publishFromEntries("global", entries, "user-1", SkillVisibility.PUBLIC, Set.of("SUPER_ADMIN"), false)) + .willReturn(new SkillPublishService.PublishResult(1L, "test-skill", version)); + + service.publish( + "global", + entries, + "user-1", + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN"), + new AuditRequestContext("127.0.0.1", "CLI/1.0") + ); + + verify(auditLogService).record( + eq("user-1"), + eq("CLI_PUBLISH"), + eq("SKILL_VERSION"), + eq(42L), + isNull(), + eq("127.0.0.1"), + eq("CLI/1.0"), + contains("\"snapshotKind\":\"latest_published_entered\"") + ); + verify(auditLogService).record( + eq("user-1"), + eq("CLI_PUBLISH"), + eq("SKILL_VERSION"), + eq(42L), + isNull(), + eq("127.0.0.1"), + eq("CLI/1.0"), + contains("\"controlId\":\"CC6.1\"") + ); } private boolean requiresInstallableLatest(SearchQuery query) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/ComplianceStandard.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/ComplianceStandard.java new file mode 100644 index 00000000..df452550 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/ComplianceStandard.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.domain.skill.metadata; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Arrays; +import java.util.Optional; + +/** + * Controlled vocabulary for phase-1 compliance mappings. + */ +public enum ComplianceStandard { + MITRE_ATTACK("mitre_attack"), + NIST_CSF("nist_csf"), + GDPR("gdpr"), + HIPAA("hipaa"), + SOC2("soc2"); + + private final String value; + + ComplianceStandard(String value) { + this.value = value; + } + + @JsonValue + public String value() { + return value; + } + + @JsonCreator + public static ComplianceStandard fromValue(String value) { + return findByValue(value) + .orElseThrow(() -> new IllegalArgumentException("Unknown compliance standard: " + value)); + } + + public static Optional findByValue(String value) { + if (value == null) { + return Optional.empty(); + } + return Arrays.stream(values()) + .filter(candidate -> candidate.value.equals(value.trim().toLowerCase(java.util.Locale.ROOT))) + .findFirst(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceAuditDetailFactory.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceAuditDetailFactory.java new file mode 100644 index 00000000..0aea577c --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceAuditDetailFactory.java @@ -0,0 +1,58 @@ +package com.iflytek.skillhub.domain.skill.metadata; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.skill.SkillVersion; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Builds audit detail payloads that carry version-scoped compliance snapshots. + */ +public class SkillComplianceAuditDetailFactory { + + private final ObjectMapper objectMapper; + private final SkillComplianceMetadataService complianceMetadataService; + + public SkillComplianceAuditDetailFactory() { + this(new ObjectMapper(), new SkillComplianceMetadataService()); + } + + SkillComplianceAuditDetailFactory(ObjectMapper objectMapper, + SkillComplianceMetadataService complianceMetadataService) { + this.objectMapper = objectMapper; + this.complianceMetadataService = complianceMetadataService; + } + + public String latestPublishedEntered(SkillVersion version) { + return latestPublishedEntered(version, Map.of()); + } + + public String latestPublishedEntered(SkillVersion version, Map extras) { + return build("latest_published_entered", version, extras); + } + + public String latestPublishedRemoved(SkillVersion version, Map extras) { + return build("latest_published_removed", version, extras); + } + + public String build(String snapshotKind, SkillVersion version, Map extras) { + LinkedHashMap payload = new LinkedHashMap<>(); + payload.put("snapshotKind", snapshotKind); + payload.put("versionId", version.getId()); + payload.put("version", version.getVersion()); + payload.put( + "compliance", + complianceMetadataService.readFromParsedMetadataJson(version.getParsedMetadataJson()) + ); + if (extras != null && !extras.isEmpty()) { + payload.putAll(extras); + } + try { + return objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Failed to serialize compliance audit detail", ex); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMapping.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMapping.java new file mode 100644 index 00000000..d0478388 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMapping.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.domain.skill.metadata; + +/** + * Normalized phase-1 compliance mapping payload stored in parsed metadata and projected to APIs. + */ +public record SkillComplianceMapping( + ComplianceStandard standard, + String standardVersion, + String controlId, + String controlTitle, + String evidenceUrl +) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataService.java new file mode 100644 index 00000000..f15573d0 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataService.java @@ -0,0 +1,260 @@ +package com.iflytek.skillhub.domain.skill.metadata; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.net.URI; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.HashSet; +import java.util.regex.Pattern; + +/** + * Validates and extracts version-scoped compliance mappings from parsed frontmatter metadata. + */ +public class SkillComplianceMetadataService { + + private static final String COMPLIANCE_FIELD = "x-astron-compliance"; + private static final int MAX_ITEMS = 20; + private static final int MAX_STANDARD_VERSION_LENGTH = 32; + private static final int MAX_CONTROL_TITLE_LENGTH = 200; + private static final Pattern CONTROL_ID_PATTERN = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$"); + private static final Set ALLOWED_KEYS = Set.of( + "standard", + "standardVersion", + "controlId", + "controlTitle", + "evidenceUrl" + ); + + private final ObjectMapper objectMapper; + + public SkillComplianceMetadataService() { + this(new ObjectMapper()); + } + + public SkillComplianceMetadataService(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public ParseResult parseFrontmatter(Map frontmatter) { + if (frontmatter == null || frontmatter.isEmpty()) { + return ParseResult.empty(); + } + + Object rawMappings = frontmatter.get(COMPLIANCE_FIELD); + if (rawMappings == null) { + return ParseResult.empty(); + } + if (!(rawMappings instanceof List items) || items.isEmpty()) { + return ParseResult.invalid("x-astron-compliance must be a non-empty array"); + } + + List errors = new ArrayList<>(); + if (items.size() > MAX_ITEMS) { + errors.add("x-astron-compliance must contain at most " + MAX_ITEMS + " items"); + } + + List mappings = new ArrayList<>(); + Set seenKeys = new HashSet<>(); + for (int index = 0; index < items.size(); index++) { + Object rawItem = items.get(index); + if (!(rawItem instanceof Map rawMap)) { + errors.add("x-astron-compliance[" + index + "] must be an object"); + continue; + } + + Map item = normalizeItem(rawMap, index, errors); + if (item == null) { + continue; + } + + ComplianceStandard standard = parseStandard(item, index, errors); + String standardVersion = parseRequiredString(item, index, "standardVersion", MAX_STANDARD_VERSION_LENGTH, errors); + String controlId = parseRequiredControlId(item, index, errors); + String controlTitle = parseOptionalString(item, index, "controlTitle", MAX_CONTROL_TITLE_LENGTH, errors); + String evidenceUrl = parseOptionalAbsoluteUri(item, index, errors); + + if (standard == null || standardVersion == null || controlId == null) { + continue; + } + + String duplicateKey = normalizedDuplicateKey(standard, standardVersion, controlId); + if (!seenKeys.add(duplicateKey)) { + errors.add("x-astron-compliance contains duplicate mapping " + duplicateKey); + continue; + } + + mappings.add(new SkillComplianceMapping( + standard, + standardVersion.trim(), + controlId.trim(), + controlTitle, + evidenceUrl + )); + } + + if (!errors.isEmpty()) { + return new ParseResult(List.of(), List.copyOf(errors)); + } + return new ParseResult(List.copyOf(mappings), List.of()); + } + + public List readFromParsedMetadataJson(String parsedMetadataJson) { + if (parsedMetadataJson == null || parsedMetadataJson.isBlank()) { + return List.of(); + } + try { + Map parsed = objectMapper.readValue( + parsedMetadataJson, + new TypeReference>() {} + ); + Object rawFrontmatter = parsed.get("frontmatter"); + if (!(rawFrontmatter instanceof Map rawMap)) { + return List.of(); + } + Map frontmatter = new LinkedHashMap<>(); + rawMap.forEach((key, value) -> { + if (key instanceof String stringKey) { + frontmatter.put(stringKey, value); + } + }); + ParseResult result = parseFrontmatter(frontmatter); + return result.errors().isEmpty() ? result.mappings() : List.of(); + } catch (Exception ignored) { + return List.of(); + } + } + + private Map normalizeItem(Map rawMap, int index, List errors) { + Map item = new LinkedHashMap<>(); + for (Map.Entry entry : rawMap.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + errors.add("x-astron-compliance[" + index + "] contains a non-string field name"); + return null; + } + item.put(key, entry.getValue()); + } + + for (String key : item.keySet()) { + if (!ALLOWED_KEYS.contains(key)) { + errors.add("x-astron-compliance[" + index + "]." + key + " is not allowed"); + } + } + return item; + } + + private ComplianceStandard parseStandard(Map item, int index, List errors) { + Object rawValue = item.get("standard"); + if (!(rawValue instanceof String value) || value.isBlank()) { + errors.add("x-astron-compliance[" + index + "].standard is required"); + return null; + } + return ComplianceStandard.findByValue(value) + .orElseGet(() -> { + errors.add( + "x-astron-compliance[" + index + "].standard must be one of " + + List.of("mitre_attack", "nist_csf", "gdpr", "hipaa", "soc2") + ); + return null; + }); + } + + private String parseRequiredString(Map item, + int index, + String fieldName, + int maxLength, + List errors) { + Object rawValue = item.get(fieldName); + if (!(rawValue instanceof String value) || value.isBlank()) { + errors.add("x-astron-compliance[" + index + "]." + fieldName + " is required"); + return null; + } + String trimmed = value.trim(); + if (trimmed.length() > maxLength) { + errors.add("x-astron-compliance[" + index + "]." + fieldName + " must be at most " + maxLength + " characters"); + return null; + } + return trimmed; + } + + private String parseRequiredControlId(Map item, int index, List errors) { + String controlId = parseRequiredString(item, index, "controlId", 128, errors); + if (controlId == null) { + return null; + } + if (!CONTROL_ID_PATTERN.matcher(controlId).matches()) { + errors.add("x-astron-compliance[" + index + "].controlId has an invalid format"); + return null; + } + return controlId; + } + + private String parseOptionalString(Map item, + int index, + String fieldName, + int maxLength, + List errors) { + Object rawValue = item.get(fieldName); + if (rawValue == null) { + return null; + } + if (!(rawValue instanceof String value) || value.isBlank()) { + errors.add("x-astron-compliance[" + index + "]." + fieldName + " must be a non-empty string"); + return null; + } + String trimmed = value.trim(); + if (trimmed.length() > maxLength) { + errors.add("x-astron-compliance[" + index + "]." + fieldName + " must be at most " + maxLength + " characters"); + return null; + } + return trimmed; + } + + private String parseOptionalAbsoluteUri(Map item, int index, List errors) { + Object rawValue = item.get("evidenceUrl"); + if (rawValue == null) { + return null; + } + if (!(rawValue instanceof String value) || value.isBlank()) { + errors.add("x-astron-compliance[" + index + "].evidenceUrl must be an absolute URI"); + return null; + } + try { + URI uri = URI.create(value.trim()); + if (!uri.isAbsolute()) { + errors.add("x-astron-compliance[" + index + "].evidenceUrl must be an absolute URI"); + return null; + } + return uri.toString(); + } catch (IllegalArgumentException ex) { + errors.add("x-astron-compliance[" + index + "].evidenceUrl must be an absolute URI"); + return null; + } + } + + private String normalizedDuplicateKey(ComplianceStandard standard, String standardVersion, String controlId) { + return standard.value() + + "/" + + standardVersion.trim().toLowerCase(Locale.ROOT) + + "/" + + controlId.trim().toUpperCase(Locale.ROOT); + } + + public record ParseResult( + List mappings, + List errors + ) { + public static ParseResult empty() { + return new ParseResult(List.of(), List.of()); + } + + public static ParseResult invalid(String error) { + return new ParseResult(List.of(), List.of(error)); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index bbb748f8..e3540f93 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -15,10 +15,12 @@ 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.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceAuditDetailFactory; import com.iflytek.skillhub.storage.ObjectStorageService; import java.time.Clock; import java.time.Instant; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.slf4j.Logger; @@ -47,6 +49,8 @@ public class SkillGovernanceService { private final SecurityScanService securityScanService; private final SkillStorageDeletionCompensationService compensationService; private final Clock clock; + private final SkillComplianceAuditDetailFactory complianceAuditDetailFactory = + new SkillComplianceAuditDetailFactory(); public SkillGovernanceService(SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, @@ -273,7 +277,20 @@ public class SkillGovernanceService { skillRepository.save(skill); } }); - auditLogService.record(actorUserId, "YANK_SKILL_VERSION", "SKILL_VERSION", versionId, null, clientIp, userAgent, jsonReason(reason)); + LinkedHashMap auditExtras = new LinkedHashMap<>(); + if (reason != null && !reason.isBlank()) { + auditExtras.put("reason", reason); + } + auditLogService.record( + actorUserId, + "YANK_SKILL_VERSION", + "SKILL_VERSION", + versionId, + null, + clientIp, + userAgent, + complianceAuditDetailFactory.latestPublishedRemoved(version, auditExtras) + ); eventPublisher.publishEvent(new com.iflytek.skillhub.domain.event.SkillVersionYankedEvent( version.getSkillId(), versionId, actorUserId)); return saved; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 66c5e319..67b0d214 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -11,6 +11,8 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceMapping; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceMetadataService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.storage.ObjectStorageService; @@ -61,6 +63,7 @@ public class SkillQueryService { private final SkillSlugResolutionService skillSlugResolutionService; private final SkillLifecycleProjectionService skillLifecycleProjectionService; private final UserAccountRepository userAccountRepository; + private final SkillComplianceMetadataService complianceMetadataService = new SkillComplianceMetadataService(); public SkillQueryService( NamespaceRepository namespaceRepository, @@ -127,7 +130,8 @@ public class SkillQueryService { Long totalSize, java.time.Instant publishedAt, String parsedMetadataJson, - String manifestJson + String manifestJson, + List complianceMappings ) {} public record SkillVersionCompareDTO( @@ -311,7 +315,8 @@ public class SkillQueryService { skillVersion.getTotalSize(), skillVersion.getPublishedAt(), skillVersion.getParsedMetadataJson(), - skillVersion.getManifestJson() + skillVersion.getManifestJson(), + complianceMetadataService.readFromParsedMetadataJson(skillVersion.getParsedMetadataJson()) ); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java index fb2b7a41..9edbc941 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.domain.skill.validation; import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; +import com.iflytek.skillhub.domain.skill.metadata.SkillComplianceMetadataService; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; import java.util.ArrayList; @@ -18,6 +20,7 @@ public class SkillPackageValidator { private static final Pattern YAML_LINE_COLUMN = Pattern.compile("line\\s+(\\d+),\\s+column\\s+(\\d+)"); private final SkillMetadataParser metadataParser; + private final SkillComplianceMetadataService complianceMetadataService; private final int maxFileCount; private final long maxSingleFileSize; private final long maxTotalPackageSize; @@ -26,6 +29,7 @@ public class SkillPackageValidator { public SkillPackageValidator(SkillMetadataParser metadataParser) { this( metadataParser, + new SkillComplianceMetadataService(), SkillPackagePolicy.MAX_FILE_COUNT, SkillPackagePolicy.MAX_SINGLE_FILE_SIZE, SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE, @@ -38,7 +42,24 @@ public class SkillPackageValidator { long maxSingleFileSize, long maxTotalPackageSize, Set allowedExtensions) { + this( + metadataParser, + new SkillComplianceMetadataService(), + maxFileCount, + maxSingleFileSize, + maxTotalPackageSize, + allowedExtensions + ); + } + + public SkillPackageValidator(SkillMetadataParser metadataParser, + SkillComplianceMetadataService complianceMetadataService, + int maxFileCount, + long maxSingleFileSize, + long maxTotalPackageSize, + Set allowedExtensions) { this.metadataParser = metadataParser; + this.complianceMetadataService = complianceMetadataService; this.maxFileCount = maxFileCount; this.maxSingleFileSize = maxSingleFileSize; this.maxTotalPackageSize = maxTotalPackageSize; @@ -89,7 +110,11 @@ public class SkillPackageValidator { // 2. Validate frontmatter try { String content = new String(skillMd.content()); - metadataParser.parse(content); + SkillMetadata metadata = metadataParser.parse(content); + SkillComplianceMetadataService.ParseResult complianceResult = + complianceMetadataService.parseFrontmatter(metadata.frontmatter()); + complianceResult.errors().forEach(error -> + errors.add("Invalid SKILL.md frontmatter: " + error)); } catch (LocalizedDomainException e) { errors.add("Invalid SKILL.md frontmatter: " + formatMetadataError(e)); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataServiceTest.java new file mode 100644 index 00000000..4c76578f --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillComplianceMetadataServiceTest.java @@ -0,0 +1,117 @@ +package com.iflytek.skillhub.domain.skill.metadata; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SkillComplianceMetadataServiceTest { + + private final SkillComplianceMetadataService service = + new SkillComplianceMetadataService(new ObjectMapper()); + + @Test + void parseFrontmatter_acceptsKnownComplianceMappings() { + Map frontmatter = Map.of( + "x-astron-compliance", + List.of( + Map.of( + "standard", "mitre_attack", + "standardVersion", "v14.1", + "controlId", "T1059", + "controlTitle", "Command and Scripting Interpreter", + "evidenceUrl", "https://example.com/evidence" + ) + ) + ); + + SkillComplianceMetadataService.ParseResult result = service.parseFrontmatter(frontmatter); + + assertThat(result.errors()).isEmpty(); + assertThat(result.mappings()).singleElement() + .extracting( + SkillComplianceMapping::standard, + SkillComplianceMapping::standardVersion, + SkillComplianceMapping::controlId, + SkillComplianceMapping::controlTitle, + SkillComplianceMapping::evidenceUrl + ) + .containsExactly( + ComplianceStandard.MITRE_ATTACK, + "v14.1", + "T1059", + "Command and Scripting Interpreter", + "https://example.com/evidence" + ); + } + + @Test + void parseFrontmatter_rejectsDuplicateMappingsAfterNormalization() { + Map frontmatter = Map.of( + "x-astron-compliance", + List.of( + Map.of( + "standard", "gdpr", + "standardVersion", "2024", + "controlId", "article-17" + ), + Map.of( + "standard", "gdpr", + "standardVersion", " 2024 ", + "controlId", "ARTICLE-17" + ) + ) + ); + + SkillComplianceMetadataService.ParseResult result = service.parseFrontmatter(frontmatter); + + assertThat(result.mappings()).isEmpty(); + assertThat(result.errors()).contains("x-astron-compliance contains duplicate mapping gdpr/2024/ARTICLE-17"); + } + + @Test + void parseFrontmatter_rejectsNonArrayComplianceField() { + Map frontmatter = Map.of( + "x-astron-compliance", + "gdpr:Article-17" + ); + + SkillComplianceMetadataService.ParseResult result = service.parseFrontmatter(frontmatter); + + assertThat(result.mappings()).isEmpty(); + assertThat(result.errors()).contains("x-astron-compliance must be a non-empty array"); + } + + @Test + void readFromParsedMetadataJson_extractsMappingsFromStoredSkillMetadata() { + String parsedMetadataJson = """ + { + "name": "demo", + "description": "demo", + "version": "1.0.0", + "body": "Body", + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "soc2", + "standardVersion": "2017", + "controlId": "CC6.1" + } + ] + } + } + """; + + List mappings = service.readFromParsedMetadataJson(parsedMetadataJson); + + assertThat(mappings).singleElement() + .extracting( + SkillComplianceMapping::standard, + SkillComplianceMapping::standardVersion, + SkillComplianceMapping::controlId + ) + .containsExactly(ComplianceStandard.SOC2, "2017", "CC6.1"); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index b6f3faff..09d972b7 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.contains; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.doThrow; @@ -143,6 +144,19 @@ class SkillGovernanceServiceTest { void yankVersion_setsYankedStatus() { SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setParsedMetadataJson(""" + { + "frontmatter": { + "x-astron-compliance": [ + { + "standard": "gdpr", + "standardVersion": "2024", + "controlId": "Article-17" + } + ] + } + } + """); given(skillVersionRepository.findById(22L)).willReturn(Optional.of(version)); given(skillVersionRepository.save(version)).willReturn(version); given(skillRepository.findById(2L)).willReturn(Optional.empty()); @@ -152,7 +166,26 @@ class SkillGovernanceServiceTest { assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.YANKED); assertThat(result.getYankedBy()).isEqualTo("admin"); assertThat(result.getYankedAt()).isEqualTo(Instant.now(CLOCK)); - verify(auditLogService).record("admin", "YANK_SKILL_VERSION", "SKILL_VERSION", 22L, null, "127.0.0.1", "JUnit", "{\"reason\":\"broken\"}"); + verify(auditLogService).record( + org.mockito.ArgumentMatchers.eq("admin"), + org.mockito.ArgumentMatchers.eq("YANK_SKILL_VERSION"), + org.mockito.ArgumentMatchers.eq("SKILL_VERSION"), + org.mockito.ArgumentMatchers.eq(22L), + org.mockito.ArgumentMatchers.eq(null), + org.mockito.ArgumentMatchers.eq("127.0.0.1"), + org.mockito.ArgumentMatchers.eq("JUnit"), + contains("\"reason\":\"broken\"") + ); + verify(auditLogService).record( + org.mockito.ArgumentMatchers.eq("admin"), + org.mockito.ArgumentMatchers.eq("YANK_SKILL_VERSION"), + org.mockito.ArgumentMatchers.eq("SKILL_VERSION"), + org.mockito.ArgumentMatchers.eq(22L), + org.mockito.ArgumentMatchers.eq(null), + org.mockito.ArgumentMatchers.eq("127.0.0.1"), + org.mockito.ArgumentMatchers.eq("JUnit"), + contains("\"snapshotKind\":\"latest_published_removed\"") + ); } @Test diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java index c2b6b63c..a97787b5 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java @@ -189,6 +189,30 @@ class SkillPackageValidatorTest { assertTrue(result.errors().isEmpty()); } + @Test + void testInvalidComplianceMetadataRejected() { + String skillMdContent = """ + --- + name: compliance-skill + description: Skill with malformed compliance metadata + version: 1.0.0 + x-astron-compliance: gdpr + --- + Body + """; + + List entries = List.of( + new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown") + ); + + ValidationResult result = validator.validate(entries); + + assertFalse(result.passed()); + assertTrue(result.errors().stream().anyMatch(error -> + error.contains("Invalid SKILL.md frontmatter") + && error.contains("x-astron-compliance must be a non-empty array"))); + } + @Test void testPackageTooLarge() { // Use a custom validator with 2KB total limit to test the logic diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java index 5a54d0a6..3b96642f 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java @@ -13,8 +13,21 @@ public record SearchQuery( int page, int size, List labelSlugs, + String complianceStandard, boolean requireInstallableLatest ) { + public SearchQuery( + String keyword, + Long namespaceId, + SearchVisibilityScope visibilityScope, + String sortBy, + int page, + int size, + List labelSlugs, + boolean requireInstallableLatest) { + this(keyword, namespaceId, visibilityScope, sortBy, page, size, labelSlugs, null, requireInstallableLatest); + } + public SearchQuery( String keyword, Long namespaceId, @@ -23,7 +36,7 @@ public record SearchQuery( int page, int size, List labelSlugs) { - this(keyword, namespaceId, visibilityScope, sortBy, page, size, labelSlugs, false); + this(keyword, namespaceId, visibilityScope, sortBy, page, size, labelSlugs, null, false); } public SearchQuery( @@ -33,6 +46,6 @@ public record SearchQuery( String sortBy, int page, int size) { - this(keyword, namespaceId, visibilityScope, sortBy, page, size, List.of(), false); + this(keyword, namespaceId, visibilityScope, sortBy, page, size, List.of(), null, false); } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index 64015844..55081655 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -80,8 +80,10 @@ public class PostgresFullTextQueryService implements SearchQueryService { public SearchResult search(SearchQuery query) { String normalizedKeyword = normalizeKeyword(query.keyword()); String tsQuery = buildPrefixTsQuery(normalizedKeyword); + String complianceStandard = normalizeComplianceStandard(query.complianceStandard()); boolean hasKeyword = normalizedKeyword != null; boolean hasTsQuery = tsQuery != null; + boolean hasComplianceStandard = complianceStandard != null; boolean useRelevanceOrdering = "relevance".equals(query.sortBy()) && hasKeyword; boolean useShortPrefixTitleSearch = hasTsQuery && isShortAsciiPrefixSearch(normalizedKeyword); boolean useSemanticRerank = semanticEnabled @@ -107,7 +109,7 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("FROM skill_search_document d "); sql.append("JOIN skill s ON s.id = d.skill_id "); sql.append("JOIN namespace n ON n.id = d.namespace_id "); - if (query.requireInstallableLatest()) { + if (query.requireInstallableLatest() || hasComplianceStandard) { sql.append("JOIN skill_version latest ON latest.id = s.latest_version_id "); } sql.append("WHERE 1=1 "); @@ -128,6 +130,9 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("AND latest.download_ready = TRUE "); sql.append("AND latest.yanked_at IS NULL "); } + if (hasComplianceStandard) { + sql.append("AND (latest.parsed_metadata_json -> 'frontmatter' -> 'x-astron-compliance') @> CAST(:complianceFilter AS jsonb) "); + } sql.append("AND (n.status <> 'ARCHIVED' "); if (query.visibilityScope().userId() != null) { sql.append("OR d.namespace_id IN :memberNamespaceIds "); @@ -203,6 +208,9 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (query.labelSlugs() != null && !query.labelSlugs().isEmpty()) { nativeQuery.setParameter("labelSlugs", query.labelSlugs()); } + if (hasComplianceStandard) { + nativeQuery.setParameter("complianceFilter", "[{\"standard\":\"" + complianceStandard + "\"}]"); + } if (hasKeyword) { if (hasTsQuery) { @@ -247,6 +255,9 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (query.labelSlugs() != null && !query.labelSlugs().isEmpty()) { countQuery.setParameter("labelSlugs", query.labelSlugs()); } + if (hasComplianceStandard) { + countQuery.setParameter("complianceFilter", "[{\"standard\":\"" + complianceStandard + "\"}]"); + } if (hasKeyword) { if (hasTsQuery) { @@ -264,6 +275,13 @@ public class PostgresFullTextQueryService implements SearchQueryService { return new SearchResult(skillIds, total, query.page(), query.size()); } + private String normalizeComplianceStandard(String complianceStandard) { + if (complianceStandard == null || complianceStandard.isBlank()) { + return null; + } + return complianceStandard.trim().toLowerCase(Locale.ROOT); + } + private List rerankBySemanticSimilarity(List candidateSkillIds, String normalizedKeyword, int requestedOffset, diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index c84c6cbd..bf11bc84 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -295,6 +295,42 @@ class PostgresFullTextQueryServiceTest { verify(countQuery).setParameter("labelSlugs", List.of("code-generation", "official")); } + @Test + void complianceStandardFilterShouldBeAppliedToSearchAndCountQueries() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "review", + null, + SearchVisibilityScope.anonymous(), + "relevance", + 0, + 20, + List.of("official"), + "gdpr", + false + )); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()).contains( + "(latest.parsed_metadata_json -> 'frontmatter' -> 'x-astron-compliance') @> CAST(:complianceFilter AS jsonb)" + ); + verify(nativeQuery).setParameter("complianceFilter", "[{\"standard\":\"gdpr\"}]"); + verify(countQuery).setParameter("complianceFilter", "[{\"standard\":\"gdpr\"}]"); + } + @Test void downloadsSortShouldNotBindRelevanceOnlyParameters() { EntityManager entityManager = mock(EntityManager.class); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a99ba1a9..b6394e78 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -4161,6 +4161,14 @@ export interface components { publishedAt?: string; parsedMetadataJson?: string; manifestJson?: string; + complianceMappings?: components["schemas"]["SkillComplianceMappingResponse"][]; + }; + SkillComplianceMappingResponse: { + standard?: string; + standardVersion?: string; + controlId?: string; + controlTitle?: string; + evidenceUrl?: string; }; ApiResponseSkillVersionCompareResponse: { /** Format: int32 */ @@ -8508,6 +8516,7 @@ export interface operations { q?: string; namespace?: string; label?: string[]; + complianceStandard?: string; sort?: string; page?: number; size?: number; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 60bef288..fd1ab1ee 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -255,6 +255,14 @@ export interface SkillVersion { downloadAvailable: boolean } +export interface SkillComplianceMapping { + standard: string + standardVersion: string + controlId: string + controlTitle?: string + evidenceUrl?: string +} + export interface SkillVersionDetail { id: number version: string @@ -265,6 +273,7 @@ export interface SkillVersionDetail { publishedAt: string parsedMetadataJson?: string manifestJson?: string + complianceMappings: SkillComplianceMapping[] } export interface SkillFile { @@ -328,6 +337,7 @@ export interface SearchParams { q?: string namespace?: string label?: string + complianceStandard?: string sort?: string page?: number size?: number diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index c9b3bac8..9104a2d6 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -199,11 +199,12 @@ const searchRoute = createRoute({ getParentRoute: () => rootRoute, path: 'search', component: SearchPage, - validateSearch: (search: Record): { q: string; namespace?: string; label?: string; sort: string; page: number; starredOnly: boolean } => { + validateSearch: (search: Record): { q: string; namespace?: string; label?: string; complianceStandard?: string; sort: string; page: number; starredOnly: boolean } => { return { q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''), namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace.replace(/^@/, '') : undefined, label: typeof search.label === 'string' && search.label ? search.label : undefined, + complianceStandard: typeof search.complianceStandard === 'string' && search.complianceStandard ? search.complianceStandard : undefined, sort: (search.sort as string) || 'newest', page: Number(search.page) || 0, starredOnly: search.starredOnly === true || search.starredOnly === 'true', @@ -227,8 +228,9 @@ const namespaceRoute = createRoute({ const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace/$slug', - validateSearch: (search: Record): { returnTo?: string } => ({ + validateSearch: (search: Record): { returnTo?: string; version?: string } => ({ returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined, + version: typeof search.version === 'string' && search.version ? search.version : undefined, }), component: SkillDetailPage, }) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 2e7d4f28..6ffc3dba 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -176,6 +176,16 @@ "filters": { "label": "Filter:" }, + "compliance": { + "label": "Compliance:", + "options": { + "mitre_attack": "MITRE ATT&CK", + "nist_csf": "NIST CSF", + "gdpr": "GDPR", + "hipaa": "HIPAA", + "soc2": "SOC 2" + } + }, "sort": { "label": "Sort:", "relevance": "Relevance", @@ -822,6 +832,13 @@ "noVersions": "No versions", "fileCount": "{{count}} files", "version": "Version", + "complianceSectionTitle": "Declared Alignment", + "complianceSectionDescription": "Version {{version}} declares the following control mappings.", + "complianceEmptyTitle": "No declared mappings", + "complianceEmptyDescription": "This version does not declare alignment to a compliance standard yet.", + "complianceStandardVersion": "Standard version: {{version}}", + "complianceControlId": "Control ID: {{controlId}}", + "complianceEvidenceLink": "Open evidence", "downloads": "Downloads", "rating": "Rating", "ratingNone": "None", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 2281121a..7234cea8 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -176,6 +176,16 @@ "filters": { "label": "筛选:" }, + "compliance": { + "label": "合规标准:", + "options": { + "mitre_attack": "MITRE ATT&CK", + "nist_csf": "NIST CSF", + "gdpr": "GDPR", + "hipaa": "HIPAA", + "soc2": "SOC 2" + } + }, "sort": { "label": "排序:", "relevance": "相关性", @@ -822,6 +832,13 @@ "noVersions": "暂无版本", "fileCount": "{{count}} 个文件", "version": "版本", + "complianceSectionTitle": "声明的对齐关系", + "complianceSectionDescription": "版本 {{version}} 声明了以下控制项映射。", + "complianceEmptyTitle": "暂无声明映射", + "complianceEmptyDescription": "这个版本暂未声明任何合规标准映射。", + "complianceStandardVersion": "标准版本:{{version}}", + "complianceControlId": "控制项 ID:{{controlId}}", + "complianceEvidenceLink": "查看证据链接", "downloads": "下载量", "rating": "评分", "ratingNone": "暂无", diff --git a/web/src/pages/search.test.tsx b/web/src/pages/search.test.tsx index aac629a0..0ee0b669 100644 --- a/web/src/pages/search.test.tsx +++ b/web/src/pages/search.test.tsx @@ -134,6 +134,7 @@ describe('SearchPage', () => { q: 'agent', namespace: 'team-ai', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'downloads', page: 1, starredOnly: false, @@ -156,6 +157,7 @@ describe('SearchPage', () => { expect(html).toContain('Code Generation') expect(findButton('Code Generation').variant).toBe('default') expect(findButton('Official').variant).toBe('outline') + expect(findButton('search.compliance.options.gdpr').variant).toBe('default') }) it('toggles the selected label off and resets paging', () => { @@ -169,6 +171,7 @@ describe('SearchPage', () => { q: 'agent', namespace: 'team-ai', label: '', + complianceStandard: 'gdpr', sort: 'downloads', page: 0, starredOnly: false, @@ -187,6 +190,7 @@ describe('SearchPage', () => { q: 'agent', namespace: 'team-ai', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'newest', page: 0, starredOnly: false, @@ -206,6 +210,7 @@ describe('SearchPage', () => { q: 'agent', namespace: 'team-ai', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'downloads', page: 2, starredOnly: false, @@ -217,6 +222,7 @@ describe('SearchPage', () => { q: 'agent', namespace: 'team-ai', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'downloads', page: 0, starredOnly: true, @@ -231,6 +237,7 @@ describe('SearchPage', () => { q: 'agent', namespace: 'team-ai', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'downloads', page: 1, size: 12, @@ -248,6 +255,7 @@ describe('SearchPage', () => { q: 'onboarding', namespace: 'product-team', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'downloads', page: 0, starredOnly: false, @@ -256,6 +264,25 @@ describe('SearchPage', () => { }) }) + it('toggles the compliance filter and resets paging', () => { + renderToStaticMarkup() + + findButton('search.compliance.options.soc2').onClick?.() + + expect(navigateMock).toHaveBeenCalledWith({ + to: '/search', + search: { + q: 'agent', + namespace: 'team-ai', + label: 'code-generation', + complianceStandard: 'soc2', + sort: 'downloads', + page: 0, + starredOnly: false, + }, + }) + }) + it('renders the default skill list when the empty query still returns items', () => { useSearchMock.mockReturnValue({ q: '', diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index ec23d319..ea739fe8 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -17,6 +17,7 @@ import { Button } from '@/shared/ui/button' import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style' const PAGE_SIZE = 12 +const COMPLIANCE_STANDARD_OPTIONS = ['mitre_attack', 'nist_csf', 'gdpr', 'hipaa', 'soc2'] as const function blurActiveElement() { if (typeof document === 'undefined' || typeof HTMLElement === 'undefined') { @@ -93,12 +94,32 @@ export function SearchPage() { const q = normalizeSearchQuery(searchParams.q || '') const namespace = (searchParams.namespace || '').replace(/^@/, '') const selectedLabel = searchParams.label || '' + const complianceStandard = searchParams.complianceStandard || '' const sort = searchParams.sort || 'newest' const page = searchParams.page ?? 0 const starredOnly = searchParams.starredOnly ?? false const [queryInput, setQueryInput] = useState(formatNamespaceSearchInput(namespace, q)) const previousPageRef = useRef(page) + const buildSearchState = (overrides: Partial<{ + q: string + namespace: string + label: string + complianceStandard: string + sort: string + page: number + starredOnly: boolean + }> = {}) => ({ + q, + namespace, + label: selectedLabel, + complianceStandard, + sort, + page, + starredOnly, + ...overrides, + }) + useEffect(() => { setQueryInput(formatNamespaceSearchInput(namespace, q)) }, [namespace, q]) @@ -121,6 +142,7 @@ export function SearchPage() { q, namespace: namespace || undefined, label: selectedLabel || undefined, + complianceStandard: complianceStandard || undefined, sort, page, size: PAGE_SIZE, @@ -142,44 +164,77 @@ export function SearchPage() { if (!parsedInput.query && !parsedInput.namespace) { startTransition(() => { - navigate({ to: '/search', search: { q: '', namespace: '', label: selectedLabel, sort, page: 0, starredOnly }, replace: page === 0 }) + navigate({ + to: '/search', + search: { + q: '', + namespace: '', + label: selectedLabel, + complianceStandard, + sort, + page: 0, + starredOnly, + }, + replace: page === 0, + }) }) return } const timeoutId = window.setTimeout(() => { startTransition(() => { - navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) + navigate({ + to: '/search', + search: { + q: parsedInput.query, + namespace: parsedInput.namespace, + label: selectedLabel, + complianceStandard, + sort, + page: 0, + starredOnly, + }, + replace: true, + }) }) }, 250) return () => window.clearTimeout(timeoutId) - }, [navigate, namespace, page, q, queryInput, selectedLabel, sort, starredOnly]) + }, [complianceStandard, navigate, namespace, page, q, queryInput, selectedLabel, sort, starredOnly]) const handleSearch = (query: string) => { const parsedInput = parseNamespaceSearchInput(query) setQueryInput(query) startTransition(() => { - navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) + navigate({ + to: '/search', + search: buildSearchState({ q: parsedInput.query, namespace: parsedInput.namespace, page: 0 }), + replace: true, + }) }) } const handleSortChange = (newSort: string) => { - navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort: newSort, page: 0, starredOnly } }) + navigate({ to: '/search', search: buildSearchState({ sort: newSort, page: 0 }) }) } const handlePageChange = (newPage: number) => { blurActiveElement() - navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: newPage, starredOnly } }) + navigate({ to: '/search', search: buildSearchState({ page: newPage }) }) } const handleLabelToggle = (label: string) => { const nextLabel = selectedLabel === label ? '' : label - navigate({ to: '/search', search: { q, namespace, label: nextLabel, sort, page: 0, starredOnly } }) + navigate({ to: '/search', search: buildSearchState({ label: nextLabel, page: 0 }) }) + } + + const handleComplianceToggle = (standard: string) => { + const nextStandard = complianceStandard === standard ? '' : standard + navigate({ to: '/search', search: buildSearchState({ complianceStandard: nextStandard, page: 0 }) }) } const handleNamespaceClear = () => { - navigate({ to: '/search', search: { q, namespace: '', label: selectedLabel, sort, page: 0, starredOnly } }) + navigate({ to: '/search', search: buildSearchState({ namespace: '', page: 0 }) }) } const handleStarredToggle = () => { @@ -193,7 +248,7 @@ export function SearchPage() { return } - navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: 0, starredOnly: !starredOnly } }) + navigate({ to: '/search', search: buildSearchState({ page: 0, starredOnly: !starredOnly }) }) } const handleSkillClick = (namespace: string, slug: string) => { @@ -301,6 +356,21 @@ export function SearchPage() { ) : null} + {!starredOnly && ( +
+ {t('search.compliance.label')} + {COMPLIANCE_STANDARD_OPTIONS.map((standard) => ( + + ))} +
+ )} {/* Results */} diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index 1c6374ce..74b71c0d 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -16,9 +16,11 @@ const hasRoleMock = vi.fn<(role: string) => boolean>((role: string) => role === const useSkillDetailMock = vi.fn() const useSkillLabelsMock = vi.fn() const useSkillVersionsMock = vi.fn() +const useSkillVersionDetailMock = vi.fn() const useSkillFilesMock = vi.fn() const useSkillReadmeMock = vi.fn() const useSkillFileMock = vi.fn() +let detailSearchState: { returnTo?: string; version?: string } = { returnTo: '/dashboard/skills' } let authState: { user: { userId: string; platformRoles: string[] } | null hasRole: (role: string) => boolean @@ -31,7 +33,7 @@ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, useParams: () => ({ namespace: 'global', slug: 'demo-skill' }), useRouterState: () => ({ pathname: '/space/global/demo-skill', searchStr: '', hash: '' }), - useSearch: () => ({ returnTo: '/dashboard/skills' }), + useSearch: () => detailSearchState, })) vi.mock('react-i18next', async () => { @@ -159,7 +161,7 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useAttachSkillLabel: () => ({ mutate: vi.fn(), isPending: false }), useDetachSkillLabel: () => ({ mutate: vi.fn(), isPending: false }), useSkillVersions: (...args: unknown[]) => useSkillVersionsMock(...args), - useSkillVersionDetail: () => ({ data: undefined }), + useSkillVersionDetail: (...args: unknown[]) => useSkillVersionDetailMock(...args), useSkillFiles: (...args: unknown[]) => useSkillFilesMock(...args), useSkillReadme: (...args: unknown[]) => useSkillReadmeMock(...args), useSkillFile: (...args: unknown[]) => useSkillFileMock(...args), @@ -236,8 +238,10 @@ describe('SkillDetailPage', () => { useSkillFilesMock.mockReset() useSkillReadmeMock.mockReset() useSkillFileMock.mockReset() + useSkillVersionDetailMock.mockReset() toastMocks.success.mockReset() toastMocks.error.mockReset() + detailSearchState = { returnTo: '/dashboard/skills' } hasRoleMock.mockImplementation((role: string) => role === 'USER') authState = { user: { userId: 'owner-1', platformRoles: ['USER'] }, @@ -269,6 +273,20 @@ describe('SkillDetailPage', () => { useSkillFilesMock.mockReturnValue({ data: [] }) useSkillReadmeMock.mockReturnValue({ data: '# Demo', error: null }) useSkillFileMock.mockReturnValue({ data: null, isLoading: false, error: null }) + useSkillVersionDetailMock.mockReturnValue({ + data: { + id: 10, + version: '1.0.0', + status: 'PUBLISHED', + changelog: '', + fileCount: 1, + totalSize: 12, + publishedAt: '2026-03-20T00:00:00Z', + parsedMetadataJson: '{}', + manifestJson: '[]', + complianceMappings: [], + }, + }) }) it('shows hard delete action for the skill owner', () => { @@ -314,6 +332,65 @@ describe('SkillDetailPage', () => { expect(html).not.toContain('skillDetail.deleteSkill') }) + it('renders version-scoped compliance mappings for the selected version from route search', () => { + detailSearchState = { returnTo: '/dashboard/skills', version: '0.9.0' } + useSkillVersionsMock.mockReturnValue({ + data: [ + { + id: 10, + version: '1.0.0', + status: 'PUBLISHED', + changelog: '', + fileCount: 1, + totalSize: 12, + publishedAt: '2026-03-20T00:00:00Z', + downloadAvailable: true, + }, + { + id: 9, + version: '0.9.0', + status: 'PUBLISHED', + changelog: '', + fileCount: 1, + totalSize: 12, + publishedAt: '2026-03-19T00:00:00Z', + downloadAvailable: true, + }, + ], + }) + useSkillVersionDetailMock.mockImplementation((_namespace: string, _slug: string, version?: string) => ({ + data: version === '0.9.0' + ? { + id: 9, + version: '0.9.0', + status: 'PUBLISHED', + changelog: '', + fileCount: 1, + totalSize: 12, + publishedAt: '2026-03-19T00:00:00Z', + parsedMetadataJson: '{}', + manifestJson: '[]', + complianceMappings: [ + { + standard: 'gdpr', + standardVersion: '2024', + controlId: 'Article-17', + controlTitle: 'Right to erasure', + evidenceUrl: 'https://example.com/evidence', + }, + ], + } + : undefined, + })) + + const html = renderToStaticMarkup() + + expect(useSkillVersionDetailMock).toHaveBeenCalledWith('global', 'demo-skill', '0.9.0', true) + expect(html).toContain('skillDetail.complianceSectionTitle') + expect(html).toContain('Right to erasure') + expect(html).toContain('Article-17') + }) + it('shows the label management panel for a user who can manage the skill lifecycle', () => { useSkillDetailMock.mockReturnValue({ data: createSkill({ diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 77b0c291..4b19dbe4 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -8,7 +8,7 @@ import { resolvePackageRelativeLink } from '@/features/skill/package-relative-li import { FileTree } from '@/features/skill/file-tree' import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' import type { FileTreeNode } from '@/features/skill/file-tree-builder' -import type { SkillFile } from '@/api/types' +import type { SkillComplianceMapping, SkillFile } from '@/api/types' import { InstallCommand } from '@/features/skill/install-command' import { ShareButton } from '@/features/skill/share-button' import { SkillLabelPanel } from '@/features/skill/skill-label-panel' @@ -103,6 +103,10 @@ function createPackageFilePreviewNode(file: SkillFile): FileTreeNode { } } +function renderComplianceMappingTitle(mapping: SkillComplianceMapping) { + return mapping.controlTitle || mapping.controlId +} + function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | null { if (error.serverMessageKey === 'promotion.duplicate_pending') { return 'promotion.duplicate_pending' @@ -160,8 +164,16 @@ export function SkillDetailPage() { const headlineVersion = skill ? getHeadlineVersion(skill) : null const publishedVersion = skill ? getPublishedVersion(skill) : null const ownerPreviewVersion = skill ? getOwnerPreviewVersion(skill) : null - const selectedVersion = headlineVersion?.version ?? versions?.[0]?.version - const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion) ?? versions?.[0] + const requestedVersion = search.version + const requestedVersionEntry = requestedVersion + ? versions?.find((version) => version.version === requestedVersion) + : undefined + const headlineVersionEntry = headlineVersion + ? versions?.find((version) => version.version === headlineVersion.version) + : undefined + const selectedVersionEntry = requestedVersionEntry ?? headlineVersionEntry ?? versions?.[0] + const selectedVersion = selectedVersionEntry?.version ?? requestedVersion ?? headlineVersion?.version ?? versions?.[0]?.version + const { data: selectedVersionDetail } = useSkillVersionDetail(qns, qslug, selectedVersion, skillReady) const { data: files } = useSkillFiles(qns, qslug, selectedVersion, skillReady) const documentationPath = resolveDocumentationFilePath(files) const { data: readme, error: readmeError } = useSkillReadme(qns, qslug, selectedVersion, documentationPath, skillReady) @@ -194,6 +206,7 @@ export function SkillDetailPage() { const canHardDeleteSkill = Boolean(skill && user && (skill.ownerId === user.userId || hasRole('SUPER_ADMIN'))) const canManageLabels = Boolean(skill && user && (skill.canManageLifecycle || hasRole('SUPER_ADMIN'))) const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' && (selectedVersionEntry?.downloadAvailable ?? false) + const selectedVersionComplianceMappings = selectedVersionDetail?.complianceMappings ?? [] useEffect(() => { // Recompute collapse rules whenever rendered documentation height changes so the page can keep @@ -261,6 +274,14 @@ export function SkillDetailPage() { queryClient.invalidateQueries({ queryKey: ['skills'] }) } + const handleVersionSelect = (version: string) => { + navigate({ + to: '/space/$namespace/$slug', + params: { namespace, slug }, + search: { returnTo: search.returnTo, version }, + }) + } + const hideMutation = useMutation({ mutationFn: () => adminApi.hideSkill(skill!.id), onSuccess: refreshSkill, @@ -945,9 +966,18 @@ export function SkillDetailPage() {
- + {version.status && ( {resolveVersionStatusLabel(version.status)} @@ -1077,7 +1107,7 @@ export function SkillDetailPage() {
{t('skillDetail.version')}
- {headlineVersion ? `v${headlineVersion.version}` : '—'} + {selectedVersionEntry ? `v${selectedVersionEntry.version}` : '—'}
@@ -1218,6 +1248,55 @@ export function SkillDetailPage() { )} + {selectedVersionEntry && ( + +
+
+ {t('skillDetail.complianceSectionTitle')} +
+

+ {t('skillDetail.complianceSectionDescription', { version: selectedVersionEntry.version })} +

+
+ {selectedVersionComplianceMappings.length > 0 ? ( +
+ {selectedVersionComplianceMappings.map((mapping) => ( +
+
+ {t(`search.compliance.options.${mapping.standard}`)} +
+
+ {renderComplianceMappingTitle(mapping)} +
+
+
{t('skillDetail.complianceStandardVersion', { version: mapping.standardVersion })}
+
{t('skillDetail.complianceControlId', { controlId: mapping.controlId })}
+ {mapping.evidenceUrl ? ( + + {t('skillDetail.complianceEvidenceLink')} + + ) : null} +
+
+ ))} +
+ ) : ( +
+
{t('skillDetail.complianceEmptyTitle')}
+

{t('skillDetail.complianceEmptyDescription')}

+
+ )} +
+ )} + { q: ' hello world ', namespace: '@team-ai', label: 'code-generation', + complianceStandard: 'gdpr', sort: 'relevance', page: 2, size: 12, - })).toBe('/api/web/skills?q=hello+world&namespace=team-ai&label=code-generation&sort=relevance&page=2&size=12') + })).toBe('/api/web/skills?q=hello+world&namespace=team-ai&label=code-generation&complianceStandard=gdpr&sort=relevance&page=2&size=12') }) it('returns the base skills endpoint when no search params are provided', () => { diff --git a/web/src/shared/hooks/skill-query-helpers.ts b/web/src/shared/hooks/skill-query-helpers.ts index 24a9d745..4ae1a05b 100644 --- a/web/src/shared/hooks/skill-query-helpers.ts +++ b/web/src/shared/hooks/skill-query-helpers.ts @@ -19,6 +19,10 @@ export function buildSkillSearchUrl(params: SearchParams) { queryParams.append('label', params.label) } + if (params.complianceStandard) { + queryParams.append('complianceStandard', params.complianceStandard) + } + if (params.sort) { queryParams.append('sort', params.sort) }