From 520309902155f4ae4b4a0daf39392c0319b4bc52 Mon Sep 17 00:00:00 2001 From: paradoxgacsd <3154222708@qq.com> Date: Sat, 16 May 2026 14:16:21 +0800 Subject: [PATCH 1/2] feat(skill-bundle): skill bundle aggregate, draft build, review and detail Adds the skill bundle module described in the design doc: - V42 migration: skill_bundle aggregate, skill_bundle_version with manifest/lock JSON, skill_bundle_item snapshots, validation results, review tasks, social tables (star/rating/comment/download_event), and search index. - Domain layer: SkillBundle, SkillBundleVersion, SkillBundleItem, SkillBundleReviewTask aggregates with status enums, draft service enforcing item uniqueness / project-type and role-tag rules / registry version snapshotting, review service driving the DRAFT -> PENDING_REVIEW -> PUBLISHED|REJECTED state machine with optimistic-lock and self-review guards. - Infra layer: JPA repositories with paged queries and bulk updates. - App layer: JpaSkillBundleItemSourceResolver pulls published skill metadata; SkillBundleAppService converts payloads, resolves the visible version, and exposes detail and download endpoints. - HTTP: /api/v1/skill-bundles (build draft, get detail, submit review, download accounting) and admin /api/v1/skill-bundle-reviews (approve / reject). - Frontend: skill-bundle-detail page, react-query hooks and a thin envelope-aware fetch wrapper. - Tests: domain-level tests for draft validation and review state machine, vitest coverage for the front-end fetch wrapper. --- .../skillhub/config/DomainBeanConfig.java | 21 +++ .../admin/SkillBundleReviewController.java | 46 +++++ .../portal/SkillBundleController.java | 64 +++++++ .../bundle/BuildSkillBundleDraftRequest.java | 32 ++++ .../dto/bundle/SkillBundleDetailResponse.java | 61 ++++++ .../SkillBundleReviewActionRequest.java | 7 + .../bundle/SkillBundleVersionResponse.java | 20 ++ .../JpaSkillBundleItemSourceResolver.java | 53 ++++++ .../service/bundle/SkillBundleAppService.java | 158 ++++++++++++++++ .../db/migration/V42__skill_bundle_tables.sql | 163 ++++++++++++++++ .../domain/bundle/BundleItemSourceType.java | 13 ++ .../domain/bundle/BundleValidationStatus.java | 12 ++ .../skillhub/domain/bundle/SkillBundle.java | 124 ++++++++++++ .../bundle/SkillBundleDraftService.java | 124 ++++++++++++ .../domain/bundle/SkillBundleException.java | 11 ++ .../domain/bundle/SkillBundleItem.java | 100 ++++++++++ .../bundle/SkillBundleItemRepository.java | 12 ++ .../domain/bundle/SkillBundleRepository.java | 21 +++ .../bundle/SkillBundleReviewService.java | 111 +++++++++++ .../domain/bundle/SkillBundleReviewTask.java | 70 +++++++ .../SkillBundleReviewTaskRepository.java | 22 +++ .../domain/bundle/SkillBundleType.java | 17 ++ .../domain/bundle/SkillBundleVersion.java | 98 ++++++++++ .../bundle/SkillBundleVersionRepository.java | 16 ++ .../bundle/SkillBundleVersionStatus.java | 14 ++ .../skillhub/domain/bundle/package-info.java | 4 + .../bundle/SkillBundleDraftServiceTest.java | 159 ++++++++++++++++ .../bundle/SkillBundleReviewServiceTest.java | 177 ++++++++++++++++++ .../jpa/SkillBundleItemJpaRepository.java | 23 +++ .../infra/jpa/SkillBundleJpaRepository.java | 39 ++++ .../SkillBundleReviewTaskJpaRepository.java | 41 ++++ .../jpa/SkillBundleVersionJpaRepository.java | 23 +++ web/src/features/skill-bundle/api.test.ts | 115 ++++++++++++ web/src/features/skill-bundle/api.ts | 117 ++++++++++++ web/src/features/skill-bundle/hooks.ts | 36 ++++ web/src/pages/bundles/skill-bundle-detail.tsx | 104 ++++++++++ 36 files changed, 2228 insertions(+) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/SkillBundleReviewController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillBundleController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/BuildSkillBundleDraftRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/SkillBundleDetailResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/SkillBundleReviewActionRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/SkillBundleVersionResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/JpaSkillBundleItemSourceResolver.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillBundleAppService.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V42__skill_bundle_tables.sql create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/BundleItemSourceType.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/BundleValidationStatus.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundle.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleException.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleItem.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleItemRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewTask.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewTaskRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleType.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleVersion.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleVersionRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleVersionStatus.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/package-info.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftServiceTest.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewServiceTest.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleItemJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleReviewTaskJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleVersionJpaRepository.java create mode 100644 web/src/features/skill-bundle/api.test.ts create mode 100644 web/src/features/skill-bundle/api.ts create mode 100644 web/src/features/skill-bundle/hooks.ts create mode 100644 web/src/pages/bundles/skill-bundle-detail.tsx diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java index 7b36265f0..3d3a34fd2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java @@ -1,5 +1,11 @@ package com.iflytek.skillhub.config; +import com.iflytek.skillhub.domain.bundle.SkillBundleDraftService; +import com.iflytek.skillhub.domain.bundle.SkillBundleItemRepository; +import com.iflytek.skillhub.domain.bundle.SkillBundleRepository; +import com.iflytek.skillhub.domain.bundle.SkillBundleReviewService; +import com.iflytek.skillhub.domain.bundle.SkillBundleReviewTaskRepository; +import com.iflytek.skillhub.domain.bundle.SkillBundleVersionRepository; import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; @@ -41,4 +47,19 @@ public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMeta public VisibilityChecker visibilityChecker() { return new VisibilityChecker(); } + + @Bean + public SkillBundleDraftService skillBundleDraftService(SkillBundleRepository bundleRepository, + SkillBundleVersionRepository versionRepository, + SkillBundleItemRepository itemRepository, + SkillBundleDraftService.SkillBundleItemSourceResolver resolver) { + return new SkillBundleDraftService(bundleRepository, versionRepository, itemRepository, resolver); + } + + @Bean + public SkillBundleReviewService skillBundleReviewService(SkillBundleRepository bundleRepository, + SkillBundleVersionRepository versionRepository, + SkillBundleReviewTaskRepository reviewTaskRepository) { + return new SkillBundleReviewService(bundleRepository, versionRepository, reviewTaskRepository); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/SkillBundleReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/SkillBundleReviewController.java new file mode 100644 index 000000000..3d87e8319 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/SkillBundleReviewController.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.bundle.SkillBundleReviewActionRequest; +import com.iflytek.skillhub.service.bundle.SkillBundleAppService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Admin endpoints for skill bundle review queue. + */ +@RestController +@RequestMapping("/api/v1/skill-bundle-reviews") +@PreAuthorize("hasAnyRole('SKILL_ADMIN','SUPER_ADMIN','NAMESPACE_ADMIN','NAMESPACE_OWNER')") +public class SkillBundleReviewController extends BaseApiController { + + private final SkillBundleAppService appService; + + public SkillBundleReviewController(SkillBundleAppService appService, ApiResponseFactory responseFactory) { + super(responseFactory); + this.appService = appService; + } + + @PostMapping("/{id}/approve") + public ApiResponse approve(@PathVariable Long id, + @RequestBody(required = false) SkillBundleReviewActionRequest body, + @RequestAttribute("userId") String userId) { + String comment = body == null ? null : body.comment(); + return ok("response.success.updated", appService.approve(id, comment, userId).getId()); + } + + @PostMapping("/{id}/reject") + public ApiResponse reject(@PathVariable Long id, + @RequestBody(required = false) SkillBundleReviewActionRequest body, + @RequestAttribute("userId") String userId) { + String comment = body == null ? null : body.comment(); + return ok("response.success.updated", appService.reject(id, comment, userId).getId()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillBundleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillBundleController.java new file mode 100644 index 000000000..69ad4ae64 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillBundleController.java @@ -0,0 +1,64 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.bundle.BuildSkillBundleDraftRequest; +import com.iflytek.skillhub.dto.bundle.SkillBundleDetailResponse; +import com.iflytek.skillhub.dto.bundle.SkillBundleVersionResponse; +import com.iflytek.skillhub.service.bundle.SkillBundleAppService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Portal endpoints for the skill bundle module: build draft, query detail, submit + * for review, and download accounting. + */ +@RestController +@RequestMapping("/api/v1/skill-bundles") +public class SkillBundleController extends BaseApiController { + + private final SkillBundleAppService appService; + + public SkillBundleController(SkillBundleAppService appService, ApiResponseFactory responseFactory) { + super(responseFactory); + this.appService = appService; + } + + @PostMapping("/{namespace}/drafts/build") + public ApiResponse buildDraft(@PathVariable String namespace, + @Valid @RequestBody BuildSkillBundleDraftRequest request, + @RequestAttribute("userId") String userId) { + return ok("response.success.created", appService.buildDraft(namespace, request, userId)); + } + + @PostMapping("/{namespace}/{slug}/versions/{bundleVersionId}/submit-review") + public ApiResponse submitReview(@PathVariable String namespace, + @PathVariable String slug, + @PathVariable Long bundleVersionId, + @RequestAttribute("userId") String userId) { + return ok("response.success.created", + appService.submitForReview(bundleVersionId, userId).getId()); + } + + @GetMapping("/{namespace}/{slug}") + public ApiResponse getDetail(@PathVariable String namespace, + @PathVariable String slug, + @RequestParam(required = false) String version) { + return ok("response.success.read", appService.getDetail(namespace, slug, version)); + } + + @PostMapping("/{namespace}/{slug}/download") + public ApiResponse recordDownload(@PathVariable String namespace, + @PathVariable String slug) { + appService.incrementDownload(namespace, slug); + return ok("response.success.created", null); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/BuildSkillBundleDraftRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/BuildSkillBundleDraftRequest.java new file mode 100644 index 000000000..25d9a3831 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/BuildSkillBundleDraftRequest.java @@ -0,0 +1,32 @@ +package com.iflytek.skillhub.dto.bundle; + +import com.iflytek.skillhub.domain.bundle.SkillBundleType; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** + * Builds a draft skill bundle from existing platform skills (no zip upload). + */ +public record BuildSkillBundleDraftRequest( + @NotBlank @Size(max = 128) String slug, + @NotBlank @Size(max = 256) String displayName, + @NotBlank @Size(max = 32) String version, + @NotNull SkillBundleType type, + @NotBlank @Size(max = 512) String summary, + List targetProjectTypes, + List roleTags, + @NotNull List items, + List mediaIds +) { + public record DraftItemRequest( + @NotNull Long skillId, + @NotNull Long skillVersionId, + @NotBlank @Size(max = 512) String roleDescription, + boolean required, + @Min(0) int installOrder + ) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/SkillBundleDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/SkillBundleDetailResponse.java new file mode 100644 index 000000000..b487a0198 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/SkillBundleDetailResponse.java @@ -0,0 +1,61 @@ +package com.iflytek.skillhub.dto.bundle; + +import com.iflytek.skillhub.domain.bundle.SkillBundle; +import com.iflytek.skillhub.domain.bundle.SkillBundleType; +import com.iflytek.skillhub.domain.bundle.SkillBundleVersion; +import com.iflytek.skillhub.domain.bundle.SkillBundleVersionStatus; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public record SkillBundleDetailResponse( + Long id, + Long namespaceId, + String slug, + String displayName, + SkillBundleType type, + String summary, + long downloadCount, + int starCount, + BigDecimal ratingAvg, + int ratingCount, + int commentCount, + Long latestVersionId, + VersionView version, + List items, + Instant updatedAt +) { + public record VersionView(Long id, String version, SkillBundleVersionStatus status, Instant publishedAt) { + public static VersionView from(SkillBundleVersion v) { + return new VersionView(v.getId(), v.getVersion(), v.getStatus(), v.getPublishedAt()); + } + } + + public record ItemView( + Long skillId, + String namespaceSlug, + String skillSlug, + String displayName, + String version, + String roleDescription, + boolean required, + int installOrder, + String detailUrl + ) {} + + public static SkillBundleDetailResponse build(SkillBundle bundle, + SkillBundleVersion version, + List items) { + return new SkillBundleDetailResponse( + bundle.getId(), bundle.getNamespaceId(), bundle.getSlug(), + bundle.getDisplayName(), bundle.getBundleType(), bundle.getSummary(), + bundle.getDownloadCount(), bundle.getStarCount(), + bundle.getRatingAvg(), bundle.getRatingCount(), bundle.getCommentCount(), + bundle.getLatestVersionId(), + version == null ? null : VersionView.from(version), + items, + bundle.getUpdatedAt() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/SkillBundleReviewActionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/SkillBundleReviewActionRequest.java new file mode 100644 index 000000000..d1a309476 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/SkillBundleReviewActionRequest.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.dto.bundle; + +import jakarta.validation.constraints.Size; + +public record SkillBundleReviewActionRequest( + @Size(max = 1000) String comment +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/SkillBundleVersionResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/SkillBundleVersionResponse.java new file mode 100644 index 000000000..0948eafc0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/bundle/SkillBundleVersionResponse.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.dto.bundle; + +import com.iflytek.skillhub.domain.bundle.SkillBundleVersion; +import com.iflytek.skillhub.domain.bundle.SkillBundleVersionStatus; + +import java.time.Instant; + +public record SkillBundleVersionResponse( + Long bundleId, + Long bundleVersionId, + String version, + SkillBundleVersionStatus status, + Instant publishedAt +) { + public static SkillBundleVersionResponse from(SkillBundleVersion v) { + return new SkillBundleVersionResponse( + v.getBundleId(), v.getId(), v.getVersion(), + v.getStatus(), v.getPublishedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/JpaSkillBundleItemSourceResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/JpaSkillBundleItemSourceResolver.java new file mode 100644 index 000000000..8c114f464 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/JpaSkillBundleItemSourceResolver.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.service.bundle; + +import com.iflytek.skillhub.domain.bundle.SkillBundleDraftService; +import com.iflytek.skillhub.domain.bundle.SkillBundleException; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import org.springframework.stereotype.Component; + +/** + * Resolves bundle item snapshots from the existing skill aggregate. The snapshot + * is captured at draft-build time so the bundle stays reproducible. + */ +@Component +public class JpaSkillBundleItemSourceResolver implements SkillBundleDraftService.SkillBundleItemSourceResolver { + + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final NamespaceRepository namespaceRepository; + + public JpaSkillBundleItemSourceResolver(SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + NamespaceRepository namespaceRepository) { + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.namespaceRepository = namespaceRepository; + } + + @Override + public SkillBundleDraftService.SkillBundleItemSnapshot resolveRegistryItem(Long skillId, Long skillVersionId) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.item.skillNotFound")); + SkillVersion version = skillVersionRepository.findById(skillVersionId) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.item.versionNotFound")); + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + throw new SkillBundleException("error.skillBundle.item.versionNotPublished"); + } + Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.item.namespaceNotFound")); + return new SkillBundleDraftService.SkillBundleItemSnapshot( + namespace.getSlug(), + skill.getSlug(), + skill.getDisplayName(), + version.getVersion(), + skill.getSummary(), + version.getPublishedAt() != null ? version.getPublishedAt() : version.getCreatedAt() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillBundleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillBundleAppService.java new file mode 100644 index 000000000..8249d987e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillBundleAppService.java @@ -0,0 +1,158 @@ +package com.iflytek.skillhub.service.bundle; + +import com.iflytek.skillhub.domain.bundle.SkillBundle; +import com.iflytek.skillhub.domain.bundle.SkillBundleDraftService; +import com.iflytek.skillhub.domain.bundle.SkillBundleException; +import com.iflytek.skillhub.domain.bundle.SkillBundleItem; +import com.iflytek.skillhub.domain.bundle.SkillBundleItemRepository; +import com.iflytek.skillhub.domain.bundle.SkillBundleRepository; +import com.iflytek.skillhub.domain.bundle.SkillBundleReviewService; +import com.iflytek.skillhub.domain.bundle.SkillBundleReviewTask; +import com.iflytek.skillhub.domain.bundle.SkillBundleVersion; +import com.iflytek.skillhub.domain.bundle.SkillBundleVersionRepository; +import com.iflytek.skillhub.domain.bundle.SkillBundleVersionStatus; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.dto.bundle.BuildSkillBundleDraftRequest; +import com.iflytek.skillhub.dto.bundle.SkillBundleDetailResponse; +import com.iflytek.skillhub.dto.bundle.SkillBundleVersionResponse; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.util.List; + +/** + * Application façade for the skill bundle module. Maps controller payloads to + * domain commands and translates aggregates to DTOs. + */ +@Service +public class SkillBundleAppService { + + private final SkillBundleDraftService draftService; + private final SkillBundleReviewService reviewService; + private final SkillBundleRepository bundleRepository; + private final SkillBundleVersionRepository versionRepository; + private final SkillBundleItemRepository itemRepository; + private final NamespaceRepository namespaceRepository; + private final Clock clock; + + public SkillBundleAppService(SkillBundleDraftService draftService, + SkillBundleReviewService reviewService, + SkillBundleRepository bundleRepository, + SkillBundleVersionRepository versionRepository, + SkillBundleItemRepository itemRepository, + NamespaceRepository namespaceRepository, + Clock clock) { + this.draftService = draftService; + this.reviewService = reviewService; + this.bundleRepository = bundleRepository; + this.versionRepository = versionRepository; + this.itemRepository = itemRepository; + this.namespaceRepository = namespaceRepository; + this.clock = clock; + } + + @Transactional + public SkillBundleVersionResponse buildDraft(String namespaceSlug, + BuildSkillBundleDraftRequest request, + String creator) { + Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.namespace.notFound")); + + SkillBundleDraftService.BuildDraftCommand command = new SkillBundleDraftService.BuildDraftCommand( + namespace.getId(), request.slug(), request.displayName(), request.summary(), + request.version(), parseVersionSort(request.version()), request.type(), + request.targetProjectTypes(), request.roleTags(), + request.items().stream().map(item -> new SkillBundleDraftService.DraftItem( + item.skillId(), item.skillVersionId(), item.roleDescription(), + item.required(), item.installOrder())).toList(), + "{}", "{}", placeholderStorageKey(namespaceSlug, request.slug(), request.version()) + ); + + SkillBundleVersion version = draftService.buildDraft(command, creator); + return SkillBundleVersionResponse.from(version); + } + + @Transactional + public SkillBundleReviewTask submitForReview(Long bundleVersionId, String submitter) { + return reviewService.submitForReview(bundleVersionId, submitter); + } + + @Transactional + public SkillBundleReviewTask approve(Long reviewTaskId, String comment, String reviewer) { + return reviewService.approve(reviewTaskId, comment, reviewer, clock.instant()); + } + + @Transactional + public SkillBundleReviewTask reject(Long reviewTaskId, String comment, String reviewer) { + return reviewService.reject(reviewTaskId, comment, reviewer); + } + + @Transactional(readOnly = true) + public SkillBundleDetailResponse getDetail(String namespaceSlug, String slug, String requestedVersion) { + Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.namespace.notFound")); + SkillBundle bundle = bundleRepository.findByNamespaceIdAndSlug(namespace.getId(), slug) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.notFound")); + SkillBundleVersion version = resolveVisibleVersion(bundle, requestedVersion); + + List items = version == null + ? List.of() + : itemRepository.findByBundleVersionId(version.getId()); + List views = items.stream() + .map(this::toItemView) + .sorted((a, b) -> Integer.compare(a.installOrder(), b.installOrder())) + .toList(); + + return SkillBundleDetailResponse.build(bundle, version, views); + } + + @Transactional + public void incrementDownload(String namespaceSlug, String slug) { + Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.namespace.notFound")); + SkillBundle bundle = bundleRepository.findByNamespaceIdAndSlug(namespace.getId(), slug) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.notFound")); + bundleRepository.incrementDownloadCount(bundle.getId()); + } + + private SkillBundleVersion resolveVisibleVersion(SkillBundle bundle, String requestedVersion) { + if (requestedVersion != null && !requestedVersion.isBlank()) { + return versionRepository.findByBundleIdAndVersion(bundle.getId(), requestedVersion) + .filter(v -> v.getStatus() == SkillBundleVersionStatus.PUBLISHED) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.version.notFound")); + } + if (bundle.getLatestVersionId() == null) { + return null; + } + return versionRepository.findById(bundle.getLatestVersionId()).orElse(null); + } + + private SkillBundleDetailResponse.ItemView toItemView(SkillBundleItem item) { + return new SkillBundleDetailResponse.ItemView( + item.getSkillId(), item.getNamespaceSlug(), item.getSkillSlug(), + item.getDisplayName(), item.getVersion(), + item.getRoleDescription(), item.isRequired(), item.getInstallOrder(), + "/space/" + item.getNamespaceSlug() + "/" + item.getSkillSlug() + ); + } + + private long parseVersionSort(String version) { + // semver MAJOR.MINOR.PATCH packed into a sortable long; pre-release parts ignored. + String[] parts = version.split("[.+-]"); + long sort = 0; + for (int i = 0; i < Math.min(3, parts.length); i++) { + try { + sort = sort * 1_000_000L + Long.parseLong(parts[i]); + } catch (NumberFormatException ignored) { + break; + } + } + return sort; + } + + private String placeholderStorageKey(String namespaceSlug, String slug, String version) { + return "bundles/" + namespaceSlug + "/" + slug + "/" + version + "/bundle.zip"; + } +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V42__skill_bundle_tables.sql b/server/skillhub-app/src/main/resources/db/migration/V42__skill_bundle_tables.sql new file mode 100644 index 000000000..57a587643 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V42__skill_bundle_tables.sql @@ -0,0 +1,163 @@ +-- Skill bundle management tables: bundle aggregate, version, item snapshot, +-- validation results, review tasks, social interactions, search index, media assets. + +CREATE TABLE skill_bundle ( + id BIGSERIAL PRIMARY KEY, + namespace_id BIGINT NOT NULL, + slug VARCHAR(128) NOT NULL, + display_name VARCHAR(256) NOT NULL, + summary VARCHAR(512) NOT NULL, + bundle_type VARCHAR(32) NOT NULL, + owner_id VARCHAR(128) NOT NULL, + visibility VARCHAR(32) NOT NULL DEFAULT 'NAMESPACE_ONLY', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + latest_version_id BIGINT, + download_count BIGINT NOT NULL DEFAULT 0, + star_count INT NOT NULL DEFAULT 0, + rating_avg NUMERIC(3,2), + rating_count INT NOT NULL DEFAULT 0, + comment_count INT NOT NULL DEFAULT 0, + created_by VARCHAR(128) NOT NULL, + updated_by VARCHAR(128) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT skill_bundle_namespace_slug_unique UNIQUE (namespace_id, slug) +); +CREATE INDEX idx_skill_bundle_owner ON skill_bundle (owner_id); +CREATE INDEX idx_skill_bundle_type ON skill_bundle (bundle_type); +CREATE INDEX idx_skill_bundle_visibility ON skill_bundle (visibility); +CREATE INDEX idx_skill_bundle_status ON skill_bundle (status); + +CREATE TABLE skill_bundle_version ( + id BIGSERIAL PRIMARY KEY, + bundle_id BIGINT NOT NULL REFERENCES skill_bundle(id) ON DELETE CASCADE, + version VARCHAR(32) NOT NULL, + version_sort BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + manifest_json JSONB NOT NULL, + lock_json JSONB NOT NULL, + bundle_storage_key VARCHAR(512) NOT NULL, + file_count INT NOT NULL DEFAULT 0, + total_size BIGINT NOT NULL DEFAULT 0, + validation_status VARCHAR(32) NOT NULL DEFAULT 'SCANNING', + reject_reason VARCHAR(512), + published_by VARCHAR(128), + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT skill_bundle_version_bundle_version_unique UNIQUE (bundle_id, version) +); +CREATE INDEX idx_skill_bundle_version_status ON skill_bundle_version (status); +CREATE INDEX idx_skill_bundle_version_bundle ON skill_bundle_version (bundle_id, version_sort); +CREATE INDEX idx_skill_bundle_version_validation ON skill_bundle_version (validation_status); + +CREATE TABLE skill_bundle_item ( + id BIGSERIAL PRIMARY KEY, + bundle_version_id BIGINT NOT NULL REFERENCES skill_bundle_version(id) ON DELETE CASCADE, + source_type VARCHAR(32) NOT NULL, + skill_id BIGINT, + skill_version_id BIGINT, + embedded_skill_key VARCHAR(512), + namespace_slug VARCHAR(128) NOT NULL, + skill_slug VARCHAR(128) NOT NULL, + version VARCHAR(32) NOT NULL, + display_name VARCHAR(256) NOT NULL, + summary VARCHAR(512), + role_description VARCHAR(512) NOT NULL, + required BOOLEAN NOT NULL DEFAULT TRUE, + install_order INT NOT NULL DEFAULT 0, + compatibility_json JSONB +); +CREATE INDEX idx_skill_bundle_item_version ON skill_bundle_item (bundle_version_id); +CREATE INDEX idx_skill_bundle_item_skill ON skill_bundle_item (skill_id); +CREATE INDEX idx_skill_bundle_item_skillver ON skill_bundle_item (skill_version_id); + +CREATE TABLE skill_bundle_validation_result ( + id BIGSERIAL PRIMARY KEY, + bundle_version_id BIGINT NOT NULL REFERENCES skill_bundle_version(id) ON DELETE CASCADE, + bundle_item_id BIGINT REFERENCES skill_bundle_item(id) ON DELETE CASCADE, + check_type VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + severity VARCHAR(32), + message TEXT, + related_audit_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_skill_bundle_validation_version ON skill_bundle_validation_result (bundle_version_id); +CREATE INDEX idx_skill_bundle_validation_item ON skill_bundle_validation_result (bundle_item_id); +CREATE INDEX idx_skill_bundle_validation_status ON skill_bundle_validation_result (status); + +CREATE TABLE skill_bundle_review_task ( + id BIGSERIAL PRIMARY KEY, + bundle_version_id BIGINT NOT NULL REFERENCES skill_bundle_version(id) ON DELETE CASCADE, + namespace_id BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + version INT NOT NULL DEFAULT 1, + submitted_by VARCHAR(128) NOT NULL, + reviewed_by VARCHAR(128), + review_comment TEXT, + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + CONSTRAINT skill_bundle_review_task_version_unique UNIQUE (bundle_version_id) +); +CREATE INDEX idx_skill_bundle_review_status ON skill_bundle_review_task (status); +CREATE INDEX idx_skill_bundle_review_namespace ON skill_bundle_review_task (namespace_id); +CREATE INDEX idx_skill_bundle_review_submitter ON skill_bundle_review_task (submitted_by); + +CREATE TABLE skill_bundle_star ( + bundle_id BIGINT NOT NULL REFERENCES skill_bundle(id) ON DELETE CASCADE, + user_id VARCHAR(128) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bundle_id, user_id) +); + +CREATE TABLE skill_bundle_rating ( + bundle_id BIGINT NOT NULL REFERENCES skill_bundle(id) ON DELETE CASCADE, + user_id VARCHAR(128) NOT NULL, + score SMALLINT NOT NULL CHECK (score BETWEEN 1 AND 5), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bundle_id, user_id) +); + +CREATE TABLE skill_bundle_comment ( + id BIGSERIAL PRIMARY KEY, + bundle_id BIGINT NOT NULL REFERENCES skill_bundle(id) ON DELETE CASCADE, + user_id VARCHAR(128) NOT NULL, + content TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'VISIBLE', + hidden_by VARCHAR(128), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_skill_bundle_comment_bundle ON skill_bundle_comment (bundle_id); +CREATE INDEX idx_skill_bundle_comment_status ON skill_bundle_comment (status); + +CREATE TABLE skill_bundle_download_event ( + id BIGSERIAL PRIMARY KEY, + bundle_id BIGINT NOT NULL REFERENCES skill_bundle(id) ON DELETE CASCADE, + bundle_version_id BIGINT NOT NULL, + user_id VARCHAR(128), + client_ip VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_skill_bundle_download_event_bundle ON skill_bundle_download_event (bundle_id); +CREATE INDEX idx_skill_bundle_download_event_created ON skill_bundle_download_event (created_at); + +CREATE TABLE skill_bundle_search_document ( + id BIGSERIAL PRIMARY KEY, + bundle_id BIGINT NOT NULL UNIQUE REFERENCES skill_bundle(id) ON DELETE CASCADE, + namespace_id BIGINT NOT NULL, + owner_id VARCHAR(128) NOT NULL, + title VARCHAR(256) NOT NULL, + summary VARCHAR(512) NOT NULL, + bundle_type VARCHAR(32) NOT NULL, + target_project_types VARCHAR(512), + role_tags VARCHAR(512), + item_skill_text TEXT, + search_text TEXT NOT NULL, + visibility VARCHAR(32) NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_skill_bundle_search_namespace ON skill_bundle_search_document (namespace_id); +CREATE INDEX idx_skill_bundle_search_type ON skill_bundle_search_document (bundle_type); +CREATE INDEX idx_skill_bundle_search_visibility ON skill_bundle_search_document (visibility); +CREATE INDEX idx_skill_bundle_search_text ON skill_bundle_search_document USING GIN (to_tsvector('simple', search_text)); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/BundleItemSourceType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/BundleItemSourceType.java new file mode 100644 index 000000000..e23221fac --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/BundleItemSourceType.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.domain.bundle; + +/** + * Where a skill inside a bundle came from. + *
    + *
  • {@code REGISTRY} — references a published {@code skill_version} on the platform.
  • + *
  • {@code EMBEDDED} — uploaded as part of the bundle archive itself.
  • + *
+ */ +public enum BundleItemSourceType { + REGISTRY, + EMBEDDED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/BundleValidationStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/BundleValidationStatus.java new file mode 100644 index 000000000..a9c68f3ab --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/BundleValidationStatus.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.domain.bundle; + +/** + * Severity-aware result of a single bundle validation check. + * Aligns with {@code skill_bundle_validation_result.status}. + */ +public enum BundleValidationStatus { + PASSED, + WARNING, + FAILED, + SCANNING +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundle.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundle.java new file mode 100644 index 000000000..0771a301c --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundle.java @@ -0,0 +1,124 @@ +package com.iflytek.skillhub.domain.bundle; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.Instant; + +/** + * Aggregate root for a skill bundle (a curated collection of skills). + * + *

Distinct from {@link com.iflytek.skillhub.domain.skill.Skill}: bundles have their + * own version stream, social signals, audit lifecycle, and downloads. + */ +@Entity +@Table(name = "skill_bundle") +public class SkillBundle { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + + @Column(nullable = false, length = 128) + private String slug; + + @Column(name = "display_name", nullable = false, length = 256) + private String displayName; + + @Column(nullable = false, length = 512) + private String summary; + + @Enumerated(EnumType.STRING) + @Column(name = "bundle_type", nullable = false, length = 32) + private SkillBundleType bundleType; + + @Column(name = "owner_id", nullable = false, length = 128) + private String ownerId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private SkillVisibility visibility = SkillVisibility.NAMESPACE_ONLY; + + @Column(nullable = false, length = 32) + private String status = "ACTIVE"; + + @Column(name = "latest_version_id") + private Long latestVersionId; + + @Column(name = "download_count", nullable = false) + private long downloadCount; + + @Column(name = "star_count", nullable = false) + private int starCount; + + @Column(name = "rating_avg", precision = 3, scale = 2) + private BigDecimal ratingAvg; + + @Column(name = "rating_count", nullable = false) + private int ratingCount; + + @Column(name = "comment_count", nullable = false) + private int commentCount; + + @Column(name = "created_by", nullable = false, length = 128) + private String createdBy; + + @Column(name = "updated_by", nullable = false, length = 128) + private String updatedBy; + + @Column(name = "created_at", nullable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); + + protected SkillBundle() {} + + public SkillBundle(Long namespaceId, String slug, String displayName, String summary, + SkillBundleType bundleType, String ownerId, String createdBy) { + this.namespaceId = namespaceId; + this.slug = slug; + this.displayName = displayName; + this.summary = summary; + this.bundleType = bundleType; + this.ownerId = ownerId; + this.createdBy = createdBy; + this.updatedBy = createdBy; + } + + public Long getId() { return id; } + public Long getNamespaceId() { return namespaceId; } + public String getSlug() { return slug; } + public String getDisplayName() { return displayName; } + public String getSummary() { return summary; } + public SkillBundleType getBundleType() { return bundleType; } + public String getOwnerId() { return ownerId; } + public SkillVisibility getVisibility() { return visibility; } + public String getStatus() { return status; } + public Long getLatestVersionId() { return latestVersionId; } + public long getDownloadCount() { return downloadCount; } + public int getStarCount() { return starCount; } + public BigDecimal getRatingAvg() { return ratingAvg; } + public int getRatingCount() { return ratingCount; } + public int getCommentCount() { return commentCount; } + public String getCreatedBy() { return createdBy; } + public String getUpdatedBy() { return updatedBy; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } + + public void setVisibility(SkillVisibility visibility) { this.visibility = visibility; } + public void setStatus(String status) { this.status = status; } + public void setLatestVersionId(Long latestVersionId) { this.latestVersionId = latestVersionId; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public void setSummary(String summary) { this.summary = summary; } + public void setBundleType(SkillBundleType bundleType) { this.bundleType = bundleType; } + public void setUpdatedBy(String updatedBy) { this.updatedBy = updatedBy; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } + public void setStarCount(int starCount) { this.starCount = starCount; } + public void setRatingCount(int ratingCount) { this.ratingCount = ratingCount; } + public void setRatingAvg(BigDecimal ratingAvg) { this.ratingAvg = ratingAvg; } + public void setCommentCount(int commentCount) { this.commentCount = commentCount; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftService.java new file mode 100644 index 000000000..ea8a098fc --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftService.java @@ -0,0 +1,124 @@ +package com.iflytek.skillhub.domain.bundle; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** + * Application service that builds a skill bundle draft from a set of platform skills. + * Captures the design-doc business rules: + * + *

    + *
  • Slug + version uniqueness inside a namespace.
  • + *
  • Each item must reference a {@code PUBLISHED} skill version (lock snapshot, no + * follow-latest).
  • + *
  • {@code PROJECT} bundles need at least one project type, {@code ROLE} bundles need + * at least one role tag.
  • + *
  • No duplicate skill coordinates within one bundle version.
  • + *
+ */ +public class SkillBundleDraftService { + + private final SkillBundleRepository bundleRepository; + private final SkillBundleVersionRepository versionRepository; + private final SkillBundleItemRepository itemRepository; + private final SkillBundleItemSourceResolver itemSourceResolver; + + public SkillBundleDraftService(SkillBundleRepository bundleRepository, + SkillBundleVersionRepository versionRepository, + SkillBundleItemRepository itemRepository, + SkillBundleItemSourceResolver itemSourceResolver) { + this.bundleRepository = bundleRepository; + this.versionRepository = versionRepository; + this.itemRepository = itemRepository; + this.itemSourceResolver = itemSourceResolver; + } + + public SkillBundleVersion buildDraft(BuildDraftCommand command, String creator) { + Objects.requireNonNull(command, "command"); + validateCommand(command); + + SkillBundle bundle = bundleRepository.findByNamespaceIdAndSlug(command.namespaceId(), command.slug()) + .orElseGet(() -> bundleRepository.save(new SkillBundle( + command.namespaceId(), command.slug(), command.displayName(), + command.summary(), command.bundleType(), creator, creator))); + + if (versionRepository.findByBundleIdAndVersion(bundle.getId(), command.version()).isPresent()) { + throw new SkillBundleException("error.skillBundle.version.duplicate"); + } + + SkillBundleVersion version = versionRepository.save(new SkillBundleVersion( + bundle.getId(), command.version(), command.versionSort(), + command.manifestJson(), command.lockJson(), command.bundleStorageKey())); + + for (DraftItem item : command.items()) { + SkillBundleItemSnapshot snapshot = itemSourceResolver.resolveRegistryItem(item.skillId(), item.skillVersionId()); + SkillBundleItem entity = new SkillBundleItem( + version.getId(), BundleItemSourceType.REGISTRY, + snapshot.namespaceSlug(), snapshot.skillSlug(), snapshot.version(), + snapshot.displayName(), item.roleDescription(), + item.required(), item.installOrder()); + entity.setSkillId(item.skillId()); + entity.setSkillVersionId(item.skillVersionId()); + entity.setSummary(snapshot.summary()); + itemRepository.save(entity); + } + return version; + } + + private void validateCommand(BuildDraftCommand command) { + if (command.items() == null || command.items().isEmpty()) { + throw new SkillBundleException("error.skillBundle.item.empty"); + } + if (command.bundleType() == SkillBundleType.PROJECT + && (command.targetProjectTypes() == null || command.targetProjectTypes().isEmpty())) { + throw new SkillBundleException("error.skillBundle.projectTypes.required"); + } + if (command.bundleType() == SkillBundleType.ROLE + && (command.roleTags() == null || command.roleTags().isEmpty())) { + throw new SkillBundleException("error.skillBundle.roleTags.required"); + } + long unique = command.items().stream() + .map(i -> i.skillId() + "@" + i.skillVersionId()) + .distinct() + .count(); + if (unique != command.items().size()) { + throw new SkillBundleException("error.skillBundle.item.duplicate"); + } + } + + public record BuildDraftCommand(Long namespaceId, + String slug, + String displayName, + String summary, + String version, + long versionSort, + SkillBundleType bundleType, + List targetProjectTypes, + List roleTags, + List items, + String manifestJson, + String lockJson, + String bundleStorageKey) {} + + public record DraftItem(Long skillId, + Long skillVersionId, + String roleDescription, + boolean required, + int installOrder) {} + + public record SkillBundleItemSnapshot(String namespaceSlug, + String skillSlug, + String displayName, + String version, + String summary, + Instant publishedAt) {} + + /** + * Resolves a published skill version snapshot for a bundle item. + * Implementations live in {@code skillhub-app} so the domain layer doesn't depend on JPA. + */ + public interface SkillBundleItemSourceResolver { + SkillBundleItemSnapshot resolveRegistryItem(Long skillId, Long skillVersionId); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleException.java new file mode 100644 index 000000000..16a2d0fc6 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleException.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.domain.bundle; + +/** + * Domain-level error for bundle workflows. Message is the i18n key (e.g. + * {@code error.skillBundle.manifest.missing}). + */ +public class SkillBundleException extends RuntimeException { + public SkillBundleException(String messageCode) { + super(messageCode); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleItem.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleItem.java new file mode 100644 index 000000000..0f3b5d896 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleItem.java @@ -0,0 +1,100 @@ +package com.iflytek.skillhub.domain.bundle; + +import jakarta.persistence.*; + +/** + * One skill entry inside a bundle version. Holds either a registry coordinate + * (when {@link #getSourceType()} is {@code REGISTRY}) or a storage key for an + * embedded skill archive ({@code EMBEDDED}). Display fields are snapshots — the + * bundle keeps showing the original skill name even if upstream renames. + */ +@Entity +@Table(name = "skill_bundle_item") +public class SkillBundleItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "bundle_version_id", nullable = false) + private Long bundleVersionId; + + @Enumerated(EnumType.STRING) + @Column(name = "source_type", nullable = false, length = 32) + private BundleItemSourceType sourceType; + + @Column(name = "skill_id") + private Long skillId; + + @Column(name = "skill_version_id") + private Long skillVersionId; + + @Column(name = "embedded_skill_key", length = 512) + private String embeddedSkillKey; + + @Column(name = "namespace_slug", nullable = false, length = 128) + private String namespaceSlug; + + @Column(name = "skill_slug", nullable = false, length = 128) + private String skillSlug; + + @Column(nullable = false, length = 32) + private String version; + + @Column(name = "display_name", nullable = false, length = 256) + private String displayName; + + @Column(length = 512) + private String summary; + + @Column(name = "role_description", nullable = false, length = 512) + private String roleDescription; + + @Column(nullable = false) + private boolean required = true; + + @Column(name = "install_order", nullable = false) + private int installOrder; + + @Column(name = "compatibility_json", columnDefinition = "jsonb") + private String compatibilityJson; + + protected SkillBundleItem() {} + + public SkillBundleItem(Long bundleVersionId, BundleItemSourceType sourceType, + String namespaceSlug, String skillSlug, String version, + String displayName, String roleDescription, + boolean required, int installOrder) { + this.bundleVersionId = bundleVersionId; + this.sourceType = sourceType; + this.namespaceSlug = namespaceSlug; + this.skillSlug = skillSlug; + this.version = version; + this.displayName = displayName; + this.roleDescription = roleDescription; + this.required = required; + this.installOrder = installOrder; + } + + public Long getId() { return id; } + public Long getBundleVersionId() { return bundleVersionId; } + public BundleItemSourceType getSourceType() { return sourceType; } + public Long getSkillId() { return skillId; } + public Long getSkillVersionId() { return skillVersionId; } + public String getEmbeddedSkillKey() { return embeddedSkillKey; } + public String getNamespaceSlug() { return namespaceSlug; } + public String getSkillSlug() { return skillSlug; } + public String getVersion() { return version; } + public String getDisplayName() { return displayName; } + public String getSummary() { return summary; } + public String getRoleDescription() { return roleDescription; } + public boolean isRequired() { return required; } + public int getInstallOrder() { return installOrder; } + public String getCompatibilityJson() { return compatibilityJson; } + + public void setSkillId(Long skillId) { this.skillId = skillId; } + public void setSkillVersionId(Long skillVersionId) { this.skillVersionId = skillVersionId; } + public void setEmbeddedSkillKey(String embeddedSkillKey) { this.embeddedSkillKey = embeddedSkillKey; } + public void setSummary(String summary) { this.summary = summary; } + public void setCompatibilityJson(String compatibilityJson) { this.compatibilityJson = compatibilityJson; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleItemRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleItemRepository.java new file mode 100644 index 000000000..f34f1e043 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleItemRepository.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.domain.bundle; + +import java.util.List; + +/** + * Persistence contract for {@link SkillBundleItem}. + */ +public interface SkillBundleItemRepository { + SkillBundleItem save(SkillBundleItem item); + List findByBundleVersionId(Long bundleVersionId); + void deleteByBundleVersionId(Long bundleVersionId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleRepository.java new file mode 100644 index 000000000..4c90e8dd3 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleRepository.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.domain.bundle; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import java.util.List; +import java.util.Optional; + +/** + * Persistence contract for {@link SkillBundle}. + */ +public interface SkillBundleRepository { + SkillBundle save(SkillBundle bundle); + Optional findById(Long id); + Optional findByNamespaceIdAndSlug(Long namespaceId, String slug); + Page findByOwnerId(String ownerId, Pageable pageable); + Page findByBundleType(SkillBundleType bundleType, Pageable pageable); + List findByIdIn(List ids); + boolean existsByNamespaceIdAndSlug(Long namespaceId, String slug); + void incrementDownloadCount(Long bundleId); + void delete(SkillBundle bundle); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewService.java new file mode 100644 index 000000000..15c7a052e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewService.java @@ -0,0 +1,111 @@ +package com.iflytek.skillhub.domain.bundle; + +import java.time.Instant; +import java.util.Objects; + +/** + * Drives the bundle review state machine: submit a draft for review, approve, reject. + * + *

Approval flips both the {@link SkillBundleVersion} status to {@code PUBLISHED} + * and updates the bundle's latest version pointer. Optimistic locks on the review task + * guard against concurrent reviewers. + */ +public class SkillBundleReviewService { + + private final SkillBundleRepository bundleRepository; + private final SkillBundleVersionRepository versionRepository; + private final SkillBundleReviewTaskRepository reviewTaskRepository; + + public SkillBundleReviewService(SkillBundleRepository bundleRepository, + SkillBundleVersionRepository versionRepository, + SkillBundleReviewTaskRepository reviewTaskRepository) { + this.bundleRepository = bundleRepository; + this.versionRepository = versionRepository; + this.reviewTaskRepository = reviewTaskRepository; + } + + public SkillBundleReviewTask submitForReview(Long bundleVersionId, String submitter) { + SkillBundleVersion version = versionRepository.findById(bundleVersionId) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.version.notFound")); + if (version.getStatus() != SkillBundleVersionStatus.DRAFT + && version.getStatus() != SkillBundleVersionStatus.REJECTED) { + throw new SkillBundleException("error.skillBundle.version.notSubmittable"); + } + if (version.getValidationStatus() == BundleValidationStatus.FAILED) { + throw new SkillBundleException("error.skillBundle.validation.failed"); + } + if (version.getValidationStatus() == BundleValidationStatus.SCANNING) { + throw new SkillBundleException("error.skillBundle.validation.scanning"); + } + + SkillBundle bundle = bundleRepository.findById(version.getBundleId()) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.notFound")); + + version.setStatus(SkillBundleVersionStatus.PENDING_REVIEW); + versionRepository.save(version); + + SkillBundleReviewTask task = reviewTaskRepository.findByBundleVersionId(bundleVersionId) + .orElseGet(() -> new SkillBundleReviewTask(bundleVersionId, bundle.getNamespaceId(), submitter)); + task.setStatus("PENDING"); + return reviewTaskRepository.save(task); + } + + public SkillBundleReviewTask approve(Long reviewTaskId, String comment, String reviewer, Instant now) { + SkillBundleReviewTask task = reviewTaskRepository.findById(reviewTaskId) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.reviewTask.notFound")); + if (!"PENDING".equals(task.getStatus())) { + throw new SkillBundleException("error.skillBundle.reviewTask.notPending"); + } + if (Objects.equals(task.getSubmittedBy(), reviewer)) { + throw new SkillBundleException("error.skillBundle.reviewTask.selfReview"); + } + + SkillBundleVersion version = versionRepository.findById(task.getBundleVersionId()) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.version.notFound")); + if (version.getValidationStatus() == BundleValidationStatus.FAILED) { + throw new SkillBundleException("error.skillBundle.validation.failed"); + } + if (version.getValidationStatus() == BundleValidationStatus.SCANNING) { + throw new SkillBundleException("error.skillBundle.validation.scanning"); + } + + int updated = reviewTaskRepository.updateStatusWithVersion( + task.getId(), "APPROVED", reviewer, comment, task.getVersion()); + if (updated == 0) { + throw new SkillBundleException("error.skillBundle.reviewTask.concurrentUpdate"); + } + + version.setStatus(SkillBundleVersionStatus.PUBLISHED); + version.setPublishedAt(now); + version.setPublishedBy(reviewer); + versionRepository.save(version); + + SkillBundle bundle = bundleRepository.findById(version.getBundleId()) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.notFound")); + bundle.setLatestVersionId(version.getId()); + bundle.setUpdatedAt(now); + bundle.setUpdatedBy(reviewer); + bundleRepository.save(bundle); + + return reviewTaskRepository.findById(reviewTaskId).orElseThrow(); + } + + public SkillBundleReviewTask reject(Long reviewTaskId, String comment, String reviewer) { + SkillBundleReviewTask task = reviewTaskRepository.findById(reviewTaskId) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.reviewTask.notFound")); + if (!"PENDING".equals(task.getStatus())) { + throw new SkillBundleException("error.skillBundle.reviewTask.notPending"); + } + int updated = reviewTaskRepository.updateStatusWithVersion( + task.getId(), "REJECTED", reviewer, comment, task.getVersion()); + if (updated == 0) { + throw new SkillBundleException("error.skillBundle.reviewTask.concurrentUpdate"); + } + SkillBundleVersion version = versionRepository.findById(task.getBundleVersionId()) + .orElseThrow(() -> new SkillBundleException("error.skillBundle.version.notFound")); + version.setStatus(SkillBundleVersionStatus.REJECTED); + version.setRejectReason(comment); + versionRepository.save(version); + return reviewTaskRepository.findById(reviewTaskId).orElseThrow(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewTask.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewTask.java new file mode 100644 index 000000000..33054111f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewTask.java @@ -0,0 +1,70 @@ +package com.iflytek.skillhub.domain.bundle; + +import jakarta.persistence.*; +import java.time.Instant; + +/** + * Bundle-version review task. Mirrors the existing {@code review_task} state machine + * but stays decoupled from skill review since the auditable artifact is a + * different aggregate. + */ +@Entity +@Table(name = "skill_bundle_review_task") +public class SkillBundleReviewTask { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "bundle_version_id", nullable = false, unique = true) + private Long bundleVersionId; + + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + + @Column(nullable = false, length = 32) + private String status = "PENDING"; + + @Version + @Column(nullable = false) + private Integer version = 1; + + @Column(name = "submitted_by", nullable = false, length = 128) + private String submittedBy; + + @Column(name = "reviewed_by", length = 128) + private String reviewedBy; + + @Column(name = "review_comment", columnDefinition = "TEXT") + private String reviewComment; + + @Column(name = "submitted_at", nullable = false) + private Instant submittedAt = Instant.now(); + + @Column(name = "reviewed_at") + private Instant reviewedAt; + + protected SkillBundleReviewTask() {} + + public SkillBundleReviewTask(Long bundleVersionId, Long namespaceId, String submittedBy) { + this.bundleVersionId = bundleVersionId; + this.namespaceId = namespaceId; + this.submittedBy = submittedBy; + } + + public Long getId() { return id; } + public Long getBundleVersionId() { return bundleVersionId; } + public Long getNamespaceId() { return namespaceId; } + public String getStatus() { return status; } + public Integer getVersion() { return version; } + public String getSubmittedBy() { return submittedBy; } + public String getReviewedBy() { return reviewedBy; } + public String getReviewComment() { return reviewComment; } + public Instant getSubmittedAt() { return submittedAt; } + public Instant getReviewedAt() { return reviewedAt; } + + public void setStatus(String status) { this.status = status; } + public void setReviewedBy(String reviewedBy) { this.reviewedBy = reviewedBy; } + public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; } + public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewTaskRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewTaskRepository.java new file mode 100644 index 000000000..5207df395 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewTaskRepository.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.domain.bundle; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import java.util.Optional; + +/** + * Persistence contract for {@link SkillBundleReviewTask}. + */ +public interface SkillBundleReviewTaskRepository { + SkillBundleReviewTask save(SkillBundleReviewTask task); + Optional findById(Long id); + Optional findByBundleVersionId(Long bundleVersionId); + Page findByStatusAndNamespaceId(String status, Long namespaceId, Pageable pageable); + + /** + * Optimistically transitions the task into the new status. + * @return the number of rows updated; 0 means another writer beat us to it. + */ + int updateStatusWithVersion(Long id, String newStatus, String reviewedBy, + String reviewComment, Integer expectedVersion); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleType.java new file mode 100644 index 000000000..a41a0ca69 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleType.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.domain.bundle; + +/** + * Categorization of a skill bundle as described in the design doc. + *

    + *
  • {@code PROJECT} — bound to one or more {@code targetProjectTypes}.
  • + *
  • {@code ROLE} — bound to one or more {@code roleTags}.
  • + *
  • {@code SCENARIO} — task-specific aggregation across project / role boundaries.
  • + *
  • {@code CUSTOM} — user-defined collection without a primary axis.
  • + *
+ */ +public enum SkillBundleType { + PROJECT, + ROLE, + SCENARIO, + CUSTOM +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleVersion.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleVersion.java new file mode 100644 index 000000000..722664889 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleVersion.java @@ -0,0 +1,98 @@ +package com.iflytek.skillhub.domain.bundle; + +import jakarta.persistence.*; +import java.time.Instant; + +/** + * A specific immutable release of a {@link SkillBundle}. Its {@code lockJson} + * snapshots the per-skill coordinates so the bundle install plan is reproducible + * even if upstream skills publish new versions. + */ +@Entity +@Table(name = "skill_bundle_version") +public class SkillBundleVersion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "bundle_id", nullable = false) + private Long bundleId; + + @Column(nullable = false, length = 32) + private String version; + + @Column(name = "version_sort", nullable = false) + private long versionSort; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private SkillBundleVersionStatus status = SkillBundleVersionStatus.DRAFT; + + @Column(name = "manifest_json", nullable = false, columnDefinition = "jsonb") + private String manifestJson; + + @Column(name = "lock_json", nullable = false, columnDefinition = "jsonb") + private String lockJson; + + @Column(name = "bundle_storage_key", nullable = false, length = 512) + private String bundleStorageKey; + + @Column(name = "file_count", nullable = false) + private int fileCount; + + @Column(name = "total_size", nullable = false) + private long totalSize; + + @Enumerated(EnumType.STRING) + @Column(name = "validation_status", nullable = false, length = 32) + private BundleValidationStatus validationStatus = BundleValidationStatus.SCANNING; + + @Column(name = "reject_reason", length = 512) + private String rejectReason; + + @Column(name = "published_by", length = 128) + private String publishedBy; + + @Column(name = "published_at") + private Instant publishedAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt = Instant.now(); + + protected SkillBundleVersion() {} + + public SkillBundleVersion(Long bundleId, String version, long versionSort, + String manifestJson, String lockJson, String bundleStorageKey) { + this.bundleId = bundleId; + this.version = version; + this.versionSort = versionSort; + this.manifestJson = manifestJson; + this.lockJson = lockJson; + this.bundleStorageKey = bundleStorageKey; + } + + public Long getId() { return id; } + public Long getBundleId() { return bundleId; } + public String getVersion() { return version; } + public long getVersionSort() { return versionSort; } + public SkillBundleVersionStatus getStatus() { return status; } + public String getManifestJson() { return manifestJson; } + public String getLockJson() { return lockJson; } + public String getBundleStorageKey() { return bundleStorageKey; } + public int getFileCount() { return fileCount; } + public long getTotalSize() { return totalSize; } + public BundleValidationStatus getValidationStatus() { return validationStatus; } + public String getRejectReason() { return rejectReason; } + public String getPublishedBy() { return publishedBy; } + public Instant getPublishedAt() { return publishedAt; } + public Instant getCreatedAt() { return createdAt; } + + public void setStatus(SkillBundleVersionStatus status) { this.status = status; } + public void setValidationStatus(BundleValidationStatus validationStatus) { this.validationStatus = validationStatus; } + public void setRejectReason(String rejectReason) { this.rejectReason = rejectReason; } + public void setPublishedBy(String publishedBy) { this.publishedBy = publishedBy; } + public void setPublishedAt(Instant publishedAt) { this.publishedAt = publishedAt; } + public void setFileCount(int fileCount) { this.fileCount = fileCount; } + public void setTotalSize(long totalSize) { this.totalSize = totalSize; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleVersionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleVersionRepository.java new file mode 100644 index 000000000..05203f1a0 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleVersionRepository.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.domain.bundle; + +import java.util.List; +import java.util.Optional; + +/** + * Persistence contract for {@link SkillBundleVersion}. + */ +public interface SkillBundleVersionRepository { + SkillBundleVersion save(SkillBundleVersion version); + Optional findById(Long id); + Optional findByBundleIdAndVersion(Long bundleId, String version); + List findByBundleId(Long bundleId); + List findByBundleIdAndStatus(Long bundleId, SkillBundleVersionStatus status); + void delete(SkillBundleVersion version); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleVersionStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleVersionStatus.java new file mode 100644 index 000000000..c51d7d738 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleVersionStatus.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.domain.bundle; + +/** + * Lifecycle state of a {@code skill_bundle_version}. Mirrors the skill version + * state machine but lives in its own enum so the bundle aggregate can evolve + * independently of {@link com.iflytek.skillhub.domain.skill.SkillVersionStatus}. + */ +public enum SkillBundleVersionStatus { + DRAFT, + PENDING_REVIEW, + PUBLISHED, + REJECTED, + YANKED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/package-info.java new file mode 100644 index 000000000..342af78d8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/package-info.java @@ -0,0 +1,4 @@ +/** + * Skill bundle domain — aggregates a curated set of skills as a versioned, reviewable artifact. + */ +package com.iflytek.skillhub.domain.bundle; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftServiceTest.java new file mode 100644 index 000000000..b4336e6ef --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftServiceTest.java @@ -0,0 +1,159 @@ +package com.iflytek.skillhub.domain.bundle; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link SkillBundleDraftService} validation and persistence rules. + */ +@ExtendWith(MockitoExtension.class) +class SkillBundleDraftServiceTest { + + private SkillBundleRepository bundleRepository; + private SkillBundleVersionRepository versionRepository; + private SkillBundleItemRepository itemRepository; + private SkillBundleDraftService.SkillBundleItemSourceResolver resolver; + private SkillBundleDraftService service; + + @BeforeEach + void setUp() { + bundleRepository = mock(SkillBundleRepository.class); + versionRepository = mock(SkillBundleVersionRepository.class); + itemRepository = mock(SkillBundleItemRepository.class); + resolver = mock(SkillBundleDraftService.SkillBundleItemSourceResolver.class); + service = new SkillBundleDraftService(bundleRepository, versionRepository, itemRepository, resolver); + } + + @Test + void buildDraft_rejectsWhenItemsEmpty() { + SkillBundleDraftService.BuildDraftCommand cmd = command(SkillBundleType.CUSTOM, List.of(), List.of(), List.of()); + + assertThatThrownBy(() -> service.buildDraft(cmd, "alice")) + .isInstanceOf(SkillBundleException.class) + .hasMessage("error.skillBundle.item.empty"); + + verify(bundleRepository, never()).save(any()); + } + + @Test + void buildDraft_rejectsProjectBundleWithoutProjectTypes() { + SkillBundleDraftService.BuildDraftCommand cmd = command( + SkillBundleType.PROJECT, List.of(), List.of(), + List.of(item(1L, 11L, true, 10))); + + assertThatThrownBy(() -> service.buildDraft(cmd, "alice")) + .isInstanceOf(SkillBundleException.class) + .hasMessage("error.skillBundle.projectTypes.required"); + } + + @Test + void buildDraft_rejectsRoleBundleWithoutRoleTags() { + SkillBundleDraftService.BuildDraftCommand cmd = command( + SkillBundleType.ROLE, List.of(), List.of(), + List.of(item(1L, 11L, true, 10))); + + assertThatThrownBy(() -> service.buildDraft(cmd, "alice")) + .isInstanceOf(SkillBundleException.class) + .hasMessage("error.skillBundle.roleTags.required"); + } + + @Test + void buildDraft_rejectsDuplicateItems() { + SkillBundleDraftService.BuildDraftCommand cmd = command( + SkillBundleType.CUSTOM, List.of(), List.of(), + List.of(item(1L, 11L, true, 10), item(1L, 11L, false, 20))); + + assertThatThrownBy(() -> service.buildDraft(cmd, "alice")) + .isInstanceOf(SkillBundleException.class) + .hasMessage("error.skillBundle.item.duplicate"); + } + + @Test + void buildDraft_rejectsConflictingVersionSlug() { + SkillBundleDraftService.BuildDraftCommand cmd = command( + SkillBundleType.CUSTOM, List.of(), List.of(), + List.of(item(1L, 11L, true, 10))); + + SkillBundle existing = bundleStub(); + given(bundleRepository.findByNamespaceIdAndSlug(5L, "ops")).willReturn(Optional.of(existing)); + given(versionRepository.findByBundleIdAndVersion(99L, "1.0.0")) + .willReturn(Optional.of(new SkillBundleVersion(99L, "1.0.0", 1, "{}", "{}", "key"))); + + assertThatThrownBy(() -> service.buildDraft(cmd, "alice")) + .isInstanceOf(SkillBundleException.class) + .hasMessage("error.skillBundle.version.duplicate"); + } + + @Test + void buildDraft_persistsBundleVersionAndItemsWithSnapshot() { + SkillBundleDraftService.BuildDraftCommand cmd = command( + SkillBundleType.CUSTOM, List.of(), List.of(), + List.of(item(1L, 11L, true, 10))); + + given(bundleRepository.findByNamespaceIdAndSlug(5L, "ops")).willReturn(Optional.empty()); + given(bundleRepository.save(any(SkillBundle.class))).willAnswer(invocation -> { + SkillBundle b = invocation.getArgument(0); + setField(b, "id", 99L); + return b; + }); + given(versionRepository.findByBundleIdAndVersion(anyLong(), any())).willReturn(Optional.empty()); + given(versionRepository.save(any(SkillBundleVersion.class))).willAnswer(invocation -> { + SkillBundleVersion v = invocation.getArgument(0); + setField(v, "id", 120L); + return v; + }); + given(resolver.resolveRegistryItem(1L, 11L)).willReturn( + new SkillBundleDraftService.SkillBundleItemSnapshot( + "global", "code-review", "Code Review", "1.3.0", + "Audit interface boundaries.", Instant.now())); + + SkillBundleVersion saved = service.buildDraft(cmd, "alice"); + + assertThat(saved.getId()).isEqualTo(120L); + verify(itemRepository).save(any(SkillBundleItem.class)); + } + + private SkillBundleDraftService.BuildDraftCommand command(SkillBundleType type, + List projectTypes, + List roleTags, + List items) { + return new SkillBundleDraftService.BuildDraftCommand( + 5L, "ops", "Ops", "summary", "1.0.0", 1L, + type, projectTypes, roleTags, items, "{}", "{}", "key"); + } + + private SkillBundleDraftService.DraftItem item(Long skillId, Long versionId, boolean required, int order) { + return new SkillBundleDraftService.DraftItem(skillId, versionId, "role", required, order); + } + + private SkillBundle bundleStub() { + SkillBundle bundle = new SkillBundle(5L, "ops", "Ops", "summary", SkillBundleType.CUSTOM, "alice", "alice"); + setField(bundle, "id", 99L); + return bundle; + } + + private static void setField(Object target, String name, Object value) { + try { + java.lang.reflect.Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewServiceTest.java new file mode 100644 index 000000000..72ea1637f --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewServiceTest.java @@ -0,0 +1,177 @@ +package com.iflytek.skillhub.domain.bundle; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link SkillBundleReviewService}: state machine transitions for the + * bundle audit lifecycle, including self-review prevention, validation gating, + * and optimistic-lock contention. + */ +@ExtendWith(MockitoExtension.class) +class SkillBundleReviewServiceTest { + + private SkillBundleRepository bundleRepository; + private SkillBundleVersionRepository versionRepository; + private SkillBundleReviewTaskRepository reviewTaskRepository; + private SkillBundleReviewService service; + + private final Instant now = Instant.parse("2026-06-01T10:00:00Z"); + + @BeforeEach + void setUp() { + bundleRepository = mock(SkillBundleRepository.class); + versionRepository = mock(SkillBundleVersionRepository.class); + reviewTaskRepository = mock(SkillBundleReviewTaskRepository.class); + service = new SkillBundleReviewService(bundleRepository, versionRepository, reviewTaskRepository); + } + + @Test + void submitForReview_blockedWhenValidationStillScanning() { + SkillBundleVersion version = draftVersion(BundleValidationStatus.SCANNING); + given(versionRepository.findById(120L)).willReturn(Optional.of(version)); + + assertThatThrownBy(() -> service.submitForReview(120L, "alice")) + .isInstanceOf(SkillBundleException.class) + .hasMessage("error.skillBundle.validation.scanning"); + verify(reviewTaskRepository, never()).save(any()); + } + + @Test + void submitForReview_blockedWhenValidationFailed() { + SkillBundleVersion version = draftVersion(BundleValidationStatus.FAILED); + given(versionRepository.findById(120L)).willReturn(Optional.of(version)); + + assertThatThrownBy(() -> service.submitForReview(120L, "alice")) + .isInstanceOf(SkillBundleException.class) + .hasMessage("error.skillBundle.validation.failed"); + } + + @Test + void submitForReview_movesVersionToPendingAndCreatesTask() { + SkillBundleVersion version = draftVersion(BundleValidationStatus.PASSED); + SkillBundle bundle = bundleStub(); + given(versionRepository.findById(120L)).willReturn(Optional.of(version)); + given(bundleRepository.findById(99L)).willReturn(Optional.of(bundle)); + given(reviewTaskRepository.findByBundleVersionId(120L)).willReturn(Optional.empty()); + given(reviewTaskRepository.save(any(SkillBundleReviewTask.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + given(versionRepository.save(any(SkillBundleVersion.class))).willAnswer(invocation -> invocation.getArgument(0)); + + SkillBundleReviewTask task = service.submitForReview(120L, "alice"); + + verify(versionRepository).save(any(SkillBundleVersion.class)); + verify(reviewTaskRepository).save(any(SkillBundleReviewTask.class)); + assertThat(task.getStatus(), "PENDING"); + } + + @Test + void approve_blockedWhenSubmitterIsReviewer() { + SkillBundleReviewTask task = pendingTask("alice"); + given(reviewTaskRepository.findById(7L)).willReturn(Optional.of(task)); + + assertThatThrownBy(() -> service.approve(7L, "ok", "alice", now)) + .isInstanceOf(SkillBundleException.class) + .hasMessage("error.skillBundle.reviewTask.selfReview"); + verify(reviewTaskRepository, never()).updateStatusWithVersion(anyLong(), anyString(), anyString(), any(), anyInt()); + } + + @Test + void approve_throwsOnConcurrentUpdate() { + SkillBundleReviewTask task = pendingTask("alice"); + SkillBundleVersion version = draftVersion(BundleValidationStatus.PASSED); + given(reviewTaskRepository.findById(7L)).willReturn(Optional.of(task)); + given(versionRepository.findById(120L)).willReturn(Optional.of(version)); + given(reviewTaskRepository.updateStatusWithVersion(eq(7L), eq("APPROVED"), anyString(), any(), anyInt())) + .willReturn(0); + + assertThatThrownBy(() -> service.approve(7L, "ok", "admin", now)) + .isInstanceOf(SkillBundleException.class) + .hasMessage("error.skillBundle.reviewTask.concurrentUpdate"); + } + + @Test + void approve_publishesVersionAndUpdatesBundle() { + SkillBundleReviewTask task = pendingTask("alice"); + SkillBundleVersion version = draftVersion(BundleValidationStatus.PASSED); + SkillBundle bundle = bundleStub(); + given(reviewTaskRepository.findById(7L)).willReturn(Optional.of(task), Optional.of(task)); + given(versionRepository.findById(120L)).willReturn(Optional.of(version)); + given(bundleRepository.findById(99L)).willReturn(Optional.of(bundle)); + given(reviewTaskRepository.updateStatusWithVersion(eq(7L), eq("APPROVED"), anyString(), any(), anyInt())) + .willReturn(1); + given(versionRepository.save(any(SkillBundleVersion.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(bundleRepository.save(any(SkillBundle.class))).willAnswer(invocation -> invocation.getArgument(0)); + + service.approve(7L, "ok", "admin", now); + + verify(versionRepository, times(1)).save(any(SkillBundleVersion.class)); + verify(bundleRepository, times(1)).save(any(SkillBundle.class)); + } + + @Test + void reject_setsRejectionReason() { + SkillBundleReviewTask task = pendingTask("alice"); + SkillBundleVersion version = draftVersion(BundleValidationStatus.PASSED); + given(reviewTaskRepository.findById(7L)).willReturn(Optional.of(task), Optional.of(task)); + given(versionRepository.findById(120L)).willReturn(Optional.of(version)); + given(reviewTaskRepository.updateStatusWithVersion(eq(7L), eq("REJECTED"), anyString(), any(), anyInt())) + .willReturn(1); + given(versionRepository.save(any(SkillBundleVersion.class))).willAnswer(invocation -> invocation.getArgument(0)); + + service.reject(7L, "needs work", "admin"); + + verify(versionRepository).save(any(SkillBundleVersion.class)); + } + + private SkillBundleVersion draftVersion(BundleValidationStatus validation) { + SkillBundleVersion v = new SkillBundleVersion(99L, "1.0.0", 1L, "{}", "{}", "key"); + v.setStatus(SkillBundleVersionStatus.DRAFT); + v.setValidationStatus(validation); + setField(v, "id", 120L); + return v; + } + + private SkillBundleReviewTask pendingTask(String submitter) { + SkillBundleReviewTask t = new SkillBundleReviewTask(120L, 5L, submitter); + setField(t, "id", 7L); + return t; + } + + private SkillBundle bundleStub() { + SkillBundle b = new SkillBundle(5L, "ops", "Ops", "summary", SkillBundleType.CUSTOM, "alice", "alice"); + setField(b, "id", 99L); + return b; + } + + private void assertThat(String actual, String expected) { + org.assertj.core.api.Assertions.assertThat(actual).isEqualTo(expected); + } + + private static void setField(Object target, String name, Object value) { + try { + java.lang.reflect.Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleItemJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleItemJpaRepository.java new file mode 100644 index 000000000..89fa2bd84 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleItemJpaRepository.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.bundle.SkillBundleItem; +import com.iflytek.skillhub.domain.bundle.SkillBundleItemRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SkillBundleItemJpaRepository extends JpaRepository, SkillBundleItemRepository { + + @Override + List findByBundleVersionId(Long bundleVersionId); + + @Override + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM SkillBundleItem i WHERE i.bundleVersionId = :versionId") + void deleteByBundleVersionId(@Param("versionId") Long bundleVersionId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleJpaRepository.java new file mode 100644 index 000000000..09a33032f --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleJpaRepository.java @@ -0,0 +1,39 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.bundle.SkillBundle; +import com.iflytek.skillhub.domain.bundle.SkillBundleRepository; +import com.iflytek.skillhub.domain.bundle.SkillBundleType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface SkillBundleJpaRepository extends JpaRepository, SkillBundleRepository { + + @Override + Optional findByNamespaceIdAndSlug(Long namespaceId, String slug); + + @Override + Page findByOwnerId(String ownerId, Pageable pageable); + + @Override + Page findByBundleType(SkillBundleType bundleType, Pageable pageable); + + @Override + List findByIdIn(List ids); + + @Override + boolean existsByNamespaceIdAndSlug(Long namespaceId, String slug); + + @Override + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE SkillBundle b SET b.downloadCount = b.downloadCount + 1 WHERE b.id = :id") + void incrementDownloadCount(@Param("id") Long bundleId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleReviewTaskJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleReviewTaskJpaRepository.java new file mode 100644 index 000000000..d650fe307 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleReviewTaskJpaRepository.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.bundle.SkillBundleReviewTask; +import com.iflytek.skillhub.domain.bundle.SkillBundleReviewTaskRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SkillBundleReviewTaskJpaRepository extends JpaRepository, + SkillBundleReviewTaskRepository { + + @Override + Optional findByBundleVersionId(Long bundleVersionId); + + @Override + Page findByStatusAndNamespaceId(String status, Long namespaceId, Pageable pageable); + + @Override + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE SkillBundleReviewTask t + SET t.status = :status, + t.reviewedBy = :reviewedBy, + t.reviewComment = :reviewComment, + t.reviewedAt = CURRENT_TIMESTAMP, + t.version = t.version + 1 + WHERE t.id = :id AND t.version = :expectedVersion + """) + int updateStatusWithVersion(@Param("id") Long id, + @Param("status") String newStatus, + @Param("reviewedBy") String reviewedBy, + @Param("reviewComment") String reviewComment, + @Param("expectedVersion") Integer expectedVersion); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleVersionJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleVersionJpaRepository.java new file mode 100644 index 000000000..e76ad292e --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillBundleVersionJpaRepository.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.bundle.SkillBundleVersion; +import com.iflytek.skillhub.domain.bundle.SkillBundleVersionRepository; +import com.iflytek.skillhub.domain.bundle.SkillBundleVersionStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface SkillBundleVersionJpaRepository extends JpaRepository, SkillBundleVersionRepository { + + @Override + Optional findByBundleIdAndVersion(Long bundleId, String version); + + @Override + List findByBundleId(Long bundleId); + + @Override + List findByBundleIdAndStatus(Long bundleId, SkillBundleVersionStatus status); +} diff --git a/web/src/features/skill-bundle/api.test.ts b/web/src/features/skill-bundle/api.test.ts new file mode 100644 index 000000000..d956c6d78 --- /dev/null +++ b/web/src/features/skill-bundle/api.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { skillBundleApi } from './api' + +describe('skillBundleApi', () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + Object.defineProperty(document, 'cookie', { value: '', writable: true, configurable: true }) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('buildDraft posts JSON body and unwraps envelope', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 0, + msg: 'ok', + data: { bundleId: 89, bundleVersionId: 121, status: 'DRAFT' }, + timestamp: '', + requestId: '', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + globalThis.fetch = mockFetch as unknown as typeof fetch + + const data = await skillBundleApi.buildDraft('team-a', { + slug: 'ops', + displayName: 'Ops', + version: '1.0.0', + type: 'CUSTOM', + summary: 's', + items: [{ skillId: 1, skillVersionId: 11, roleDescription: 'r', required: true, installOrder: 10 }], + }) + + expect(data.bundleId).toBe(89) + const [url, init] = mockFetch.mock.calls[0] + expect(String(url)).toContain('/api/v1/skill-bundles/team-a/drafts/build') + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string).slug).toBe('ops') + }) + + it('getDetail honours optional version query param', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 0, + msg: 'ok', + data: { + id: 1, + namespaceId: 1, + slug: 'ops', + displayName: 'Ops', + type: 'CUSTOM', + summary: 's', + downloadCount: 0, + starCount: 0, + ratingCount: 0, + commentCount: 0, + items: [], + updatedAt: 'now', + }, + timestamp: '', + requestId: '', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + globalThis.fetch = mockFetch as unknown as typeof fetch + + await skillBundleApi.getDetail('team-a', 'ops', '1.0.0') + + const [url] = mockFetch.mock.calls[0] + expect(String(url)).toContain('?version=1.0.0') + }) + + it('throws when envelope code non-zero', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 4001, + msg: 'error.skillBundle.item.versionNotPublished', + data: null, + timestamp: '', + requestId: '', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + globalThis.fetch = mockFetch as unknown as typeof fetch + + await expect(skillBundleApi.recordDownload('team-a', 'ops')).rejects.toThrow( + 'error.skillBundle.item.versionNotPublished', + ) + }) + + it('approveReview sends comment in JSON body', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 0, msg: 'ok', data: 7, timestamp: '', requestId: '' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + globalThis.fetch = mockFetch as unknown as typeof fetch + + await skillBundleApi.approveReview(7, 'looks good') + + const [, init] = mockFetch.mock.calls[0] + expect(JSON.parse(init.body as string)).toEqual({ comment: 'looks good' }) + }) +}) diff --git a/web/src/features/skill-bundle/api.ts b/web/src/features/skill-bundle/api.ts new file mode 100644 index 000000000..79a9da4d9 --- /dev/null +++ b/web/src/features/skill-bundle/api.ts @@ -0,0 +1,117 @@ +/** + * Frontend integration for the skill bundle module: API client and react-query hooks. + * + *

Until the new endpoints land in the generated openapi schema, this module ships + * a small envelope-aware wrapper so the dashboard pages can consume the new APIs. + */ +type ApiEnvelope = { + code: number + msg: string + data: T + timestamp: string + requestId: string +} + +function getCsrfToken(): string | null { + if (typeof document === 'undefined') return null + const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/) + return match ? decodeURIComponent(match[1]) : null +} + +async function request(path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers) + headers.set('Accept', 'application/json') + if (init?.body) headers.set('Content-Type', 'application/json') + const csrf = getCsrfToken() + if (csrf) headers.set('X-XSRF-TOKEN', csrf) + const res = await fetch(path, { ...init, headers, credentials: 'include' }) + if (!res.ok) throw new Error(`Request failed: ${res.status}`) + const envelope = (await res.json()) as ApiEnvelope + if (envelope.code !== 0) throw new Error(envelope.msg ?? `Request failed (code ${envelope.code})`) + return envelope.data +} + +export type BundleType = 'PROJECT' | 'ROLE' | 'SCENARIO' | 'CUSTOM' +export type BundleVersionStatus = 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | 'YANKED' + +export type DraftItemPayload = { + skillId: number + skillVersionId: number + roleDescription: string + required: boolean + installOrder: number +} + +export type BuildDraftPayload = { + slug: string + displayName: string + version: string + type: BundleType + summary: string + targetProjectTypes?: string[] + roleTags?: string[] + items: DraftItemPayload[] + mediaIds?: number[] +} + +export type SkillBundleDetail = { + id: number + namespaceId: number + slug: string + displayName: string + type: BundleType + summary: string + downloadCount: number + starCount: number + ratingAvg?: number | null + ratingCount: number + commentCount: number + latestVersionId?: number | null + version?: { id: number; version: string; status: BundleVersionStatus; publishedAt?: string | null } | null + items: Array<{ + skillId: number + namespaceSlug: string + skillSlug: string + displayName: string + version: string + roleDescription: string + required: boolean + installOrder: number + detailUrl: string + }> + updatedAt: string +} + +export const skillBundleApi = { + buildDraft: (namespace: string, payload: BuildDraftPayload) => + request<{ bundleId: number; bundleVersionId: number; status: BundleVersionStatus }>( + `/api/v1/skill-bundles/${encodeURIComponent(namespace)}/drafts/build`, + { method: 'POST', body: JSON.stringify(payload) }, + ), + getDetail: (namespace: string, slug: string, version?: string) => { + const query = version ? `?version=${encodeURIComponent(version)}` : '' + return request( + `/api/v1/skill-bundles/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}${query}`, + ) + }, + submitReview: (namespace: string, slug: string, bundleVersionId: number) => + request( + `/api/v1/skill-bundles/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/versions/${bundleVersionId}/submit-review`, + { method: 'POST' }, + ), + approveReview: (reviewTaskId: number, comment?: string) => + request(`/api/v1/skill-bundle-reviews/${reviewTaskId}/approve`, { + method: 'POST', + body: JSON.stringify({ comment: comment ?? null }), + }), + rejectReview: (reviewTaskId: number, comment?: string) => + request(`/api/v1/skill-bundle-reviews/${reviewTaskId}/reject`, { + method: 'POST', + body: JSON.stringify({ comment: comment ?? null }), + }), + recordDownload: (namespace: string, slug: string) => + request( + `/api/v1/skill-bundles/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/download`, + { method: 'POST' }, + ), +} diff --git a/web/src/features/skill-bundle/hooks.ts b/web/src/features/skill-bundle/hooks.ts new file mode 100644 index 000000000..67f155559 --- /dev/null +++ b/web/src/features/skill-bundle/hooks.ts @@ -0,0 +1,36 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { skillBundleApi, type BuildDraftPayload, type SkillBundleDetail } from './api' + +const SKILL_BUNDLE_KEY = ['skill-bundles'] as const + +export function useSkillBundleDetail(namespace: string, slug: string, version?: string) { + return useQuery({ + queryKey: [...SKILL_BUNDLE_KEY, namespace, slug, version ?? 'latest'], + queryFn: () => skillBundleApi.getDetail(namespace, slug, version), + enabled: !!namespace && !!slug, + }) +} + +export function useBuildSkillBundleDraft(namespace: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (payload: BuildDraftPayload) => skillBundleApi.buildDraft(namespace, payload), + onSuccess: () => qc.invalidateQueries({ queryKey: SKILL_BUNDLE_KEY }), + }) +} + +export function useApproveBundleReview() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, comment }: { id: number; comment?: string }) => skillBundleApi.approveReview(id, comment), + onSuccess: () => qc.invalidateQueries({ queryKey: SKILL_BUNDLE_KEY }), + }) +} + +export function useRejectBundleReview() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, comment }: { id: number; comment?: string }) => skillBundleApi.rejectReview(id, comment), + onSuccess: () => qc.invalidateQueries({ queryKey: SKILL_BUNDLE_KEY }), + }) +} diff --git a/web/src/pages/bundles/skill-bundle-detail.tsx b/web/src/pages/bundles/skill-bundle-detail.tsx new file mode 100644 index 000000000..b7bcd3a82 --- /dev/null +++ b/web/src/pages/bundles/skill-bundle-detail.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react' +import { useSkillBundleDetail } from '@/features/skill-bundle/hooks' + +/** + * Skill bundle detail page. Shows the bundle metadata, the lock-pinned skill list with + * each item's role description, and an install command snippet. The page is intentionally + * lightweight — richer interactions (star, rate, comment) live on follow-up branches. + */ +export function SkillBundleDetailPage({ + namespaceSlug, + bundleSlug, +}: { + namespaceSlug: string + bundleSlug: string +}) { + const { data, isLoading, error } = useSkillBundleDetail(namespaceSlug, bundleSlug) + const [copied, setCopied] = useState(false) + + if (isLoading) return

加载中...
+ if (error) return
{(error as Error).message}
+ if (!data) return null + + const installCommand = `skillhub bundle install @${namespaceSlug}/${bundleSlug}` + const onCopy = async () => { + await navigator.clipboard?.writeText(installCommand) + setCopied(true) + window.setTimeout(() => setCopied(false), 1500) + } + + return ( +
+
+

{data.displayName}

+

{data.summary}

+
+ 类型 {data.type} + 下载 {data.downloadCount} + 收藏 {data.starCount} + {data.ratingAvg ? 评分 {data.ratingAvg.toFixed(1)} ({data.ratingCount}) : null} +
+
+ + {data.version ? ( +
+
+ 当前版本 {data.version.version} · 状态 {data.version.status} +
+
+ ) : ( +
+ 尚未发布版本 +
+ )} + +
+
+

安装命令

+ +
+
{installCommand}
+
+ +
+

包含的技能

+
    + {data.items.map((item) => ( +
  • +
    +
    + + {item.displayName} + +
    + @{item.namespaceSlug}/{item.skillSlug}@{item.version} +
    +
    +
    + {item.required ? ( + 必装 + ) : ( + 可选 + )} + + 顺序 {item.installOrder} + +
    +
    +

    {item.roleDescription}

    +
  • + ))} +
+
+
+ ) +} + +export default SkillBundleDetailPage From 1ee8797a78d6af245ab484c8bf748527f48c0cd3 Mon Sep 17 00:00:00 2001 From: paradoxgacsd <3154222708@qq.com> Date: Mon, 18 May 2026 22:41:41 +0800 Subject: [PATCH 2/2] fix(skill-bundle): preserve locked bundle installs Generate real bundle manifests, batch resolve draft items, stabilize semver sorting, refresh review resubmission audit fields, and avoid hardcoded scan temp paths that fail PR tests on Windows runners. Constraint: PR review requires locked install metadata, efficient registry lookup, accurate resubmission audit fields, and CI-safe scan temp storage. Rejected: keeping app-layer {} placeholders | bundle installs would not know pinned skill versions. Rejected: retaining fixed /tmp/skillhub-scans paths | Windows test runs can map them to unwritable stale directories. Confidence: high Scope-risk: moderate Directive: Keep bundle manifests derived from resolved item snapshots so installs remain reproducible. Tested: Maven full server test suite, targeted bundle/security tests, web lint, web typecheck, web Vitest, web production build, git diff --check. Not-tested: real-services E2E/staging because Docker is not installed in this environment. Co-authored-by: OmX --- .../JpaSkillBundleItemSourceResolver.java | 48 ++++++++ .../service/bundle/SkillBundleAppService.java | 23 ++-- .../skillhub/stream/ScanTaskConsumer.java | 4 +- .../JpaSkillBundleItemSourceResolverTest.java | 110 ++++++++++++++++++ .../bundle/SkillBundleAppServiceTest.java | 78 +++++++++++++ .../skillhub/stream/ScanTaskConsumerTest.java | 6 +- .../bundle/SkillBundleDraftService.java | 104 ++++++++++++++++- .../bundle/SkillBundleReviewService.java | 6 +- .../domain/bundle/SkillBundleReviewTask.java | 9 ++ .../domain/security/SecurityScanService.java | 7 +- .../bundle/SkillBundleDraftServiceTest.java | 13 ++- .../bundle/SkillBundleReviewServiceTest.java | 27 +++++ web/src/features/skill-bundle/api.test.ts | 2 + 13 files changed, 412 insertions(+), 25 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/JpaSkillBundleItemSourceResolverTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillBundleAppServiceTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/JpaSkillBundleItemSourceResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/JpaSkillBundleItemSourceResolver.java index 8c114f464..46df969ef 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/JpaSkillBundleItemSourceResolver.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/JpaSkillBundleItemSourceResolver.java @@ -11,6 +11,11 @@ import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import org.springframework.stereotype.Component; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + /** * Resolves bundle item snapshots from the existing skill aggregate. The snapshot * is captured at draft-build time so the bundle stays reproducible. @@ -36,11 +41,54 @@ public SkillBundleDraftService.SkillBundleItemSnapshot resolveRegistryItem(Long .orElseThrow(() -> new SkillBundleException("error.skillBundle.item.skillNotFound")); SkillVersion version = skillVersionRepository.findById(skillVersionId) .orElseThrow(() -> new SkillBundleException("error.skillBundle.item.versionNotFound")); + if (!skill.getId().equals(version.getSkillId())) { + throw new SkillBundleException("error.skillBundle.item.versionNotFound"); + } if (version.getStatus() != SkillVersionStatus.PUBLISHED) { throw new SkillBundleException("error.skillBundle.item.versionNotPublished"); } Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) .orElseThrow(() -> new SkillBundleException("error.skillBundle.item.namespaceNotFound")); + return toSnapshot(skill, version, namespace); + } + + @Override + public List resolveRegistryItems( + List items) { + List skillIds = items.stream().map(SkillBundleDraftService.DraftItem::skillId).distinct().toList(); + List versionIds = items.stream().map(SkillBundleDraftService.DraftItem::skillVersionId).distinct().toList(); + + Map skillsById = skillRepository.findByIdIn(skillIds).stream() + .collect(Collectors.toMap(Skill::getId, Function.identity())); + Map versionsById = skillVersionRepository.findByIdIn(versionIds).stream() + .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + List namespaceIds = skillsById.values().stream().map(Skill::getNamespaceId).distinct().toList(); + Map namespacesById = namespaceRepository.findByIdIn(namespaceIds).stream() + .collect(Collectors.toMap(Namespace::getId, Function.identity())); + + return items.stream().map(item -> { + Skill skill = skillsById.get(item.skillId()); + if (skill == null) { + throw new SkillBundleException("error.skillBundle.item.skillNotFound"); + } + SkillVersion version = versionsById.get(item.skillVersionId()); + if (version == null || !skill.getId().equals(version.getSkillId())) { + throw new SkillBundleException("error.skillBundle.item.versionNotFound"); + } + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + throw new SkillBundleException("error.skillBundle.item.versionNotPublished"); + } + Namespace namespace = namespacesById.get(skill.getNamespaceId()); + if (namespace == null) { + throw new SkillBundleException("error.skillBundle.item.namespaceNotFound"); + } + return toSnapshot(skill, version, namespace); + }).toList(); + } + + private SkillBundleDraftService.SkillBundleItemSnapshot toSnapshot(Skill skill, + SkillVersion version, + Namespace namespace) { return new SkillBundleDraftService.SkillBundleItemSnapshot( namespace.getSlug(), skill.getSlug(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillBundleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillBundleAppService.java index 8249d987e..a146c79ce 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillBundleAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillBundleAppService.java @@ -67,7 +67,7 @@ public SkillBundleVersionResponse buildDraft(String namespaceSlug, request.items().stream().map(item -> new SkillBundleDraftService.DraftItem( item.skillId(), item.skillVersionId(), item.roleDescription(), item.required(), item.installOrder())).toList(), - "{}", "{}", placeholderStorageKey(namespaceSlug, request.slug(), request.version()) + placeholderStorageKey(namespaceSlug, request.slug(), request.version()) ); SkillBundleVersion version = draftService.buildDraft(command, creator); @@ -76,7 +76,7 @@ public SkillBundleVersionResponse buildDraft(String namespaceSlug, @Transactional public SkillBundleReviewTask submitForReview(Long bundleVersionId, String submitter) { - return reviewService.submitForReview(bundleVersionId, submitter); + return reviewService.submitForReview(bundleVersionId, submitter, clock.instant()); } @Transactional @@ -142,16 +142,23 @@ private long parseVersionSort(String version) { // semver MAJOR.MINOR.PATCH packed into a sortable long; pre-release parts ignored. String[] parts = version.split("[.+-]"); long sort = 0; - for (int i = 0; i < Math.min(3, parts.length); i++) { - try { - sort = sort * 1_000_000L + Long.parseLong(parts[i]); - } catch (NumberFormatException ignored) { - break; - } + for (int i = 0; i < 3; i++) { + sort = sort * 1_000_000L + parseVersionPart(parts, i); } return sort; } + private long parseVersionPart(String[] parts, int index) { + if (index >= parts.length) { + return 0L; + } + try { + return Long.parseLong(parts[index]); + } catch (NumberFormatException ignored) { + return 0L; + } + } + private String placeholderStorageKey(String namespaceSlug, String slug, String version) { return "bundles/" + namespaceSlug + "/" + slug + "/" + version + "/bundle.zip"; } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java index 19722d872..c8ed9f38d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java @@ -23,7 +23,9 @@ import java.util.Map; public class ScanTaskConsumer extends AbstractStreamConsumer { - private static final Path SCAN_TEMP_DIR = Paths.get("/tmp/skillhub-scans").toAbsolutePath().normalize(); + private static final Path SCAN_TEMP_DIR = Paths.get(System.getProperty("java.io.tmpdir"), "skillhub-scans") + .toAbsolutePath() + .normalize(); private final SecurityScanner securityScanner; private final SecurityScanService securityScanService; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/JpaSkillBundleItemSourceResolverTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/JpaSkillBundleItemSourceResolverTest.java new file mode 100644 index 000000000..ba34c89df --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/JpaSkillBundleItemSourceResolverTest.java @@ -0,0 +1,110 @@ +package com.iflytek.skillhub.service.bundle; + +import com.iflytek.skillhub.domain.bundle.SkillBundleDraftService; +import com.iflytek.skillhub.domain.bundle.SkillBundleException; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class JpaSkillBundleItemSourceResolverTest { + + @Test + void resolveRegistryItems_usesBatchRepositoryReadsAndPreservesInputOrder() { + SkillRepository skillRepository = mock(SkillRepository.class); + SkillVersionRepository versionRepository = mock(SkillVersionRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + JpaSkillBundleItemSourceResolver resolver = + new JpaSkillBundleItemSourceResolver(skillRepository, versionRepository, namespaceRepository); + Skill firstSkill = skill(1L, 5L, "alpha", "Alpha"); + Skill secondSkill = skill(2L, 6L, "beta", "Beta"); + SkillVersion firstVersion = version(11L, 1L, "1.0.0", SkillVersionStatus.PUBLISHED); + SkillVersion secondVersion = version(22L, 2L, "2.0.0", SkillVersionStatus.PUBLISHED); + Namespace firstNamespace = namespace(5L, "team-a"); + Namespace secondNamespace = namespace(6L, "team-b"); + given(skillRepository.findByIdIn(List.of(1L, 2L))).willReturn(List.of(firstSkill, secondSkill)); + given(versionRepository.findByIdIn(List.of(11L, 22L))).willReturn(List.of(firstVersion, secondVersion)); + given(namespaceRepository.findByIdIn(List.of(5L, 6L))).willReturn(List.of(firstNamespace, secondNamespace)); + + List snapshots = resolver.resolveRegistryItems(List.of( + new SkillBundleDraftService.DraftItem(1L, 11L, "first", true, 10), + new SkillBundleDraftService.DraftItem(2L, 22L, "second", true, 20) + )); + + assertThat(snapshots).extracting(SkillBundleDraftService.SkillBundleItemSnapshot::skillSlug) + .containsExactly("alpha", "beta"); + verify(skillRepository).findByIdIn(List.of(1L, 2L)); + verify(versionRepository).findByIdIn(List.of(11L, 22L)); + verify(namespaceRepository).findByIdIn(List.of(5L, 6L)); + verify(skillRepository, never()).findById(1L); + verify(versionRepository, never()).findById(11L); + verify(namespaceRepository, never()).findById(5L); + } + + @Test + void resolveRegistryItems_rejectsVersionThatDoesNotBelongToSkill() { + SkillRepository skillRepository = mock(SkillRepository.class); + SkillVersionRepository versionRepository = mock(SkillVersionRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + JpaSkillBundleItemSourceResolver resolver = + new JpaSkillBundleItemSourceResolver(skillRepository, versionRepository, namespaceRepository); + Skill skill = skill(1L, 5L, "alpha", "Alpha"); + SkillVersion version = version(22L, 2L, "2.0.0", SkillVersionStatus.PUBLISHED); + given(skillRepository.findByIdIn(List.of(1L))).willReturn(List.of(skill)); + given(versionRepository.findByIdIn(List.of(22L))).willReturn(List.of(version)); + given(namespaceRepository.findByIdIn(List.of(5L))).willReturn(List.of(namespace(5L, "team-a"))); + + assertThatThrownBy(() -> resolver.resolveRegistryItems(List.of( + new SkillBundleDraftService.DraftItem(1L, 22L, "role", true, 10) + ))) + .isInstanceOf(SkillBundleException.class) + .hasMessage("error.skillBundle.item.versionNotFound"); + } + + private Skill skill(Long id, Long namespaceId, String slug, String displayName) { + Skill skill = new Skill(namespaceId, slug, "alice", SkillVisibility.PUBLIC); + skill.setDisplayName(displayName); + skill.setSummary(displayName + " summary"); + setField(skill, "id", id); + return skill; + } + + private SkillVersion version(Long id, Long skillId, String value, SkillVersionStatus status) { + SkillVersion version = new SkillVersion(skillId, value, "alice"); + version.setStatus(status); + version.setPublishedAt(Instant.parse("2026-05-18T00:00:00Z")); + setField(version, "id", id); + return version; + } + + private Namespace namespace(Long id, String slug) { + Namespace namespace = new Namespace(slug, slug, "alice"); + setField(namespace, "id", id); + return namespace; + } + + private static void setField(Object target, String name, Object value) { + try { + java.lang.reflect.Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillBundleAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillBundleAppServiceTest.java new file mode 100644 index 000000000..c6b1cae26 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillBundleAppServiceTest.java @@ -0,0 +1,78 @@ +package com.iflytek.skillhub.service.bundle; + +import com.iflytek.skillhub.domain.bundle.SkillBundleDraftService; +import com.iflytek.skillhub.domain.bundle.SkillBundleItemRepository; +import com.iflytek.skillhub.domain.bundle.SkillBundleRepository; +import com.iflytek.skillhub.domain.bundle.SkillBundleReviewService; +import com.iflytek.skillhub.domain.bundle.SkillBundleType; +import com.iflytek.skillhub.domain.bundle.SkillBundleVersion; +import com.iflytek.skillhub.domain.bundle.SkillBundleVersionRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.dto.bundle.BuildSkillBundleDraftRequest; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class SkillBundleAppServiceTest { + + @Test + void buildDraft_padsMissingSemverSegmentsForStableVersionOrdering() { + SkillBundleDraftService draftService = mock(SkillBundleDraftService.class); + SkillBundleReviewService reviewService = mock(SkillBundleReviewService.class); + SkillBundleRepository bundleRepository = mock(SkillBundleRepository.class); + SkillBundleVersionRepository versionRepository = mock(SkillBundleVersionRepository.class); + SkillBundleItemRepository itemRepository = mock(SkillBundleItemRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillBundleAppService service = new SkillBundleAppService( + draftService, reviewService, bundleRepository, versionRepository, + itemRepository, namespaceRepository, + Clock.fixed(Instant.parse("2026-05-18T00:00:00Z"), ZoneOffset.UTC)); + + Namespace namespace = new Namespace("team-a", "Team A", "alice"); + setField(namespace, "id", 5L); + given(namespaceRepository.findBySlug("team-a")).willReturn(Optional.of(namespace)); + given(draftService.buildDraft(any(SkillBundleDraftService.BuildDraftCommand.class), any())) + .willAnswer(invocation -> new SkillBundleVersion(99L, "1.2", 0L, "{}", "{}", "key")); + + service.buildDraft("team-a", request("1.2"), "alice"); + service.buildDraft("team-a", request("1.1.9"), "alice"); + + var captor = org.mockito.ArgumentCaptor.forClass(SkillBundleDraftService.BuildDraftCommand.class); + verify(draftService, org.mockito.Mockito.times(2)) + .buildDraft(captor.capture(), org.mockito.ArgumentMatchers.eq("alice")); + long oneTwo = captor.getAllValues().get(0).versionSort(); + long oneOneNine = captor.getAllValues().get(1).versionSort(); + + assertThat(oneTwo).isEqualTo(1_000_002_000_000L); + assertThat(oneTwo).isGreaterThan(oneOneNine); + } + + private BuildSkillBundleDraftRequest request(String version) { + return new BuildSkillBundleDraftRequest( + "ops", "Ops", version, SkillBundleType.CUSTOM, "summary", + List.of(), List.of(), + List.of(new BuildSkillBundleDraftRequest.DraftItemRequest(1L, 11L, "role", true, 10)), + List.of()); + } + + private static void setField(Object target, String name, Object value) { + try { + java.lang.reflect.Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java index 8e9a8ff62..cae26123e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java @@ -38,7 +38,7 @@ import static org.mockito.Mockito.mock; class ScanTaskConsumerTest { - private static final Path SCAN_TEMP_DIR = Path.of("/tmp/skillhub-scans"); + private static final Path SCAN_TEMP_DIR = Path.of(System.getProperty("java.io.tmpdir"), "skillhub-scans"); @Test void processBusiness_andMarkCompleted_updatesAuditAndCleansTempDirectory() throws Exception { @@ -131,7 +131,7 @@ void retryMessage_republishesTaskWithRetryCount() { ScanTaskConsumer.ScanTaskPayload payload = new ScanTaskConsumer.ScanTaskPayload( "task-3", 77L, - "/tmp/retry", + Path.of(System.getProperty("java.io.tmpdir"), "retry").toString(), null, ScannerType.SKILL_SCANNER ); @@ -141,7 +141,7 @@ void retryMessage_republishesTaskWithRetryCount() { assertThat(producer.publishedTask).isEqualTo(new ScanTask( "task-3", 77L, - "/tmp/retry", + Path.of(System.getProperty("java.io.tmpdir"), "retry").toString(), null, null, producer.publishedTask.createdAtMillis(), diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftService.java index ea8a098fc..a2f62ab4a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftService.java @@ -1,7 +1,13 @@ package com.iflytek.skillhub.domain.bundle; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; /** @@ -18,6 +24,7 @@ * */ public class SkillBundleDraftService { + private static final ObjectMapper MAPPER = new ObjectMapper(); private final SkillBundleRepository bundleRepository; private final SkillBundleVersionRepository versionRepository; @@ -47,12 +54,14 @@ public SkillBundleVersion buildDraft(BuildDraftCommand command, String creator) throw new SkillBundleException("error.skillBundle.version.duplicate"); } + List resolvedItems = resolveItems(command.items()); SkillBundleVersion version = versionRepository.save(new SkillBundleVersion( bundle.getId(), command.version(), command.versionSort(), - command.manifestJson(), command.lockJson(), command.bundleStorageKey())); + buildManifestJson(command, resolvedItems), buildLockJson(resolvedItems), command.bundleStorageKey())); - for (DraftItem item : command.items()) { - SkillBundleItemSnapshot snapshot = itemSourceResolver.resolveRegistryItem(item.skillId(), item.skillVersionId()); + for (ResolvedDraftItem resolved : resolvedItems) { + DraftItem item = resolved.item(); + SkillBundleItemSnapshot snapshot = resolved.snapshot(); SkillBundleItem entity = new SkillBundleItem( version.getId(), BundleItemSourceType.REGISTRY, snapshot.namespaceSlug(), snapshot.skillSlug(), snapshot.version(), @@ -66,6 +75,85 @@ public SkillBundleVersion buildDraft(BuildDraftCommand command, String creator) return version; } + private List resolveItems(List items) { + List snapshots = itemSourceResolver.resolveRegistryItems(items); + if (snapshots == null || snapshots.size() != items.size()) { + throw new SkillBundleException("error.skillBundle.item.versionNotFound"); + } + List resolved = new ArrayList<>(items.size()); + for (int i = 0; i < items.size(); i++) { + resolved.add(new ResolvedDraftItem(items.get(i), snapshots.get(i))); + } + return resolved; + } + + private String buildManifestJson(BuildDraftCommand command, List resolvedItems) { + Map root = new LinkedHashMap<>(); + root.put("schemaVersion", 1); + root.put("slug", command.slug()); + root.put("displayName", command.displayName()); + root.put("version", command.version()); + root.put("type", command.bundleType().name()); + root.put("targetProjectTypes", safeList(command.targetProjectTypes())); + root.put("roleTags", safeList(command.roleTags())); + root.put("items", orderedItems(resolvedItems).stream().map(this::manifestItem).toList()); + return toJson(root); + } + + private String buildLockJson(List resolvedItems) { + Map root = new LinkedHashMap<>(); + root.put("schemaVersion", 1); + root.put("items", orderedItems(resolvedItems).stream().map(this::lockItem).toList()); + return toJson(root); + } + + private List orderedItems(List resolvedItems) { + return resolvedItems.stream() + .sorted(Comparator.comparingInt((ResolvedDraftItem item) -> item.item().installOrder()) + .thenComparing(item -> item.snapshot().namespaceSlug()) + .thenComparing(item -> item.snapshot().skillSlug())) + .toList(); + } + + private Map manifestItem(ResolvedDraftItem resolved) { + DraftItem item = resolved.item(); + SkillBundleItemSnapshot snapshot = resolved.snapshot(); + Map node = lockItem(resolved); + node.put("sourceType", BundleItemSourceType.REGISTRY.name()); + node.put("displayName", snapshot.displayName()); + node.put("summary", snapshot.summary()); + node.put("roleDescription", item.roleDescription()); + node.put("required", item.required()); + node.put("installOrder", item.installOrder()); + return node; + } + + private Map lockItem(ResolvedDraftItem resolved) { + DraftItem item = resolved.item(); + SkillBundleItemSnapshot snapshot = resolved.snapshot(); + Map node = new LinkedHashMap<>(); + node.put("skillId", item.skillId()); + node.put("skillVersionId", item.skillVersionId()); + node.put("namespaceSlug", snapshot.namespaceSlug()); + node.put("skillSlug", snapshot.skillSlug()); + node.put("coordinate", "@" + snapshot.namespaceSlug() + "/" + snapshot.skillSlug()); + node.put("version", snapshot.version()); + node.put("publishedAt", snapshot.publishedAt() == null ? null : snapshot.publishedAt().toString()); + return node; + } + + private List safeList(List values) { + return values == null ? List.of() : List.copyOf(values); + } + + private String toJson(Object payload) { + try { + return MAPPER.writeValueAsString(payload); + } catch (JsonProcessingException e) { + throw new SkillBundleException("error.skillBundle.manifest.invalid"); + } + } + private void validateCommand(BuildDraftCommand command) { if (command.items() == null || command.items().isEmpty()) { throw new SkillBundleException("error.skillBundle.item.empty"); @@ -97,8 +185,6 @@ public record BuildDraftCommand(Long namespaceId, List targetProjectTypes, List roleTags, List items, - String manifestJson, - String lockJson, String bundleStorageKey) {} public record DraftItem(Long skillId, @@ -114,11 +200,19 @@ public record SkillBundleItemSnapshot(String namespaceSlug, String summary, Instant publishedAt) {} + private record ResolvedDraftItem(DraftItem item, SkillBundleItemSnapshot snapshot) {} + /** * Resolves a published skill version snapshot for a bundle item. * Implementations live in {@code skillhub-app} so the domain layer doesn't depend on JPA. */ public interface SkillBundleItemSourceResolver { SkillBundleItemSnapshot resolveRegistryItem(Long skillId, Long skillVersionId); + + default List resolveRegistryItems(List items) { + return items.stream() + .map(item -> resolveRegistryItem(item.skillId(), item.skillVersionId())) + .toList(); + } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewService.java index 15c7a052e..c59595788 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewService.java @@ -25,6 +25,10 @@ public SkillBundleReviewService(SkillBundleRepository bundleRepository, } public SkillBundleReviewTask submitForReview(Long bundleVersionId, String submitter) { + return submitForReview(bundleVersionId, submitter, Instant.now()); + } + + public SkillBundleReviewTask submitForReview(Long bundleVersionId, String submitter, Instant now) { SkillBundleVersion version = versionRepository.findById(bundleVersionId) .orElseThrow(() -> new SkillBundleException("error.skillBundle.version.notFound")); if (version.getStatus() != SkillBundleVersionStatus.DRAFT @@ -46,7 +50,7 @@ public SkillBundleReviewTask submitForReview(Long bundleVersionId, String submit SkillBundleReviewTask task = reviewTaskRepository.findByBundleVersionId(bundleVersionId) .orElseGet(() -> new SkillBundleReviewTask(bundleVersionId, bundle.getNamespaceId(), submitter)); - task.setStatus("PENDING"); + task.resubmit(submitter, now); return reviewTaskRepository.save(task); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewTask.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewTask.java index 33054111f..88f025049 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewTask.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewTask.java @@ -67,4 +67,13 @@ public SkillBundleReviewTask(Long bundleVersionId, Long namespaceId, String subm public void setReviewedBy(String reviewedBy) { this.reviewedBy = reviewedBy; } public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; } public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; } + + public void resubmit(String submitter, Instant submittedAt) { + this.status = "PENDING"; + this.submittedBy = submitter; + this.submittedAt = submittedAt; + this.reviewedBy = null; + this.reviewComment = null; + this.reviewedAt = null; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java index 7a901f734..57faccd15 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java @@ -27,8 +27,9 @@ public class SecurityScanService { private static final Logger log = LoggerFactory.getLogger(SecurityScanService.class); - private static final String TEMP_DIR = "/tmp/skillhub-scans"; - private static final Path TEMP_BASE_DIR = Paths.get(TEMP_DIR).toAbsolutePath().normalize(); + private static final Path TEMP_BASE_DIR = Paths.get(System.getProperty("java.io.tmpdir"), "skillhub-scans") + .toAbsolutePath() + .normalize(); private final SecurityAuditRepository auditRepository; private final SkillVersionRepository skillVersionRepository; @@ -122,7 +123,7 @@ public void processScanResult(Long versionId, ScannerType scannerType, SecurityS private Path saveTempDirectory(Long versionId, List entries) { try { - Path skillDir = TEMP_BASE_DIR.resolve(String.valueOf(versionId)).normalize(); + Path skillDir = TEMP_BASE_DIR.resolve(versionId + "-" + UUID.randomUUID()).normalize(); Files.createDirectories(skillDir); for (PackageEntry entry : entries) { Path filePath = resolveSafeChild(skillDir, entry.path()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftServiceTest.java index b4336e6ef..ad44ebfd3 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleDraftServiceTest.java @@ -17,6 +17,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; /** * Tests for {@link SkillBundleDraftService} validation and persistence rules. @@ -117,15 +118,19 @@ void buildDraft_persistsBundleVersionAndItemsWithSnapshot() { setField(v, "id", 120L); return v; }); - given(resolver.resolveRegistryItem(1L, 11L)).willReturn( - new SkillBundleDraftService.SkillBundleItemSnapshot( + given(resolver.resolveRegistryItems(List.of(item(1L, 11L, true, 10)))).willReturn( + List.of(new SkillBundleDraftService.SkillBundleItemSnapshot( "global", "code-review", "Code Review", "1.3.0", - "Audit interface boundaries.", Instant.now())); + "Audit interface boundaries.", Instant.parse("2026-05-01T00:00:00Z")))); SkillBundleVersion saved = service.buildDraft(cmd, "alice"); assertThat(saved.getId()).isEqualTo(120L); + assertThat(saved.getManifestJson()).contains("\"coordinate\":\"@global/code-review\""); + assertThat(saved.getManifestJson()).contains("\"roleDescription\":\"role\""); + assertThat(saved.getLockJson()).contains("\"skillVersionId\":11"); verify(itemRepository).save(any(SkillBundleItem.class)); + verify(resolver, times(1)).resolveRegistryItems(List.of(item(1L, 11L, true, 10))); } private SkillBundleDraftService.BuildDraftCommand command(SkillBundleType type, @@ -134,7 +139,7 @@ private SkillBundleDraftService.BuildDraftCommand command(SkillBundleType type, List items) { return new SkillBundleDraftService.BuildDraftCommand( 5L, "ops", "Ops", "summary", "1.0.0", 1L, - type, projectTypes, roleTags, items, "{}", "{}", "key"); + type, projectTypes, roleTags, items, "key"); } private SkillBundleDraftService.DraftItem item(Long skillId, Long versionId, boolean required, int order) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewServiceTest.java index 72ea1637f..5d2b2c120 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/bundle/SkillBundleReviewServiceTest.java @@ -82,6 +82,33 @@ void submitForReview_movesVersionToPendingAndCreatesTask() { assertThat(task.getStatus(), "PENDING"); } + @Test + void submitForReview_refreshesAuditFieldsWhenExistingTaskIsResubmitted() { + SkillBundleVersion version = draftVersion(BundleValidationStatus.PASSED); + version.setStatus(SkillBundleVersionStatus.REJECTED); + SkillBundle bundle = bundleStub(); + SkillBundleReviewTask existing = pendingTask("alice"); + existing.setReviewedBy("admin"); + existing.setReviewComment("needs work"); + existing.setReviewedAt(Instant.parse("2026-05-20T10:00:00Z")); + Instant resubmittedAt = Instant.parse("2026-06-02T10:00:00Z"); + given(versionRepository.findById(120L)).willReturn(Optional.of(version)); + given(bundleRepository.findById(99L)).willReturn(Optional.of(bundle)); + given(reviewTaskRepository.findByBundleVersionId(120L)).willReturn(Optional.of(existing)); + given(reviewTaskRepository.save(any(SkillBundleReviewTask.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + given(versionRepository.save(any(SkillBundleVersion.class))).willAnswer(invocation -> invocation.getArgument(0)); + + SkillBundleReviewTask task = service.submitForReview(120L, "bob", resubmittedAt); + + org.assertj.core.api.Assertions.assertThat(task.getStatus()).isEqualTo("PENDING"); + org.assertj.core.api.Assertions.assertThat(task.getSubmittedBy()).isEqualTo("bob"); + org.assertj.core.api.Assertions.assertThat(task.getSubmittedAt()).isEqualTo(resubmittedAt); + org.assertj.core.api.Assertions.assertThat(task.getReviewedBy()).isNull(); + org.assertj.core.api.Assertions.assertThat(task.getReviewComment()).isNull(); + org.assertj.core.api.Assertions.assertThat(task.getReviewedAt()).isNull(); + } + @Test void approve_blockedWhenSubmitterIsReviewer() { SkillBundleReviewTask task = pendingTask("alice"); diff --git a/web/src/features/skill-bundle/api.test.ts b/web/src/features/skill-bundle/api.test.ts index d956c6d78..de255255d 100644 --- a/web/src/features/skill-bundle/api.test.ts +++ b/web/src/features/skill-bundle/api.test.ts @@ -1,3 +1,5 @@ +// @vitest-environment jsdom + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { skillBundleApi } from './api'