From cfcdb545f0eb7b5cc4b791f9528ed3042e1d90fd Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 17:46:48 +0800 Subject: [PATCH] feat(domain): update entities, services, repositories, and add shared exceptions --- .../domain/event/SkillPublishedEvent.java | 2 +- .../skillhub/domain/namespace/Namespace.java | 6 +- .../domain/namespace/NamespaceMember.java | 8 +- .../namespace/NamespaceMemberRepository.java | 6 +- .../namespace/NamespaceMemberService.java | 40 ++- .../domain/namespace/NamespaceRepository.java | 2 + .../domain/namespace/NamespaceService.java | 23 +- .../domain/namespace/SlugValidator.java | 28 +- .../domain/review/PromotionRequest.java | 12 +- .../review/PromotionRequestRepository.java | 2 +- .../domain/review/PromotionService.java | 6 +- .../review/ReviewPermissionChecker.java | 4 +- .../skillhub/domain/review/ReviewService.java | 8 +- .../skillhub/domain/review/ReviewTask.java | 12 +- .../domain/review/ReviewTaskRepository.java | 4 +- .../exception/DomainBadRequestException.java | 8 + .../exception/DomainForbiddenException.java | 8 + .../exception/LocalizedDomainException.java | 21 ++ .../iflytek/skillhub/domain/skill/Skill.java | 18 +- .../domain/skill/SkillFileRepository.java | 2 +- .../domain/skill/SkillRepository.java | 4 +- .../skillhub/domain/skill/SkillTag.java | 6 +- .../skillhub/domain/skill/SkillVersion.java | 16 +- .../domain/skill/SkillVersionRepository.java | 1 + .../domain/skill/VisibilityChecker.java | 4 +- .../skill/metadata/SkillMetadataParser.java | 24 +- .../skill/service/SkillDownloadService.java | 51 ++-- .../skill/service/SkillPublishService.java | 94 ++++-- .../skill/service/SkillQueryService.java | 275 ++++++++++++++++-- .../domain/skill/service/SkillTagService.java | 46 ++- .../validation/NoOpPrePublishValidator.java | 3 + .../skill/validation/PrePublishValidator.java | 2 +- .../validation/SkillPackageValidator.java | 8 +- .../skillhub/domain/user/UserAccount.java | 15 +- .../domain/user/UserAccountRepository.java | 2 +- .../namespace/NamespaceMemberServiceTest.java | 88 +++--- .../namespace/NamespaceServiceTest.java | 47 ++- .../domain/namespace/SlugValidatorTest.java | 37 +-- .../domain/review/PromotionServiceTest.java | 8 +- .../review/ReviewPermissionCheckerTest.java | 44 +-- .../domain/review/ReviewServiceTest.java | 8 +- .../domain/skill/VisibilityCheckerTest.java | 8 +- .../metadata/SkillMetadataParserTest.java | 45 +-- .../service/SkillDownloadServiceTest.java | 39 ++- .../service/SkillPublishServiceTest.java | 31 +- .../skill/service/SkillQueryServiceTest.java | 169 ++++++++++- .../skill/service/SkillTagServiceTest.java | 55 +++- 47 files changed, 1012 insertions(+), 338 deletions(-) create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainBadRequestException.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainForbiddenException.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedDomainException.java diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillPublishedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillPublishedEvent.java index b11c0826..e9e5b2b8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillPublishedEvent.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillPublishedEvent.java @@ -1,3 +1,3 @@ package com.iflytek.skillhub.domain.event; -public record SkillPublishedEvent(Long skillId, Long versionId, Long publisherId) {} +public record SkillPublishedEvent(Long skillId, Long versionId, String publisherId) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java index fec23b46..55073de8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java @@ -31,7 +31,7 @@ public class Namespace { private String avatarUrl; @Column(name = "created_by") - private Long createdBy; + private String createdBy; @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -41,7 +41,7 @@ public class Namespace { protected Namespace() {} - public Namespace(String slug, String displayName, Long createdBy) { + public Namespace(String slug, String displayName, String createdBy) { this.slug = slug; this.displayName = displayName; this.createdBy = createdBy; @@ -69,7 +69,7 @@ public class Namespace { public void setType(NamespaceType type) { this.type = type; } public String getAvatarUrl() { return avatarUrl; } public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; } - public Long getCreatedBy() { return createdBy; } + public String getCreatedBy() { return createdBy; } public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java index 4ba06bf1..1edc7323 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java @@ -15,7 +15,7 @@ public class NamespaceMember { private Long namespaceId; @Column(name = "user_id", nullable = false) - private Long userId; + private String userId; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 32) @@ -29,7 +29,7 @@ public class NamespaceMember { protected NamespaceMember() {} - public NamespaceMember(Long namespaceId, Long userId, NamespaceRole role) { + public NamespaceMember(Long namespaceId, String userId, NamespaceRole role) { this.namespaceId = namespaceId; this.userId = userId; this.role = role; @@ -49,8 +49,8 @@ public class NamespaceMember { public Long getId() { return id; } public Long getNamespaceId() { return namespaceId; } public void setNamespaceId(Long namespaceId) { this.namespaceId = namespaceId; } - public Long getUserId() { return userId; } - public void setUserId(Long userId) { this.userId = userId; } + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } public NamespaceRole getRole() { return role; } public void setRole(NamespaceRole role) { this.role = role; } public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java index c524a4f6..6387dab9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java @@ -7,9 +7,9 @@ import java.util.List; import java.util.Optional; public interface NamespaceMemberRepository { - Optional findByNamespaceIdAndUserId(Long namespaceId, Long userId); - List findByUserId(Long userId); + Optional findByNamespaceIdAndUserId(Long namespaceId, String userId); + List findByUserId(String userId); Page findByNamespaceId(Long namespaceId, Pageable pageable); NamespaceMember save(NamespaceMember member); - void deleteByNamespaceIdAndUserId(Long namespaceId, Long userId); + void deleteByNamespaceIdAndUserId(Long namespaceId, String userId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java index 8362c3d1..11b55a83 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.namespace; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -11,19 +12,24 @@ import java.util.Optional; public class NamespaceMemberService { private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceService namespaceService; - public NamespaceMemberService(NamespaceMemberRepository namespaceMemberRepository) { + public NamespaceMemberService(NamespaceMemberRepository namespaceMemberRepository, + NamespaceService namespaceService) { this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceService = namespaceService; } @Transactional - public NamespaceMember addMember(Long namespaceId, Long userId, NamespaceRole role) { + public NamespaceMember addMember(Long namespaceId, String userId, NamespaceRole role, String operatorUserId) { + namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); + if (role == NamespaceRole.OWNER) { - throw new IllegalArgumentException("Cannot directly assign OWNER role"); + throw new DomainBadRequestException("error.namespace.member.owner.assignDirect"); } if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId).isPresent()) { - throw new IllegalArgumentException("User is already a member of this namespace"); + throw new DomainBadRequestException("error.namespace.member.alreadyExists"); } NamespaceMember member = new NamespaceMember(namespaceId, userId, role); @@ -31,41 +37,45 @@ public class NamespaceMemberService { } @Transactional - public void removeMember(Long namespaceId, Long userId) { + public void removeMember(Long namespaceId, String userId, String operatorUserId) { + namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); + NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) - .orElseThrow(() -> new IllegalArgumentException("Member not found")); + .orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound")); if (member.getRole() == NamespaceRole.OWNER) { - throw new IllegalArgumentException("Cannot remove OWNER from namespace"); + throw new DomainBadRequestException("error.namespace.member.owner.remove"); } namespaceMemberRepository.deleteByNamespaceIdAndUserId(namespaceId, userId); } @Transactional - public NamespaceMember updateMemberRole(Long namespaceId, Long userId, NamespaceRole newRole) { + public NamespaceMember updateMemberRole(Long namespaceId, String userId, NamespaceRole newRole, String operatorUserId) { + namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); + if (newRole == NamespaceRole.OWNER) { - throw new IllegalArgumentException("Cannot set OWNER role directly. Use transferOwnership instead"); + throw new DomainBadRequestException("error.namespace.member.owner.setDirect"); } NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) - .orElseThrow(() -> new IllegalArgumentException("Member not found")); + .orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound")); member.setRole(newRole); return namespaceMemberRepository.save(member); } @Transactional - public void transferOwnership(Long namespaceId, Long currentOwnerId, Long newOwnerId) { + public void transferOwnership(Long namespaceId, String currentOwnerId, String newOwnerId) { NamespaceMember currentOwner = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId) - .orElseThrow(() -> new IllegalArgumentException("Current owner not found")); + .orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.current.notFound")); if (currentOwner.getRole() != NamespaceRole.OWNER) { - throw new IllegalArgumentException("User is not the current owner"); + throw new DomainBadRequestException("error.namespace.owner.current.invalid"); } NamespaceMember newOwner = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, newOwnerId) - .orElseThrow(() -> new IllegalArgumentException("New owner not found in namespace")); + .orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.new.notFound")); currentOwner.setRole(NamespaceRole.ADMIN); newOwner.setRole(NamespaceRole.OWNER); @@ -74,7 +84,7 @@ public class NamespaceMemberService { namespaceMemberRepository.save(newOwner); } - public Optional getMemberRole(Long namespaceId, Long userId) { + public Optional getMemberRole(Long namespaceId, String userId) { return namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) .map(NamespaceMember::getRole); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index 9aea1b03..febf4ac0 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -3,10 +3,12 @@ package com.iflytek.skillhub.domain.namespace; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; import java.util.Optional; public interface NamespaceRepository { Optional findById(Long id); + List findByIdIn(List ids); Optional findBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); Namespace save(Namespace namespace); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java index 3caf0881..6062b28b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.domain.namespace; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,11 +18,11 @@ public class NamespaceService { } @Transactional - public Namespace createNamespace(String slug, String displayName, String description, Long creatorUserId) { + public Namespace createNamespace(String slug, String displayName, String description, String creatorUserId) { SlugValidator.validate(slug); if (namespaceRepository.findBySlug(slug).isPresent()) { - throw new IllegalArgumentException("Namespace with slug '" + slug + "' already exists"); + throw new DomainBadRequestException("error.namespace.slug.exists", slug); } Namespace namespace = new Namespace(slug, displayName, creatorUserId); @@ -35,9 +37,11 @@ public class NamespaceService { } @Transactional - public Namespace updateNamespace(Long namespaceId, String displayName, String description, String avatarUrl) { + public Namespace updateNamespace(Long namespaceId, String displayName, String description, String avatarUrl, + String operatorUserId) { Namespace namespace = namespaceRepository.findById(namespaceId) - .orElseThrow(() -> new IllegalArgumentException("Namespace not found with id: " + namespaceId)); + .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); + assertAdminOrOwner(namespaceId, operatorUserId); if (displayName != null) { namespace.setDisplayName(displayName); @@ -54,6 +58,15 @@ public class NamespaceService { public Namespace getNamespaceBySlug(String slug) { return namespaceRepository.findBySlug(slug) - .orElseThrow(() -> new IllegalArgumentException("Namespace not found with slug: " + slug)); + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + } + + void assertAdminOrOwner(Long namespaceId, String userId) { + NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .map(NamespaceMember::getRole) + .orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required")); + if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) { + throw new DomainForbiddenException("error.namespace.admin.required"); + } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java index c166cd49..dd3457e7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.domain.namespace; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; + import java.util.Set; import java.util.regex.Pattern; @@ -15,22 +17,32 @@ public class SlugValidator { public static void validate(String slug) { if (slug == null || slug.isBlank()) { - throw new IllegalArgumentException("Slug cannot be null or blank"); + throw new DomainBadRequestException("error.slug.blank"); } if (slug.length() < MIN_LENGTH || slug.length() > MAX_LENGTH) { - throw new IllegalArgumentException( - String.format("Slug length must be between %d and %d characters", MIN_LENGTH, MAX_LENGTH)); + throw new DomainBadRequestException("error.slug.length", MIN_LENGTH, MAX_LENGTH); } if (!SLUG_PATTERN.matcher(slug).matches()) { - throw new IllegalArgumentException( - "Slug must contain only lowercase alphanumeric characters and hyphens, " + - "and must start and end with an alphanumeric character"); + throw new DomainBadRequestException("error.slug.pattern"); } if (slug.contains("--")) { - throw new IllegalArgumentException("Slug cannot contain consecutive hyphens"); + throw new DomainBadRequestException("error.slug.doubleHyphen"); } if (RESERVED_SLUGS.contains(slug)) { - throw new IllegalArgumentException("Slug '" + slug + "' is reserved and cannot be used"); + throw new DomainBadRequestException("error.slug.reserved", slug); } } + + public static String slugify(String raw) { + if (raw == null) { + throw new DomainBadRequestException("error.slug.blank"); + } + String slug = raw.trim().toLowerCase() + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+", "") + .replaceAll("-+$", "") + .replaceAll("-{2,}", "-"); + validate(slug); + return slug; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequest.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequest.java index 5bcf8b14..9b4b0424 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequest.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequest.java @@ -32,10 +32,10 @@ public class PromotionRequest { private Integer version = 1; @Column(name = "submitted_by", nullable = false) - private Long submittedBy; + private String submittedBy; @Column(name = "reviewed_by") - private Long reviewedBy; + private String reviewedBy; @Column(name = "review_comment", columnDefinition = "TEXT") private String reviewComment; @@ -49,7 +49,7 @@ public class PromotionRequest { protected PromotionRequest() {} public PromotionRequest(Long sourceSkillId, Long sourceVersionId, - Long targetNamespaceId, Long submittedBy) { + Long targetNamespaceId, String submittedBy) { this.sourceSkillId = sourceSkillId; this.sourceVersionId = sourceVersionId; this.targetNamespaceId = targetNamespaceId; @@ -76,11 +76,11 @@ public class PromotionRequest { public Integer getVersion() { return version; } - public Long getSubmittedBy() { return submittedBy; } + public String getSubmittedBy() { return submittedBy; } - public Long getReviewedBy() { return reviewedBy; } + public String getReviewedBy() { return reviewedBy; } - public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; } + public void setReviewedBy(String reviewedBy) { this.reviewedBy = reviewedBy; } public String getReviewComment() { return reviewComment; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java index 3587e33f..51ff4860 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java @@ -9,6 +9,6 @@ public interface PromotionRequestRepository { Optional findById(Long id); Optional findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status); Page findByStatus(ReviewTaskStatus status, Pageable pageable); - int updateStatusWithVersion(Long id, ReviewTaskStatus status, Long reviewedBy, + int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, String reviewComment, Long targetSkillId, Integer expectedVersion); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 0ba7fba6..01d4d581 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -46,7 +46,7 @@ public class PromotionService { @Transactional public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId, - Long targetNamespaceId, Long userId) { + Long targetNamespaceId, String userId) { Skill sourceSkill = skillRepository.findById(sourceSkillId) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId)); @@ -78,7 +78,7 @@ public class PromotionService { } @Transactional - public PromotionRequest approvePromotion(Long promotionId, Long reviewerId, + public PromotionRequest approvePromotion(Long promotionId, String reviewerId, String comment, Set platformRoles) { PromotionRequest request = promotionRequestRepository.findById(promotionId) .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", promotionId)); @@ -149,7 +149,7 @@ public class PromotionService { } @Transactional - public PromotionRequest rejectPromotion(Long promotionId, Long reviewerId, + public PromotionRequest rejectPromotion(Long promotionId, String reviewerId, String comment, Set platformRoles) { PromotionRequest request = promotionRequestRepository.findById(promotionId) .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", promotionId)); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index 388ec22f..0cc89df1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -21,7 +21,7 @@ public class ReviewPermissionChecker { * @return true if the user is allowed to review */ public boolean canReview(ReviewTask task, - Long userId, + String userId, NamespaceType namespaceType, Map userNamespaceRoles, Set platformRoles) { @@ -49,7 +49,7 @@ public class ReviewPermissionChecker { */ public boolean canReviewPromotion( PromotionRequest request, - Long userId, + String userId, Set platformRoles) { if (request.getSubmittedBy().equals(userId)) { return false; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index 1481701c..fb2482af 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -48,7 +48,7 @@ public class ReviewService { } @Transactional - public ReviewTask submitReview(Long skillVersionId, Long namespaceId, Long userId) { + public ReviewTask submitReview(Long skillVersionId, Long namespaceId, String userId) { SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId)); @@ -68,7 +68,7 @@ public class ReviewService { } @Transactional - public ReviewTask approveReview(Long reviewTaskId, Long reviewerId, String comment, + public ReviewTask approveReview(Long reviewTaskId, String reviewerId, String comment, Map userNamespaceRoles, Set platformRoles) { ReviewTask task = reviewTaskRepository.findById(reviewTaskId) @@ -111,7 +111,7 @@ public class ReviewService { } @Transactional - public ReviewTask rejectReview(Long reviewTaskId, Long reviewerId, String comment, + public ReviewTask rejectReview(Long reviewTaskId, String reviewerId, String comment, Map userNamespaceRoles, Set platformRoles) { ReviewTask task = reviewTaskRepository.findById(reviewTaskId) @@ -144,7 +144,7 @@ public class ReviewService { } @Transactional - public void withdrawReview(Long skillVersionId, Long userId) { + public void withdrawReview(Long skillVersionId, String userId) { ReviewTask task = reviewTaskRepository.findBySkillVersionIdAndStatus( skillVersionId, ReviewTaskStatus.PENDING) .orElseThrow(() -> new DomainNotFoundException("review_task.not_found_for_version", skillVersionId)); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java index 719a6538..4ccd6786 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java @@ -26,10 +26,10 @@ public class ReviewTask { private Integer version = 1; @Column(name = "submitted_by", nullable = false) - private Long submittedBy; + private String submittedBy; @Column(name = "reviewed_by") - private Long reviewedBy; + private String reviewedBy; @Column(name = "review_comment", columnDefinition = "TEXT") private String reviewComment; @@ -43,7 +43,7 @@ public class ReviewTask { protected ReviewTask() {} public ReviewTask(Long skillVersionId, Long namespaceId, - Long submittedBy) { + String submittedBy) { this.skillVersionId = skillVersionId; this.namespaceId = namespaceId; this.submittedBy = submittedBy; @@ -61,11 +61,11 @@ public class ReviewTask { public Integer getVersion() { return version; } - public Long getSubmittedBy() { return submittedBy; } + public String getSubmittedBy() { return submittedBy; } - public Long getReviewedBy() { return reviewedBy; } + public String getReviewedBy() { return reviewedBy; } - public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; } + public void setReviewedBy(String reviewedBy) { this.reviewedBy = reviewedBy; } public String getReviewComment() { return reviewComment; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java index 5105c46a..9dfd545c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java @@ -9,8 +9,8 @@ public interface ReviewTaskRepository { Optional findById(Long id); Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); - Page findBySubmittedByAndStatus(Long submittedBy, ReviewTaskStatus status, Pageable pageable); + Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); void delete(ReviewTask reviewTask); - int updateStatusWithVersion(Long id, ReviewTaskStatus status, Long reviewedBy, + int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, String reviewComment, Integer expectedVersion); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainBadRequestException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainBadRequestException.java new file mode 100644 index 00000000..670489fe --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainBadRequestException.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.shared.exception; + +public class DomainBadRequestException extends LocalizedDomainException { + + public DomainBadRequestException(String messageCode, Object... messageArgs) { + super(messageCode, messageArgs); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainForbiddenException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainForbiddenException.java new file mode 100644 index 00000000..1d5c9525 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainForbiddenException.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.shared.exception; + +public class DomainForbiddenException extends LocalizedDomainException { + + public DomainForbiddenException(String messageCode, Object... messageArgs) { + super(messageCode, messageArgs); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedDomainException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedDomainException.java new file mode 100644 index 00000000..c09d1919 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedDomainException.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.domain.shared.exception; + +public abstract class LocalizedDomainException extends RuntimeException { + + private final String messageCode; + private final Object[] messageArgs; + + protected LocalizedDomainException(String messageCode, Object... messageArgs) { + super(messageCode); + this.messageCode = messageCode; + this.messageArgs = messageArgs == null ? new Object[0] : messageArgs; + } + + public String messageCode() { + return messageCode; + } + + public Object[] messageArgs() { + return messageArgs.clone(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java index 6a290561..32598bf3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java @@ -25,7 +25,7 @@ public class Skill { private String summary; @Column(name = "owner_id", nullable = false) - private Long ownerId; + private String ownerId; @Column(name = "source_skill_id") private Long sourceSkillId; @@ -54,13 +54,13 @@ public class Skill { private Integer ratingCount = 0; @Column(name = "created_by") - private Long createdBy; + private String createdBy; @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @Column(name = "updated_by") - private Long updatedBy; + private String updatedBy; @Column(name = "updated_at") private LocalDateTime updatedAt; @@ -68,7 +68,7 @@ public class Skill { protected Skill() { } - public Skill(Long namespaceId, String slug, Long ownerId, SkillVisibility visibility) { + public Skill(Long namespaceId, String slug, String ownerId, SkillVisibility visibility) { this.namespaceId = namespaceId; this.slug = slug; this.ownerId = ownerId; @@ -108,7 +108,7 @@ public class Skill { return summary; } - public Long getOwnerId() { + public String getOwnerId() { return ownerId; } @@ -144,7 +144,7 @@ public class Skill { return ratingCount; } - public Long getCreatedBy() { + public String getCreatedBy() { return createdBy; } @@ -152,7 +152,7 @@ public class Skill { return createdAt; } - public Long getUpdatedBy() { + public String getUpdatedBy() { return updatedBy; } @@ -185,11 +185,11 @@ public class Skill { this.latestVersionId = latestVersionId; } - public void setCreatedBy(Long createdBy) { + public void setCreatedBy(String createdBy) { this.createdBy = createdBy; } - public void setUpdatedBy(Long updatedBy) { + public void setUpdatedBy(String updatedBy) { this.updatedBy = updatedBy; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java index 0a93e8e5..4cc0bd81 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java @@ -5,6 +5,6 @@ import java.util.List; public interface SkillFileRepository { List findByVersionId(Long versionId); SkillFile save(SkillFile file); - void saveAll(List files); + List saveAll(Iterable files); void deleteByVersionId(Long versionId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java index 46902848..93816ba1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java @@ -5,9 +5,11 @@ import java.util.Optional; public interface SkillRepository { Optional findById(Long id); + List findByIdIn(List ids); + List findAll(); Optional findByNamespaceIdAndSlug(Long namespaceId, String slug); List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status); Skill save(Skill skill); - List findByOwnerId(Long ownerId); + List findByOwnerId(String ownerId); void incrementDownloadCount(Long skillId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java index 92f6c432..1bf6c292 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java @@ -21,7 +21,7 @@ public class SkillTag { private Long versionId; @Column(name = "created_by", nullable = false) - private Long createdBy; + private String createdBy; @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -32,7 +32,7 @@ public class SkillTag { protected SkillTag() { } - public SkillTag(Long skillId, String tagName, Long versionId, Long createdBy) { + public SkillTag(Long skillId, String tagName, Long versionId, String createdBy) { this.skillId = skillId; this.tagName = tagName; this.versionId = versionId; @@ -67,7 +67,7 @@ public class SkillTag { return versionId; } - public Long getCreatedBy() { + public String getCreatedBy() { return createdBy; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java index 2a2a8136..a05c425f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java @@ -2,6 +2,8 @@ package com.iflytek.skillhub.domain.skill; import jakarta.persistence.*; import java.time.LocalDateTime; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; @Entity @Table(name = "skill_version") @@ -14,7 +16,7 @@ public class SkillVersion { @Column(name = "skill_id", nullable = false) private Long skillId; - @Column(nullable = false, length = 50) + @Column(nullable = false, length = 64) private String version; @Enumerated(EnumType.STRING) @@ -24,10 +26,12 @@ public class SkillVersion { @Column(columnDefinition = "TEXT") private String changelog; - @Column(name = "parsed_metadata_json", columnDefinition = "TEXT") + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "parsed_metadata_json", columnDefinition = "jsonb") private String parsedMetadataJson; - @Column(name = "manifest_json", columnDefinition = "TEXT") + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "manifest_json", columnDefinition = "jsonb") private String manifestJson; @Column(name = "file_count", nullable = false) @@ -40,7 +44,7 @@ public class SkillVersion { private LocalDateTime publishedAt; @Column(name = "created_by", nullable = false) - private Long createdBy; + private String createdBy; @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -48,7 +52,7 @@ public class SkillVersion { protected SkillVersion() { } - public SkillVersion(Long skillId, String version, Long createdBy) { + public SkillVersion(Long skillId, String version, String createdBy) { this.skillId = skillId; this.version = version; this.createdBy = createdBy; @@ -101,7 +105,7 @@ public class SkillVersion { return publishedAt; } - public Long getCreatedBy() { + public String getCreatedBy() { return createdBy; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java index 427c1624..7dce8b83 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java @@ -5,6 +5,7 @@ import java.util.Optional; public interface SkillVersionRepository { Optional findById(Long id); + List findByIdIn(List ids); Optional findBySkillIdAndVersion(Long skillId, String version); List findBySkillIdAndStatus(Long skillId, SkillVersionStatus status); SkillVersion save(SkillVersion version); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index da09e839..2598a0bf 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -6,7 +6,7 @@ import java.util.Map; public class VisibilityChecker { - public boolean canAccess(Skill skill, Long currentUserId, Map userNamespaceRoles) { + public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles) { return switch (skill.getVisibility()) { case PUBLIC -> true; case NAMESPACE_ONLY -> userNamespaceRoles.containsKey(skill.getNamespaceId()); @@ -14,7 +14,7 @@ public class VisibilityChecker { }; } - private boolean isOwner(Skill skill, Long currentUserId) { + private boolean isOwner(Skill skill, String currentUserId) { return currentUserId != null && skill.getOwnerId().equals(currentUserId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java index b12e2b15..ffaae86c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.skill.metadata; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import org.yaml.snakeyaml.Yaml; import java.util.Map; @@ -10,23 +11,23 @@ public class SkillMetadataParser { public SkillMetadata parse(String content) { if (content == null || content.isBlank()) { - throw new IllegalArgumentException("Content cannot be null or empty"); + throw new DomainBadRequestException("error.skill.metadata.content.empty"); } String trimmedContent = content.trim(); if (!trimmedContent.startsWith(FRONTMATTER_DELIMITER)) { - throw new IllegalArgumentException("Missing frontmatter: content must start with '---'"); + throw new DomainBadRequestException("error.skill.metadata.frontmatter.missingStart"); } int firstDelimiterEnd = trimmedContent.indexOf('\n', FRONTMATTER_DELIMITER.length()); if (firstDelimiterEnd == -1) { - throw new IllegalArgumentException("Missing frontmatter: no content after first '---'"); + throw new DomainBadRequestException("error.skill.metadata.frontmatter.missingContent"); } int secondDelimiterStart = trimmedContent.indexOf(FRONTMATTER_DELIMITER, firstDelimiterEnd + 1); if (secondDelimiterStart == -1) { - throw new IllegalArgumentException("Missing frontmatter: no closing '---' found"); + throw new DomainBadRequestException("error.skill.metadata.frontmatter.missingEnd"); } String yamlContent = trimmedContent.substring(firstDelimiterEnd + 1, secondDelimiterStart).trim(); @@ -37,18 +38,20 @@ public class SkillMetadataParser { Yaml yaml = new Yaml(); Object parsed = yaml.load(yamlContent); if (!(parsed instanceof Map)) { - throw new IllegalArgumentException("Invalid YAML: frontmatter must be a map"); + throw new DomainBadRequestException("error.skill.metadata.yaml.notMap"); } @SuppressWarnings("unchecked") Map map = (Map) parsed; frontmatter = map; + } catch (DomainBadRequestException e) { + throw e; } catch (Exception e) { - throw new IllegalArgumentException("Invalid YAML in frontmatter: " + e.getMessage(), e); + throw new DomainBadRequestException("error.skill.metadata.yaml.invalid", e.getMessage()); } String name = extractRequiredField(frontmatter, "name"); String description = extractRequiredField(frontmatter, "description"); - String version = extractRequiredField(frontmatter, "version"); + String version = extractOptionalField(frontmatter, "version"); return new SkillMetadata(name, description, version, body, frontmatter); } @@ -56,8 +59,13 @@ public class SkillMetadataParser { private String extractRequiredField(Map frontmatter, String fieldName) { Object value = frontmatter.get(fieldName); if (value == null) { - throw new IllegalArgumentException("Missing required field: " + fieldName); + throw new DomainBadRequestException("error.skill.metadata.requiredField.missing", fieldName); } return value.toString(); } + + private String extractOptionalField(Map frontmatter, String fieldName) { + Object value = frontmatter.get(fieldName); + return value == null ? null : value.toString(); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index c86c44b1..31ee1a53 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -4,6 +4,8 @@ import com.iflytek.skillhub.domain.event.SkillDownloadedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.storage.ObjectStorageService; import com.iflytek.skillhub.storage.ObjectMetadata; @@ -51,24 +53,24 @@ public class SkillDownloadService { public DownloadResult downloadLatest( String namespaceSlug, String skillSlug, - Long currentUserId, + String currentUserId, Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); // Visibility check if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { - throw new SecurityException("Access denied to skill: " + skillSlug); + throw new DomainForbiddenException("error.skill.access.denied", skillSlug); } if (skill.getLatestVersionId() == null) { - throw new IllegalArgumentException("No published version available for skill: " + skillSlug); + throw new DomainBadRequestException("error.skill.version.latest.unavailable", skillSlug); } SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()) - .orElseThrow(() -> new IllegalArgumentException("Latest version not found")); + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.latest.notFound")); return downloadVersion(skill, version); } @@ -77,20 +79,20 @@ public class SkillDownloadService { String namespaceSlug, String skillSlug, String versionStr, - Long currentUserId, + String currentUserId, Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); // Visibility check if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { - throw new SecurityException("Access denied to skill: " + skillSlug); + throw new DomainForbiddenException("error.skill.access.denied", skillSlug); } SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), versionStr) - .orElseThrow(() -> new IllegalArgumentException("Version not found: " + versionStr)); + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionStr)); return downloadVersion(skill, version); } @@ -99,36 +101,39 @@ public class SkillDownloadService { String namespaceSlug, String skillSlug, String tagName, - Long currentUserId, + String currentUserId, Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); // Visibility check if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { - throw new SecurityException("Access denied to skill: " + skillSlug); + throw new DomainForbiddenException("error.skill.access.denied", skillSlug); } SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName) - .orElseThrow(() -> new IllegalArgumentException("Tag not found: " + tagName)); + .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName)); if (tag.getVersionId() == null) { - throw new IllegalArgumentException("Tag does not point to a version: " + tagName); + throw new DomainBadRequestException("error.skill.tag.version.missing", tagName); } SkillVersion version = skillVersionRepository.findById(tag.getVersionId()) - .orElseThrow(() -> new IllegalArgumentException("Version not found for tag: " + tagName)); + .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.version.notFound", tagName)); return downloadVersion(skill, version); } private DownloadResult downloadVersion(Skill skill, SkillVersion version) { + assertPublishedAccessible(skill); + assertPublishedVersion(version); + String storageKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); if (!objectStorageService.exists(storageKey)) { - throw new IllegalArgumentException("Bundle not found in storage"); + throw new DomainBadRequestException("error.skill.bundle.notFound"); } ObjectMetadata metadata = objectStorageService.getMetadata(storageKey); @@ -144,6 +149,18 @@ public class SkillDownloadService { private Namespace findNamespace(String slug) { return namespaceRepository.findBySlug(slug) - .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug)); + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + } + + private void assertPublishedAccessible(Skill skill) { + if (skill.getStatus() != SkillStatus.ACTIVE) { + throw new DomainBadRequestException("error.skill.status.notActive"); + } + } + + private void assertPublishedVersion(SkillVersion version) { + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + throw new DomainBadRequestException("error.skill.version.notPublished", version.getVersion()); + } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index a9b15d71..2d6fa0bb 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -5,6 +5,10 @@ import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; @@ -18,15 +22,25 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.security.MessageDigest; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HexFormat; import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; @Service public class SkillPublishService { + public record PublishResult( + Long skillId, + String slug, + SkillVersion version + ) {} + private final NamespaceRepository namespaceRepository; private final NamespaceMemberRepository namespaceMemberRepository; private final SkillRepository skillRepository; @@ -38,6 +52,7 @@ public class SkillPublishService { private final PrePublishValidator prePublishValidator; private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; + private final ReviewTaskRepository reviewTaskRepository; public SkillPublishService( NamespaceRepository namespaceRepository, @@ -50,7 +65,8 @@ public class SkillPublishService { SkillMetadataParser skillMetadataParser, PrePublishValidator prePublishValidator, ApplicationEventPublisher eventPublisher, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + ReviewTaskRepository reviewTaskRepository) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.skillRepository = skillRepository; @@ -62,70 +78,79 @@ public class SkillPublishService { this.prePublishValidator = prePublishValidator; this.eventPublisher = eventPublisher; this.objectMapper = objectMapper; + this.reviewTaskRepository = reviewTaskRepository; } @Transactional - public SkillVersion publishFromEntries( + public PublishResult publishFromEntries( String namespaceSlug, List entries, - Long publisherId, + String publisherId, SkillVisibility visibility) { // 1. Find namespace by slug Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) - .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + namespaceSlug)); + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", namespaceSlug)); // 2. Check publisher is member namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId) - .orElseThrow(() -> new IllegalArgumentException("Publisher is not a member of namespace: " + namespaceSlug)); + .orElseThrow(() -> new DomainBadRequestException("error.skill.publish.publisher.notMember", namespaceSlug)); // 3. Validate package ValidationResult packageValidation = skillPackageValidator.validate(entries); if (!packageValidation.passed()) { - throw new IllegalArgumentException("Package validation failed: " + String.join(", ", packageValidation.errors())); + throw new DomainBadRequestException( + "error.skill.publish.package.invalid", + String.join(", ", packageValidation.errors())); } // 4. Parse SKILL.md PackageEntry skillMd = entries.stream() .filter(e -> e.path().equals("SKILL.md")) .findFirst() - .orElseThrow(() -> new IllegalArgumentException("SKILL.md not found")); + .orElseThrow(() -> new DomainBadRequestException("error.skill.publish.skillMd.notFound")); String skillMdContent = new String(skillMd.content()); SkillMetadata metadata = skillMetadataParser.parse(skillMdContent); + if (metadata.version() == null || metadata.version().isBlank()) { + throw new DomainBadRequestException("error.skill.metadata.requiredField.missing", "version"); + } + String skillSlug = SlugValidator.slugify(metadata.name()); // 5. Run PrePublishValidator PrePublishValidator.SkillPackageContext context = new PrePublishValidator.SkillPackageContext( entries, metadata, publisherId, namespace.getId()); ValidationResult prePublishValidation = prePublishValidator.validate(context); if (!prePublishValidation.passed()) { - throw new IllegalArgumentException("Pre-publish validation failed: " + String.join(", ", prePublishValidation.errors())); + throw new DomainBadRequestException( + "error.skill.publish.precheck.failed", + String.join(", ", prePublishValidation.errors())); } // 6. Find or create Skill record - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), metadata.name()) + Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) .orElseGet(() -> { - Skill newSkill = new Skill(namespace.getId(), metadata.name(), publisherId, visibility); + Skill newSkill = new Skill(namespace.getId(), skillSlug, publisherId, visibility); newSkill.setCreatedBy(publisherId); return skillRepository.save(newSkill); }); // 7. Check version doesn't already exist if (skillVersionRepository.findBySkillIdAndVersion(skill.getId(), metadata.version()).isPresent()) { - throw new IllegalArgumentException("Version already exists: " + metadata.version()); + throw new DomainBadRequestException("error.skill.version.exists", metadata.version()); } // 8. Create SkillVersion SkillVersion version = new SkillVersion(skill.getId(), metadata.version(), publisherId); - version.setStatus(SkillVersionStatus.PUBLISHED); - version.setPublishedAt(LocalDateTime.now()); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); // Store metadata as JSON try { String metadataJson = objectMapper.writeValueAsString(metadata); version.setParsedMetadataJson(metadataJson); + version.setManifestJson(objectMapper.writeValueAsString(buildManifest(entries))); } catch (Exception e) { - throw new RuntimeException("Failed to serialize metadata", e); + throw new IllegalStateException("Failed to serialize metadata", e); } version = skillVersionRepository.save(version); @@ -168,12 +193,22 @@ public class SkillPublishService { digest.reset(); } } catch (Exception e) { - throw new RuntimeException("Failed to process files", e); + throw new IllegalStateException("Failed to process files", e); } // 10. Save SkillFile records skillFileRepository.saveAll(skillFiles); + // 10.5 Build and upload bundle zip for download endpoints + byte[] bundleZip = buildBundle(entries); + String bundleKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); + objectStorageService.putObject( + bundleKey, + new ByteArrayInputStream(bundleZip), + bundleZip.length, + "application/zip" + ); + // 11. Update version stats version.setFileCount(skillFiles.size()); version.setTotalSize(totalSize); @@ -189,7 +224,32 @@ public class SkillPublishService { // 13. Publish SkillPublishedEvent eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId)); - // 14. Return version - return version; + // 14. Return published identifiers + return new PublishResult(skill.getId(), skill.getSlug(), version); + } + + private List> buildManifest(List entries) { + return entries.stream() + .map(entry -> Map.of( + "path", entry.path(), + "size", entry.size(), + "contentType", entry.contentType())) + .toList(); + } + + private byte[] buildBundle(List entries) { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { + for (PackageEntry entry : entries) { + ZipEntry zipEntry = new ZipEntry(entry.path()); + zipOutputStream.putNextEntry(zipEntry); + zipOutputStream.write(entry.content()); + zipOutputStream.closeEntry(); + } + zipOutputStream.finish(); + return outputStream.toByteArray(); + } catch (Exception e) { + throw new IllegalStateException("Failed to build bundle zip", e); + } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 79cc70d8..997b2982 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -3,6 +3,8 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.storage.ObjectStorageService; import org.springframework.data.domain.Page; @@ -11,8 +13,15 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Comparator; +import java.util.HexFormat; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; @Service @@ -22,6 +31,7 @@ public class SkillQueryService { private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final SkillFileRepository skillFileRepository; + private final SkillTagRepository skillTagRepository; private final ObjectStorageService objectStorageService; private final VisibilityChecker visibilityChecker; @@ -30,12 +40,14 @@ public class SkillQueryService { SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, SkillFileRepository skillFileRepository, + SkillTagRepository skillTagRepository, ObjectStorageService objectStorageService, VisibilityChecker visibilityChecker) { this.namespaceRepository = namespaceRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillFileRepository = skillFileRepository; + this.skillTagRepository = skillTagRepository; this.objectStorageService = objectStorageService; this.visibilityChecker = visibilityChecker; } @@ -53,19 +65,42 @@ public class SkillQueryService { Long namespaceId ) {} + public record SkillVersionDetailDTO( + Long id, + String version, + String status, + String changelog, + Integer fileCount, + Long totalSize, + java.time.LocalDateTime publishedAt, + String parsedMetadataJson, + String manifestJson + ) {} + + public record ResolvedVersionDTO( + Long skillId, + String namespace, + String slug, + String version, + Long versionId, + String fingerprint, + Boolean matched, + String downloadUrl + ) {} + public SkillDetailDTO getSkillDetail( String namespaceSlug, String skillSlug, - Long currentUserId, + String currentUserId, Map userNsRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); // Visibility check if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { - throw new SecurityException("Access denied to skill: " + skillSlug); + throw new DomainForbiddenException("error.skill.access.denied", skillSlug); } String latestVersion = null; @@ -92,7 +127,7 @@ public class SkillQueryService { public Page listSkillsByNamespace( String namespaceSlug, - Long currentUserId, + String currentUserId, Map userNsRoles, Pageable pageable) { @@ -112,38 +147,91 @@ public class SkillQueryService { return new PageImpl<>(pageContent, pageable, accessibleSkills.size()); } - public List listFiles(String namespaceSlug, String skillSlug, String version) { - Namespace namespace = findNamespace(namespaceSlug); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + public SkillVersionDetailDTO getVersionDetail( + String namespaceSlug, + String skillSlug, + String version, + String currentUserId, + Map userNsRoles) { + Skill skill = findSkill(namespaceSlug, skillSlug); + assertPublishedAccessible(skill, currentUserId, userNsRoles); + SkillVersion skillVersion = findVersion(skill, version); + assertPublishedVersion(skillVersion, version); - SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) - .orElseThrow(() -> new IllegalArgumentException("Version not found: " + version)); + return new SkillVersionDetailDTO( + skillVersion.getId(), + skillVersion.getVersion(), + skillVersion.getStatus().name(), + skillVersion.getChangelog(), + skillVersion.getFileCount(), + skillVersion.getTotalSize(), + skillVersion.getPublishedAt(), + skillVersion.getParsedMetadataJson(), + skillVersion.getManifestJson() + ); + } + + public List listFiles( + String namespaceSlug, + String skillSlug, + String version, + String currentUserId, + Map userNsRoles) { + Skill skill = findSkill(namespaceSlug, skillSlug); + assertPublishedAccessible(skill, currentUserId, userNsRoles); + + SkillVersion skillVersion = findVersion(skill, version); + assertPublishedVersion(skillVersion, version); return skillFileRepository.findByVersionId(skillVersion.getId()); } - public InputStream getFileContent(String namespaceSlug, String skillSlug, String version, String filePath) { - Namespace namespace = findNamespace(namespaceSlug); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + public List listFilesByTag( + String namespaceSlug, + String skillSlug, + String tagName, + String currentUserId, + Map userNsRoles) { + Skill skill = findSkill(namespaceSlug, skillSlug); + assertPublishedAccessible(skill, currentUserId, userNsRoles); + SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); + return skillFileRepository.findByVersionId(skillVersion.getId()); + } - SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) - .orElseThrow(() -> new IllegalArgumentException("Version not found: " + version)); + public InputStream getFileContent( + String namespaceSlug, + String skillSlug, + String version, + String filePath, + String currentUserId, + Map userNsRoles) { + Skill skill = findSkill(namespaceSlug, skillSlug); + assertPublishedAccessible(skill, currentUserId, userNsRoles); - List files = skillFileRepository.findByVersionId(skillVersion.getId()); - SkillFile file = files.stream() - .filter(f -> f.getFilePath().equals(filePath)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("File not found: " + filePath)); + SkillVersion skillVersion = findVersion(skill, version); + assertPublishedVersion(skillVersion, version); + + SkillFile file = findFile(skillVersion, filePath); return objectStorageService.getObject(file.getStorageKey()); } + public InputStream getFileContentByTag( + String namespaceSlug, + String skillSlug, + String tagName, + String filePath, + String currentUserId, + Map userNsRoles) { + Skill skill = findSkill(namespaceSlug, skillSlug); + assertPublishedAccessible(skill, currentUserId, userNsRoles); + SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); + SkillFile file = findFile(skillVersion, filePath); + return objectStorageService.getObject(file.getStorageKey()); + } + public Page listVersions(String namespaceSlug, String skillSlug, Pageable pageable) { - Namespace namespace = findNamespace(namespaceSlug); - Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + Skill skill = findSkill(namespaceSlug, skillSlug); List publishedVersions = skillVersionRepository.findBySkillIdAndStatus( skill.getId(), SkillVersionStatus.PUBLISHED); @@ -156,8 +244,145 @@ public class SkillQueryService { return new PageImpl<>(pageContent, pageable, publishedVersions.size()); } + public ResolvedVersionDTO resolveVersion( + String namespaceSlug, + String skillSlug, + String version, + String tag, + String hash, + String currentUserId, + Map userNsRoles) { + if (version != null && !version.isBlank() && tag != null && !tag.isBlank()) { + throw new DomainBadRequestException("error.skill.resolve.versionTag.conflict"); + } + + Skill skill = findSkill(namespaceSlug, skillSlug); + assertPublishedAccessible(skill, currentUserId, userNsRoles); + SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash); + String fingerprint = computeFingerprint(resolved); + Boolean matched = hash == null || hash.isBlank() ? null : Objects.equals(hash, fingerprint); + + return new ResolvedVersionDTO( + skill.getId(), + namespaceSlug, + skill.getSlug(), + resolved.getVersion(), + resolved.getId(), + fingerprint, + matched, + String.format( + "/api/v1/skills/%s/%s/versions/%s/download", + encodePathSegment(namespaceSlug), + encodePathSegment(skill.getSlug()), + encodePathSegment(resolved.getVersion())) + ); + } + private Namespace findNamespace(String slug) { return namespaceRepository.findBySlug(slug) - .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug)); + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + } + + private Skill findSkill(String namespaceSlug, String skillSlug) { + Namespace namespace = findNamespace(namespaceSlug); + return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + } + + private SkillVersion findVersion(Skill skill, String version) { + return skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); + } + + private SkillFile findFile(SkillVersion skillVersion, String filePath) { + return skillFileRepository.findByVersionId(skillVersion.getId()).stream() + .filter(f -> f.getFilePath().equals(filePath)) + .findFirst() + .orElseThrow(() -> new DomainBadRequestException("error.skill.file.notFound", filePath)); + } + + private SkillVersion resolveVersionEntity(Skill skill, String version, String tag, String hash) { + if (version != null && !version.isBlank()) { + SkillVersion exactVersion = findVersion(skill, version); + assertPublishedVersion(exactVersion, version); + return exactVersion; + } + + if (tag != null && !tag.isBlank()) { + if ("latest".equalsIgnoreCase(tag)) { + return resolveLatestVersion(skill); + } + SkillTag skillTag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tag) + .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tag)); + if (skillTag.getVersionId() == null) { + throw new DomainBadRequestException("error.skill.tag.version.missing", tag); + } + SkillVersion taggedVersion = skillVersionRepository.findById(skillTag.getVersionId()) + .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.version.notFound", tag)); + assertPublishedVersion(taggedVersion, taggedVersion.getVersion()); + return taggedVersion; + } + + List publishedVersions = skillVersionRepository.findBySkillIdAndStatus( + skill.getId(), SkillVersionStatus.PUBLISHED); + if (publishedVersions.isEmpty()) { + throw new DomainBadRequestException("error.skill.version.latest.unavailable", skill.getSlug()); + } + + if (hash != null && !hash.isBlank()) { + Optional matchedVersion = publishedVersions.stream() + .filter(candidate -> Objects.equals(hash, computeFingerprint(candidate))) + .findFirst(); + if (matchedVersion.isPresent()) { + return matchedVersion.get(); + } + } + + return resolveLatestVersion(skill); + } + + private SkillVersion resolveLatestVersion(Skill skill) { + if (skill.getLatestVersionId() == null) { + throw new DomainBadRequestException("error.skill.version.latest.unavailable", skill.getSlug()); + } + SkillVersion latestVersion = skillVersionRepository.findById(skill.getLatestVersionId()) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.latest.notFound")); + assertPublishedVersion(latestVersion, latestVersion.getVersion()); + return latestVersion; + } + + private String computeFingerprint(SkillVersion version) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + List files = skillFileRepository.findByVersionId(version.getId()).stream() + .sorted(Comparator.comparing(SkillFile::getFilePath)) + .toList(); + for (SkillFile file : files) { + String line = file.getFilePath() + ":" + file.getSha256() + "\n"; + digest.update(line.getBytes(StandardCharsets.UTF_8)); + } + return "sha256:" + HexFormat.of().formatHex(digest.digest()); + } catch (Exception e) { + throw new IllegalStateException("Failed to compute version fingerprint", e); + } + } + + private String encodePathSegment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private void assertPublishedAccessible(Skill skill, String currentUserId, Map userNsRoles) { + if (skill.getStatus() != SkillStatus.ACTIVE) { + throw new DomainBadRequestException("error.skill.status.notActive"); + } + if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); + } + } + + private void assertPublishedVersion(SkillVersion version, String versionStr) { + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + throw new DomainBadRequestException("error.skill.version.notPublished", versionStr); + } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java index 44ed334b..39ca41f9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java @@ -1,7 +1,11 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,16 +18,19 @@ public class SkillTagService { private static final String RESERVED_TAG_LATEST = "latest"; private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final SkillTagRepository skillTagRepository; public SkillTagService( NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, SkillTagRepository skillTagRepository) { this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillTagRepository = skillTagRepository; @@ -32,9 +39,13 @@ public class SkillTagService { public List listTags(String namespaceSlug, String skillSlug) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); - return skillTagRepository.findBySkillId(skill.getId()); + List tags = new java.util.ArrayList<>(skillTagRepository.findBySkillId(skill.getId())); + if (skill.getLatestVersionId() != null) { + tags.add(new SkillTag(skill.getId(), RESERVED_TAG_LATEST, skill.getLatestVersionId(), skill.getOwnerId())); + } + return tags; } @Transactional @@ -43,24 +54,25 @@ public class SkillTagService { String skillSlug, String tagName, String targetVersion, - Long operatorId) { + String operatorId) { // Reject "latest" tag if (RESERVED_TAG_LATEST.equalsIgnoreCase(tagName)) { - throw new IllegalArgumentException("Tag name 'latest' is reserved"); + throw new DomainBadRequestException("error.skill.tag.latest.reserved"); } Namespace namespace = findNamespace(namespaceSlug); + assertAdminOrOwner(namespace.getId(), operatorId); Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); // Find target version SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), targetVersion) - .orElseThrow(() -> new IllegalArgumentException("Version not found: " + targetVersion)); + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", targetVersion)); // Target must be PUBLISHED if (version.getStatus() != SkillVersionStatus.PUBLISHED) { - throw new IllegalArgumentException("Target version must be PUBLISHED"); + throw new DomainBadRequestException("error.skill.tag.targetVersion.notPublished"); } // Check if tag exists @@ -78,24 +90,34 @@ public class SkillTagService { } @Transactional - public void deleteTag(String namespaceSlug, String skillSlug, String tagName, Long operatorId) { + public void deleteTag(String namespaceSlug, String skillSlug, String tagName, String operatorId) { // Reject "latest" tag if (RESERVED_TAG_LATEST.equalsIgnoreCase(tagName)) { - throw new IllegalArgumentException("Tag name 'latest' is reserved and cannot be deleted"); + throw new DomainBadRequestException("error.skill.tag.latest.delete"); } Namespace namespace = findNamespace(namespaceSlug); + assertAdminOrOwner(namespace.getId(), operatorId); Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) - .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName) - .orElseThrow(() -> new IllegalArgumentException("Tag not found: " + tagName)); + .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName)); skillTagRepository.delete(tag); } private Namespace findNamespace(String slug) { return namespaceRepository.findBySlug(slug) - .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug)); + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + } + + private void assertAdminOrOwner(Long namespaceId, String operatorId) { + NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorId) + .map(member -> member.getRole()) + .orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required")); + if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) { + throw new DomainForbiddenException("error.namespace.admin.required"); + } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java index cd8e1ef8..73f821d4 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.domain.skill.validation; +import org.springframework.stereotype.Component; + +@Component public class NoOpPrePublishValidator implements PrePublishValidator { @Override diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PrePublishValidator.java index e3322c64..5f64f151 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PrePublishValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PrePublishValidator.java @@ -10,7 +10,7 @@ public interface PrePublishValidator { record SkillPackageContext( List entries, SkillMetadata metadata, - Long publisherId, + String publisherId, Long namespaceId ) {} } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java index 161a8097..57977dc6 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.skill.validation; +import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; import java.util.ArrayList; @@ -42,8 +43,11 @@ public class SkillPackageValidator { try { String content = new String(skillMd.content()); metadataParser.parse(content); - } catch (IllegalArgumentException e) { - errors.add("Invalid SKILL.md frontmatter: " + e.getMessage()); + } catch (LocalizedDomainException e) { + String detail = e.messageArgs().length == 0 + ? e.messageCode() + : e.messageCode() + " " + java.util.Arrays.toString(e.messageArgs()); + errors.add("Invalid SKILL.md frontmatter: " + detail); } // 3. Check file count diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java index 7956f141..2c505a05 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java @@ -7,8 +7,8 @@ import java.time.LocalDateTime; @Table(name = "user_account") public class UserAccount { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Column(length = 128) + private String id; @Column(name = "display_name", nullable = false, length = 128) private String displayName; @@ -24,7 +24,7 @@ public class UserAccount { private UserStatus status = UserStatus.ACTIVE; @Column(name = "merged_to_user_id") - private Long mergedToUserId; + private String mergedToUserId; @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -34,7 +34,8 @@ public class UserAccount { protected UserAccount() {} - public UserAccount(String displayName, String email, String avatarUrl) { + public UserAccount(String id, String displayName, String email, String avatarUrl) { + this.id = id; this.displayName = displayName; this.email = email; this.avatarUrl = avatarUrl; @@ -52,7 +53,7 @@ public class UserAccount { this.updatedAt = LocalDateTime.now(); } - public Long getId() { return id; } + public String getId() { return id; } public String getDisplayName() { return displayName; } public void setDisplayName(String displayName) { this.displayName = displayName; } public String getEmail() { return email; } @@ -61,8 +62,8 @@ public class UserAccount { public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; } public UserStatus getStatus() { return status; } public void setStatus(UserStatus status) { this.status = status; } - public Long getMergedToUserId() { return mergedToUserId; } - public void setMergedToUserId(Long mergedToUserId) { this.mergedToUserId = mergedToUserId; } + public String getMergedToUserId() { return mergedToUserId; } + public void setMergedToUserId(String mergedToUserId) { this.mergedToUserId = mergedToUserId; } public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } public boolean isActive() { return this.status == UserStatus.ACTIVE; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java index 003b536a..d9448334 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java @@ -3,6 +3,6 @@ package com.iflytek.skillhub.domain.user; import java.util.Optional; public interface UserAccountRepository { - Optional findById(Long id); + Optional findById(String id); UserAccount save(UserAccount user); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java index 97def31d..380c221e 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java @@ -1,16 +1,12 @@ package com.iflytek.skillhub.domain.namespace; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; - -import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -22,6 +18,8 @@ class NamespaceMemberServiceTest { @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private NamespaceService namespaceService; @InjectMocks private NamespaceMemberService namespaceMemberService; @@ -29,7 +27,7 @@ class NamespaceMemberServiceTest { @Test void addMember_shouldAddMemberSuccessfully() { Long namespaceId = 1L; - Long userId = 2L; + String userId = "user-2"; NamespaceRole role = NamespaceRole.MEMBER; when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) @@ -37,7 +35,7 @@ class NamespaceMemberServiceTest { when(namespaceMemberRepository.save(any(NamespaceMember.class))) .thenReturn(new NamespaceMember(namespaceId, userId, role)); - NamespaceMember result = namespaceMemberService.addMember(namespaceId, userId, role); + NamespaceMember result = namespaceMemberService.addMember(namespaceId, userId, role, "user-99"); assertNotNull(result); verify(namespaceMemberRepository).save(any(NamespaceMember.class)); @@ -45,52 +43,60 @@ class NamespaceMemberServiceTest { @Test void addMember_shouldThrowExceptionForOwnerRole() { - assertThrows(IllegalArgumentException.class, () -> - namespaceMemberService.addMember(1L, 2L, NamespaceRole.OWNER)); + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.addMember(1L, "user-2", NamespaceRole.OWNER, "user-99")); + } + + @Test + void addMember_shouldRequireAdminOrOwner() { + doThrow(new DomainForbiddenException("error.namespace.admin.required")).when(namespaceService).assertAdminOrOwner(1L, "user-99"); + + assertThrows(DomainForbiddenException.class, () -> + namespaceMemberService.addMember(1L, "user-2", NamespaceRole.MEMBER, "user-99")); } @Test void addMember_shouldThrowExceptionWhenMemberExists() { Long namespaceId = 1L; - Long userId = 2L; + String userId = "user-2"; when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.of(new NamespaceMember())); - assertThrows(IllegalArgumentException.class, () -> - namespaceMemberService.addMember(namespaceId, userId, NamespaceRole.MEMBER)); + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.addMember(namespaceId, userId, NamespaceRole.MEMBER, "user-99")); } @Test void removeMember_shouldThrowExceptionForOwner() { Long namespaceId = 1L; - Long userId = 2L; + String userId = "user-2"; NamespaceMember ownerMember = new NamespaceMember(namespaceId, userId, NamespaceRole.OWNER); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.of(ownerMember)); - assertThrows(IllegalArgumentException.class, () -> - namespaceMemberService.removeMember(namespaceId, userId)); + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.removeMember(namespaceId, userId, "user-99")); } @Test void removeMember_shouldThrowExceptionWhenMemberNotFound() { - when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, 2L)) + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")) .thenReturn(Optional.empty()); - assertThrows(IllegalArgumentException.class, () -> - namespaceMemberService.removeMember(1L, 2L)); + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.removeMember(1L, "user-2", "user-99")); } @Test void updateMemberRole_shouldUpdateRoleSuccessfully() { Long namespaceId = 1L; - Long userId = 2L; + String userId = "user-2"; NamespaceMember member = new NamespaceMember(namespaceId, userId, NamespaceRole.MEMBER); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.of(member)); when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenReturn(member); - NamespaceMember result = namespaceMemberService.updateMemberRole(namespaceId, userId, NamespaceRole.ADMIN); + NamespaceMember result = namespaceMemberService.updateMemberRole(namespaceId, userId, NamespaceRole.ADMIN, "user-99"); assertNotNull(result); verify(namespaceMemberRepository).save(member); @@ -99,26 +105,26 @@ class NamespaceMemberServiceTest { @Test void updateMemberRole_shouldThrowExceptionForOwnerRole() { Long namespaceId = 1L; - Long userId = 2L; + String userId = "user-2"; - assertThrows(IllegalArgumentException.class, () -> - namespaceMemberService.updateMemberRole(namespaceId, userId, NamespaceRole.OWNER)); + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.updateMemberRole(namespaceId, userId, NamespaceRole.OWNER, "user-99")); } @Test void updateMemberRole_shouldThrowExceptionWhenMemberNotFound() { - when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, 2L)) + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")) .thenReturn(Optional.empty()); - assertThrows(IllegalArgumentException.class, () -> - namespaceMemberService.updateMemberRole(1L, 2L, NamespaceRole.ADMIN)); + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.ADMIN, "user-99")); } @Test void transferOwnership_shouldTransferOwnershipSuccessfully() { Long namespaceId = 1L; - Long currentOwnerId = 2L; - Long newOwnerId = 3L; + String currentOwnerId = "user-2"; + String newOwnerId = "user-3"; NamespaceMember currentOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.OWNER); NamespaceMember newOwner = new NamespaceMember(namespaceId, newOwnerId, NamespaceRole.ADMIN); @@ -137,30 +143,30 @@ class NamespaceMemberServiceTest { @Test void transferOwnership_shouldThrowExceptionWhenCurrentOwnerNotFound() { - when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, 2L)) + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")) .thenReturn(Optional.empty()); - assertThrows(IllegalArgumentException.class, () -> - namespaceMemberService.transferOwnership(1L, 2L, 3L)); + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.transferOwnership(1L, "user-2", "user-3")); } @Test void transferOwnership_shouldThrowExceptionWhenCurrentUserIsNotOwner() { Long namespaceId = 1L; - Long currentOwnerId = 2L; + String currentOwnerId = "user-2"; NamespaceMember notOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.ADMIN); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId)) .thenReturn(Optional.of(notOwner)); - assertThrows(IllegalArgumentException.class, () -> - namespaceMemberService.transferOwnership(namespaceId, currentOwnerId, 3L)); + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.transferOwnership(namespaceId, currentOwnerId, "user-3")); } @Test void transferOwnership_shouldThrowExceptionWhenNewOwnerNotFound() { Long namespaceId = 1L; - Long currentOwnerId = 2L; - Long newOwnerId = 3L; + String currentOwnerId = "user-2"; + String newOwnerId = "user-3"; NamespaceMember currentOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.OWNER); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId)) @@ -168,14 +174,14 @@ class NamespaceMemberServiceTest { when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, newOwnerId)) .thenReturn(Optional.empty()); - assertThrows(IllegalArgumentException.class, () -> + assertThrows(DomainBadRequestException.class, () -> namespaceMemberService.transferOwnership(namespaceId, currentOwnerId, newOwnerId)); } @Test void getMemberRole_shouldReturnRole() { Long namespaceId = 1L; - Long userId = 2L; + String userId = "user-2"; NamespaceMember member = new NamespaceMember(namespaceId, userId, NamespaceRole.ADMIN); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.of(member)); @@ -188,10 +194,10 @@ class NamespaceMemberServiceTest { @Test void getMemberRole_shouldReturnEmptyWhenMemberNotFound() { - when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, 2L)) + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")) .thenReturn(Optional.empty()); - Optional result = namespaceMemberService.getMemberRole(1L, 2L); + Optional result = namespaceMemberService.getMemberRole(1L, "user-2"); assertFalse(result.isPresent()); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java index 33dde52c..ecb8d18b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.domain.namespace; -import org.junit.jupiter.api.BeforeEach; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -30,7 +31,7 @@ class NamespaceServiceTest { String slug = "test-namespace"; String displayName = "Test Namespace"; String description = "Test description"; - Long creatorUserId = 1L; + String creatorUserId = "user-1"; Namespace savedNamespace = new Namespace(slug, displayName, creatorUserId); when(namespaceRepository.findBySlug(slug)).thenReturn(Optional.empty()); @@ -51,24 +52,33 @@ class NamespaceServiceTest { String slug = "existing-slug"; when(namespaceRepository.findBySlug(slug)).thenReturn(Optional.of(new Namespace())); - assertThrows(IllegalArgumentException.class, () -> - namespaceService.createNamespace(slug, "Name", "Desc", 1L)); + assertThrows(DomainBadRequestException.class, () -> + namespaceService.createNamespace(slug, "Name", "Desc", "user-1")); } @Test void createNamespace_shouldThrowExceptionForInvalidSlug() { - assertThrows(IllegalArgumentException.class, () -> - namespaceService.createNamespace("INVALID", "Name", "Desc", 1L)); + assertThrows(DomainBadRequestException.class, () -> + namespaceService.createNamespace("INVALID", "Name", "Desc", "user-1")); } @Test void updateNamespace_shouldUpdateFields() { Long namespaceId = 1L; - Namespace namespace = new Namespace("slug", "Old Name", 1L); + String operatorUserId = "user-1"; + Namespace namespace = new Namespace("slug", "Old Name", "user-1"); when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) + .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER))); when(namespaceRepository.save(any(Namespace.class))).thenReturn(namespace); - Namespace result = namespaceService.updateNamespace(namespaceId, "New Name", "New Desc", "http://avatar.url"); + Namespace result = namespaceService.updateNamespace( + namespaceId, + "New Name", + "New Desc", + "http://avatar.url", + operatorUserId + ); assertNotNull(result); verify(namespaceRepository).save(namespace); @@ -78,14 +88,27 @@ class NamespaceServiceTest { void updateNamespace_shouldThrowExceptionWhenNotFound() { when(namespaceRepository.findById(1L)).thenReturn(Optional.empty()); - assertThrows(IllegalArgumentException.class, () -> - namespaceService.updateNamespace(1L, "Name", "Desc", null)); + assertThrows(DomainBadRequestException.class, () -> + namespaceService.updateNamespace(1L, "Name", "Desc", null, "user-1")); + } + + @Test + void updateNamespace_shouldThrowExceptionWhenOperatorLacksPrivilege() { + Long namespaceId = 1L; + String operatorUserId = "user-2"; + Namespace namespace = new Namespace("slug", "Old Name", "user-1"); + when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) + .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.MEMBER))); + + assertThrows(DomainForbiddenException.class, () -> + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); } @Test void getNamespaceBySlug_shouldReturnNamespace() { String slug = "test-slug"; - Namespace namespace = new Namespace(slug, "Name", 1L); + Namespace namespace = new Namespace(slug, "Name", "user-1"); when(namespaceRepository.findBySlug(slug)).thenReturn(Optional.of(namespace)); Namespace result = namespaceService.getNamespaceBySlug(slug); @@ -98,7 +121,7 @@ class NamespaceServiceTest { void getNamespaceBySlug_shouldThrowExceptionWhenNotFound() { when(namespaceRepository.findBySlug("nonexistent")).thenReturn(Optional.empty()); - assertThrows(IllegalArgumentException.class, () -> + assertThrows(DomainBadRequestException.class, () -> namespaceService.getNamespaceBySlug("nonexistent")); } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/SlugValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/SlugValidatorTest.java index 1caf9d39..5da0885c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/SlugValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/SlugValidatorTest.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.namespace; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; @@ -13,45 +14,45 @@ class SlugValidatorTest { } @Test void shouldRejectTooShort() { - Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("a")); - assertTrue(ex.getMessage().contains("length")); + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("a")); + assertEquals("error.slug.length", ex.messageCode()); } @Test void shouldRejectTooLong() { String longSlug = "a".repeat(65); - Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate(longSlug)); - assertTrue(ex.getMessage().contains("length")); + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate(longSlug)); + assertEquals("error.slug.length", ex.messageCode()); } @Test void shouldRejectUpperCase() { - Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("MyNamespace")); - assertTrue(ex.getMessage().contains("lowercase")); + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("MyNamespace")); + assertEquals("error.slug.pattern", ex.messageCode()); } @Test void shouldRejectStartingWithHyphen() { - Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("-namespace")); - assertTrue(ex.getMessage().contains("alphanumeric")); + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("-namespace")); + assertEquals("error.slug.pattern", ex.messageCode()); } @Test void shouldRejectEndingWithHyphen() { - Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("namespace-")); - assertTrue(ex.getMessage().contains("alphanumeric")); + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("namespace-")); + assertEquals("error.slug.pattern", ex.messageCode()); } @Test void shouldRejectDoubleHyphen() { - Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("my--namespace")); - assertTrue(ex.getMessage().contains("consecutive")); + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("my--namespace")); + assertEquals("error.slug.doubleHyphen", ex.messageCode()); } @Test void shouldRejectReservedWords() { - assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("admin")); - assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("api")); - assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("global")); - assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("system")); + assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("admin")); + assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("api")); + assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("global")); + assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("system")); } @Test void shouldRejectSpecialCharacters() { - Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("my_namespace")); - assertTrue(ex.getMessage().contains("lowercase") || ex.getMessage().contains("alphanumeric")); + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("my_namespace")); + assertEquals("error.slug.pattern", ex.messageCode()); } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index f188d9b4..7670a76d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -40,8 +40,8 @@ class PromotionServiceTest { private static final Long SOURCE_SKILL_ID = 10L; private static final Long SOURCE_VERSION_ID = 20L; private static final Long TARGET_NAMESPACE_ID = 30L; - private static final Long USER_ID = 100L; - private static final Long REVIEWER_ID = 200L; + private static final String USER_ID = "user-100"; + private static final String REVIEWER_ID = "user-200"; private static final Long PROMOTION_ID = 1L; private static final Long NEW_SKILL_ID = 50L; private static final Long NEW_VERSION_ID = 60L; @@ -84,14 +84,14 @@ class PromotionServiceTest { } private Namespace createGlobalNamespace() { - Namespace ns = new Namespace("global", "Global", 1L); + Namespace ns = new Namespace("global", "Global", "user-1"); setField(ns, "id", TARGET_NAMESPACE_ID); ns.setType(NamespaceType.GLOBAL); return ns; } private Namespace createTeamNamespace() { - Namespace ns = new Namespace("team-a", "Team A", 1L); + Namespace ns = new Namespace("team-a", "Team A", "user-1"); setField(ns, "id", TARGET_NAMESPACE_ID); // default type is TEAM return ns; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index 6ca7ec06..f3d43cd9 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -17,7 +17,7 @@ class ReviewPermissionCheckerTest { @Test void cannotReviewOwnSubmission() { - Long userId = 1L; + String userId = "user-1"; ReviewTask task = new ReviewTask(1L, 10L, userId); assertFalse(checker.canReview(task, userId, NamespaceType.TEAM, Map.of(), Set.of())); @@ -25,56 +25,56 @@ class ReviewPermissionCheckerTest { @Test void teamAdminCanReviewTeamSkill() { - ReviewTask task = new ReviewTask(1L, 10L, 2L); - assertTrue(checker.canReview(task, 1L, + ReviewTask task = new ReviewTask(1L, 10L, "user-2"); + assertTrue(checker.canReview(task, "user-1", NamespaceType.TEAM, Map.of(10L, NamespaceRole.ADMIN), Set.of())); } @Test void teamOwnerCanReviewTeamSkill() { - ReviewTask task = new ReviewTask(1L, 10L, 2L); - assertTrue(checker.canReview(task, 1L, + ReviewTask task = new ReviewTask(1L, 10L, "user-2"); + assertTrue(checker.canReview(task, "user-1", NamespaceType.TEAM, Map.of(10L, NamespaceRole.OWNER), Set.of())); } @Test void teamMemberCannotReviewTeamSkill() { - ReviewTask task = new ReviewTask(1L, 10L, 2L); - assertFalse(checker.canReview(task, 1L, + ReviewTask task = new ReviewTask(1L, 10L, "user-2"); + assertFalse(checker.canReview(task, "user-1", NamespaceType.TEAM, Map.of(10L, NamespaceRole.MEMBER), Set.of())); } @Test void skillAdminCanReviewGlobalSkill() { - ReviewTask task = new ReviewTask(1L, 1L, 2L); - assertTrue(checker.canReview(task, 1L, + ReviewTask task = new ReviewTask(1L, 1L, "user-2"); + assertTrue(checker.canReview(task, "user-1", NamespaceType.GLOBAL, Map.of(), Set.of("SKILL_ADMIN"))); } @Test void superAdminCanReviewGlobalSkill() { - ReviewTask task = new ReviewTask(1L, 1L, 2L); - assertTrue(checker.canReview(task, 1L, + ReviewTask task = new ReviewTask(1L, 1L, "user-2"); + assertTrue(checker.canReview(task, "user-1", NamespaceType.GLOBAL, Map.of(), Set.of("SUPER_ADMIN"))); } @Test void skillAdminCannotReviewTeamSkill() { - ReviewTask task = new ReviewTask(1L, 10L, 2L); - assertFalse(checker.canReview(task, 1L, + ReviewTask task = new ReviewTask(1L, 10L, "user-2"); + assertFalse(checker.canReview(task, "user-1", NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN"))); } @Test void nonAdminCannotReviewGlobalSkill() { - ReviewTask task = new ReviewTask(1L, 1L, 2L); - assertFalse(checker.canReview(task, 1L, + ReviewTask task = new ReviewTask(1L, 1L, "user-2"); + assertFalse(checker.canReview(task, "user-1", NamespaceType.GLOBAL, Map.of(), Set.of())); } @@ -83,28 +83,28 @@ class ReviewPermissionCheckerTest { @Test void skillAdminCanReviewPromotion() { - PromotionRequest req = new PromotionRequest(1L, 1L, 1L, 2L); - assertTrue(checker.canReviewPromotion(req, 1L, + PromotionRequest req = new PromotionRequest(1L, 1L, 1L, "user-2"); + assertTrue(checker.canReviewPromotion(req, "user-1", Set.of("SKILL_ADMIN"))); } @Test void superAdminCanReviewPromotion() { - PromotionRequest req = new PromotionRequest(1L, 1L, 1L, 2L); - assertTrue(checker.canReviewPromotion(req, 1L, + PromotionRequest req = new PromotionRequest(1L, 1L, 1L, "user-2"); + assertTrue(checker.canReviewPromotion(req, "user-1", Set.of("SUPER_ADMIN"))); } @Test void regularUserCannotReviewPromotion() { - PromotionRequest req = new PromotionRequest(1L, 1L, 1L, 2L); - assertFalse(checker.canReviewPromotion(req, 1L, + PromotionRequest req = new PromotionRequest(1L, 1L, 1L, "user-2"); + assertFalse(checker.canReviewPromotion(req, "user-1", Set.of())); } @Test void cannotReviewOwnPromotion() { - Long userId = 2L; + String userId = "user-2"; PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId); assertFalse(checker.canReviewPromotion(req, userId, Set.of("SKILL_ADMIN"))); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index e677de8a..297a7701 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -46,8 +46,8 @@ class ReviewServiceTest { private static final Long SKILL_VERSION_ID = 10L; private static final Long NAMESPACE_ID = 20L; - private static final Long USER_ID = 100L; - private static final Long REVIEWER_ID = 200L; + private static final String USER_ID = "user-100"; + private static final String REVIEWER_ID = "user-200"; private static final Long REVIEW_TASK_ID = 1L; private static final Long SKILL_ID = 30L; @@ -77,7 +77,7 @@ class ReviewServiceTest { } private Namespace createTeamNamespace() { - Namespace ns = new Namespace("team-a", "Team A", 1L); + Namespace ns = new Namespace("team-a", "Team A", "user-1"); setField(ns, "id", NAMESPACE_ID); return ns; } @@ -347,7 +347,7 @@ class ReviewServiceTest { when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(task)); - Long otherUserId = 999L; + String otherUserId = "user-999"; assertThrows(DomainForbiddenException.class, () -> reviewService.withdrawReview(SKILL_VERSION_ID, otherUserId)); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java index 59b3a7e4..4d273345 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java @@ -16,10 +16,10 @@ class VisibilityCheckerTest { private Skill privateSkill; private static final Long NAMESPACE_ID = 1L; - private static final Long OWNER_ID = 100L; - private static final Long OTHER_USER_ID = 200L; - private static final Long ADMIN_USER_ID = 300L; - private static final Long NAMESPACE_OWNER_ID = 400L; + private static final String OWNER_ID = "user-100"; + private static final String OTHER_USER_ID = "user-200"; + private static final String ADMIN_USER_ID = "user-300"; + private static final String NAMESPACE_OWNER_ID = "user-400"; @BeforeEach void setUp() { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java index 75df99b7..2c864578 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.skill.metadata; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import org.junit.jupiter.api.Test; import java.util.Map; @@ -62,11 +63,11 @@ class SkillMetadataParserTest { void testThrowsWhenNoFrontmatter() { String content = "# Just a markdown file without frontmatter"; - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, + DomainBadRequestException exception = assertThrows( + DomainBadRequestException.class, () -> parser.parse(content) ); - assertTrue(exception.getMessage().contains("Missing frontmatter")); + assertEquals("error.skill.metadata.frontmatter.missingStart", exception.messageCode()); } @Test @@ -79,11 +80,12 @@ class SkillMetadataParserTest { Body """; - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, + DomainBadRequestException exception = assertThrows( + DomainBadRequestException.class, () -> parser.parse(content) ); - assertTrue(exception.getMessage().contains("name")); + assertEquals("error.skill.metadata.requiredField.missing", exception.messageCode()); + assertEquals("name", exception.messageArgs()[0]); } @Test @@ -96,15 +98,16 @@ class SkillMetadataParserTest { Body """; - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, + DomainBadRequestException exception = assertThrows( + DomainBadRequestException.class, () -> parser.parse(content) ); - assertTrue(exception.getMessage().contains("description")); + assertEquals("error.skill.metadata.requiredField.missing", exception.messageCode()); + assertEquals("description", exception.messageArgs()[0]); } @Test - void testThrowsWhenMissingVersion() { + void testAllowsMissingVersion() { String content = """ --- name: test-skill @@ -113,11 +116,11 @@ class SkillMetadataParserTest { Body """; - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> parser.parse(content) - ); - assertTrue(exception.getMessage().contains("version")); + SkillMetadata metadata = parser.parse(content); + + assertEquals("test-skill", metadata.name()); + assertEquals("Test description", metadata.description()); + assertNull(metadata.version()); } @Test @@ -131,11 +134,11 @@ class SkillMetadataParserTest { Body """; - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, + DomainBadRequestException exception = assertThrows( + DomainBadRequestException.class, () -> parser.parse(content) ); - assertTrue(exception.getMessage().contains("Invalid YAML")); + assertEquals("error.skill.metadata.yaml.invalid", exception.messageCode()); } @Test @@ -147,10 +150,10 @@ class SkillMetadataParserTest { version: 1.0.0 """; - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, + DomainBadRequestException exception = assertThrows( + DomainBadRequestException.class, () -> parser.parse(content) ); - assertTrue(exception.getMessage().contains("Missing frontmatter")); + assertEquals("error.skill.metadata.frontmatter.missingEnd", exception.messageCode()); } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 1a8b1231..876ea1d6 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.event.SkillDownloadedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.storage.ObjectMetadata; import com.iflytek.skillhub.storage.ObjectStorageService; @@ -63,17 +64,19 @@ class SkillDownloadServiceTest { // Arrange String namespaceSlug = "test-ns"; String skillSlug = "test-skill"; - Long userId = 100L; + String userId = "user-100"; Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); - Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); skill.setLatestVersionId(10L); SkillVersion version = new SkillVersion(1L, "1.0.0", userId); setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -102,16 +105,18 @@ class SkillDownloadServiceTest { String namespaceSlug = "test-ns"; String skillSlug = "test-skill"; String tagName = "stable"; - Long userId = 100L; + String userId = "user-100"; Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); - Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); SkillTag tag = new SkillTag(1L, tagName, 10L, userId); SkillVersion version = new SkillVersion(1L, "1.0.0", userId); setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -134,6 +139,32 @@ class SkillDownloadServiceTest { verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadVersion_ShouldRejectDraftVersion() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String versionStr = "1.0.0"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion version = new SkillVersion(1L, versionStr, userId); + setId(version, 10L); + version.setStatus(SkillVersionStatus.DRAFT); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); + + assertThrows(DomainBadRequestException.class, () -> + service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles)); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 792d00fa..9054c09d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -6,6 +6,8 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; @@ -54,6 +56,8 @@ class SkillPublishServiceTest { private PrePublishValidator prePublishValidator; @Mock private ApplicationEventPublisher eventPublisher; + @Mock + private ReviewTaskRepository reviewTaskRepository; private SkillPublishService service; private ObjectMapper objectMapper; @@ -72,7 +76,8 @@ class SkillPublishServiceTest { skillMetadataParser, prePublishValidator, eventPublisher, - objectMapper + objectMapper, + reviewTaskRepository ); } @@ -80,14 +85,14 @@ class SkillPublishServiceTest { void testPublishFromEntries_Success() throws Exception { // Arrange String namespaceSlug = "test-ns"; - Long publisherId = 100L; + String publisherId = "user-100"; String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); PackageEntry file1 = new PackageEntry("file1.txt", "content".getBytes(), 7, "text/plain"); List entries = List.of(skillMd, file1); - Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); NamespaceMember member = mock(NamespaceMember.class); SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); @@ -108,10 +113,18 @@ class SkillPublishServiceTest { when(skillRepository.save(any())).thenReturn(skill); // Act - SkillVersion result = service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC); + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC + ); // Assert assertNotNull(result); + assertEquals(1L, result.skillId()); + assertEquals("test-skill", result.slug()); + assertEquals("1.0.0", result.version().getVersion()); verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); verify(skillFileRepository).saveAll(anyList()); verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString()); @@ -124,8 +137,8 @@ class SkillPublishServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.empty()); // Act & Assert - assertThrows(IllegalArgumentException.class, () -> - service.publishFromEntries(namespaceSlug, List.of(), 100L, SkillVisibility.PUBLIC) + assertThrows(DomainBadRequestException.class, () -> + service.publishFromEntries(namespaceSlug, List.of(), "user-100", SkillVisibility.PUBLIC) ); } @@ -133,15 +146,15 @@ class SkillPublishServiceTest { void testPublishFromEntries_NotAMember() throws Exception { // Arrange String namespaceSlug = "test-ns"; - Long publisherId = 100L; - Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + String publisherId = "user-100"; + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.empty()); // Act & Assert - assertThrows(IllegalArgumentException.class, () -> + assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(namespaceSlug, List.of(), publisherId, SkillVisibility.PUBLIC) ); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index bdead154..549ce761 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -3,6 +3,8 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.storage.ObjectStorageService; import org.junit.jupiter.api.BeforeEach; @@ -37,6 +39,8 @@ class SkillQueryServiceTest { @Mock private SkillFileRepository skillFileRepository; @Mock + private SkillTagRepository skillTagRepository; + @Mock private ObjectStorageService objectStorageService; @Mock private VisibilityChecker visibilityChecker; @@ -50,6 +54,7 @@ class SkillQueryServiceTest { skillRepository, skillVersionRepository, skillFileRepository, + skillTagRepository, objectStorageService, visibilityChecker ); @@ -60,10 +65,10 @@ class SkillQueryServiceTest { // Arrange String namespaceSlug = "test-ns"; String skillSlug = "test-skill"; - Long userId = 100L; + String userId = "user-100"; Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); - Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); setId(skill, 1L); @@ -94,12 +99,12 @@ class SkillQueryServiceTest { // Arrange String namespaceSlug = "test-ns"; String skillSlug = "test-skill"; - Long userId = 100L; + String userId = "user-100"; Map userNsRoles = Map.of(); - Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); - Skill skill = new Skill(1L, skillSlug, 200L, SkillVisibility.PRIVATE); + Skill skill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PRIVATE); setId(skill, 1L); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); @@ -107,7 +112,7 @@ class SkillQueryServiceTest { when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false); // Act & Assert - assertThrows(SecurityException.class, () -> + assertThrows(DomainForbiddenException.class, () -> service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles) ); } @@ -116,11 +121,11 @@ class SkillQueryServiceTest { void testListSkillsByNamespace() throws Exception { // Arrange String namespaceSlug = "test-ns"; - Long userId = 100L; + String userId = "user-100"; Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); Pageable pageable = PageRequest.of(0, 10); - Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); Skill skill1 = new Skill(1L, "skill1", userId, SkillVisibility.PUBLIC); setId(skill1, 1L); @@ -147,27 +152,167 @@ class SkillQueryServiceTest { String skillSlug = "test-skill"; String version = "1.0.0"; - Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); - Skill skill = new Skill(1L, skillSlug, 100L, SkillVisibility.PUBLIC); + Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); setId(skill, 1L); - SkillVersion skillVersion = new SkillVersion(1L, version, 100L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion skillVersion = new SkillVersion(1L, version, "user-100"); setId(skillVersion, 1L); + skillVersion.setStatus(SkillVersionStatus.PUBLISHED); SkillFile file1 = new SkillFile(1L, "file1.txt", 100L, "text/plain", "hash1", "key1"); + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1)); // Act - List result = service.listFiles(namespaceSlug, skillSlug, version); + List result = service.listFiles(namespaceSlug, skillSlug, version, "user-100", userNsRoles); // Assert assertEquals(1, result.size()); assertEquals("file1.txt", result.get(0).getFilePath()); } + @Test + void testListFiles_ShouldRejectDraftVersion() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.0.0"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion skillVersion = new SkillVersion(1L, version, "user-100"); + setId(skillVersion, 1L); + skillVersion.setStatus(SkillVersionStatus.DRAFT); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); + + assertThrows(DomainBadRequestException.class, () -> + service.listFiles(namespaceSlug, skillSlug, version, "user-100", userNsRoles)); + } + + @Test + void testGetVersionDetail_ShouldReturnMetadataPayload() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.0.0"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion skillVersion = new SkillVersion(1L, version, "user-100"); + setId(skillVersion, 10L); + skillVersion.setStatus(SkillVersionStatus.PUBLISHED); + skillVersion.setParsedMetadataJson("{\"name\":\"test-skill\"}"); + skillVersion.setManifestJson("[{\"path\":\"SKILL.md\"}]"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); + + SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail( + namespaceSlug, + skillSlug, + version, + "user-100", + userNsRoles + ); + + assertEquals("{\"name\":\"test-skill\"}", result.parsedMetadataJson()); + assertEquals("[{\"path\":\"SKILL.md\"}]", result.manifestJson()); + } + + @Test + void testListFilesByTag_ShouldResolveLatestTag() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + SkillVersion latestVersion = new SkillVersion(1L, "1.1.0", "user-100"); + setId(latestVersion, 11L); + latestVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion)); + when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); + + List result = service.listFilesByTag(namespaceSlug, skillSlug, "latest", "user-100", userNsRoles); + + assertEquals(1, result.size()); + assertEquals("README.md", result.get(0).getFilePath()); + } + + @Test + void testResolveVersion_ShouldReturnLatestWhenHashDoesNotMatch() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version100 = new SkillVersion(1L, "1.0.0", "user-100"); + setId(version100, 9L); + version100.setStatus(SkillVersionStatus.PUBLISHED); + SkillVersion version110 = new SkillVersion(1L, "1.1.0", "user-100"); + setId(version110, 10L); + version110.setStatus(SkillVersionStatus.PUBLISHED); + + SkillFile version100File = new SkillFile(9L, "SKILL.md", 10L, "text/markdown", "hash100", "key100"); + SkillFile version110File = new SkillFile(10L, "SKILL.md", 10L, "text/markdown", "hash110", "key110"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(version100, version110)); + when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version110)); + when(skillFileRepository.findByVersionId(9L)).thenReturn(List.of(version100File)); + when(skillFileRepository.findByVersionId(10L)).thenReturn(List.of(version110File)); + + SkillQueryService.ResolvedVersionDTO result = service.resolveVersion( + namespaceSlug, + skillSlug, + null, + null, + "sha256:does-not-match", + "user-100", + userNsRoles + ); + + assertEquals("1.1.0", result.version()); + assertEquals(Boolean.FALSE, result.matched()); + assertTrue(result.downloadUrl().contains("/versions/1.1.0/download")); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java index e597670f..85b5f317 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java @@ -1,7 +1,12 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -23,6 +28,8 @@ class SkillTagServiceTest { @Mock private NamespaceRepository namespaceRepository; @Mock + private NamespaceMemberRepository namespaceMemberRepository; + @Mock private SkillRepository skillRepository; @Mock private SkillVersionRepository skillVersionRepository; @@ -35,6 +42,7 @@ class SkillTagServiceTest { void setUp() { service = new SkillTagService( namespaceRepository, + namespaceMemberRepository, skillRepository, skillVersionRepository, skillTagRepository @@ -48,9 +56,9 @@ class SkillTagServiceTest { String skillSlug = "test-skill"; String tagName = "stable"; String targetVersion = "1.0.0"; - Long operatorId = 100L; + String operatorId = "user-100"; - Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, operatorId, SkillVisibility.PUBLIC); setId(skill, 1L); @@ -60,6 +68,8 @@ class SkillTagServiceTest { SkillTag tag = new SkillTag(1L, tagName, 1L, operatorId); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId)) + .thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.OWNER))); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); when(skillVersionRepository.findBySkillIdAndVersion(1L, targetVersion)).thenReturn(Optional.of(version)); when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.empty()); @@ -80,10 +90,10 @@ class SkillTagServiceTest { String skillSlug = "test-skill"; String tagName = "latest"; String targetVersion = "1.0.0"; - Long operatorId = 100L; + String operatorId = "user-100"; // Act & Assert - assertThrows(IllegalArgumentException.class, () -> + assertThrows(DomainBadRequestException.class, () -> service.createOrMoveTag(namespaceSlug, skillSlug, tagName, targetVersion, operatorId) ); } @@ -94,15 +104,17 @@ class SkillTagServiceTest { String namespaceSlug = "test-ns"; String skillSlug = "test-skill"; String tagName = "stable"; - Long operatorId = 100L; + String operatorId = "user-100"; - Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, operatorId, SkillVisibility.PUBLIC); setId(skill, 1L); SkillTag tag = new SkillTag(1L, tagName, 1L, operatorId); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId)) + .thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.ADMIN))); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag)); @@ -119,26 +131,45 @@ class SkillTagServiceTest { String namespaceSlug = "test-ns"; String skillSlug = "test-skill"; String tagName = "latest"; - Long operatorId = 100L; + String operatorId = "user-100"; // Act & Assert - assertThrows(IllegalArgumentException.class, () -> + assertThrows(DomainBadRequestException.class, () -> service.deleteTag(namespaceSlug, skillSlug, tagName, operatorId) ); } + @Test + void testCreateTag_RequiresAdminOrOwner() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String tagName = "stable"; + String targetVersion = "1.0.0"; + String operatorId = "user-100"; + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId)) + .thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.MEMBER))); + + assertThrows(DomainForbiddenException.class, () -> + service.createOrMoveTag(namespaceSlug, skillSlug, tagName, targetVersion, operatorId)); + } + @Test void testListTags() throws Exception { // Arrange String namespaceSlug = "test-ns"; String skillSlug = "test-skill"; - Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); setId(namespace, 1L); - Skill skill = new Skill(1L, skillSlug, 100L, SkillVisibility.PUBLIC); + Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); setId(skill, 1L); - SkillTag tag1 = new SkillTag(1L, "stable", 1L, 100L); - SkillTag tag2 = new SkillTag(1L, "beta", 2L, 100L); + SkillTag tag1 = new SkillTag(1L, "stable", 1L, "user-100"); + SkillTag tag2 = new SkillTag(1L, "beta", 2L, "user-100"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));