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