diff --git a/server/skillhub-domain/pom.xml b/server/skillhub-domain/pom.xml
index a1746633..035ac158 100644
--- a/server/skillhub-domain/pom.xml
+++ b/server/skillhub-domain/pom.xml
@@ -31,6 +31,15 @@
org.yaml
snakeyaml
+
+ com.iflytek.skillhub
+ skillhub-storage
+ ${project.version}
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
org.junit.jupiter
junit-jupiter
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
new file mode 100644
index 00000000..c86c44b1
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java
@@ -0,0 +1,149 @@
+package com.iflytek.skillhub.domain.skill.service;
+
+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.skill.*;
+import com.iflytek.skillhub.storage.ObjectStorageService;
+import com.iflytek.skillhub.storage.ObjectMetadata;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+
+import java.io.InputStream;
+import java.util.Map;
+
+@Service
+public class SkillDownloadService {
+
+ private final NamespaceRepository namespaceRepository;
+ private final SkillRepository skillRepository;
+ private final SkillVersionRepository skillVersionRepository;
+ private final SkillTagRepository skillTagRepository;
+ private final ObjectStorageService objectStorageService;
+ private final VisibilityChecker visibilityChecker;
+ private final ApplicationEventPublisher eventPublisher;
+
+ public SkillDownloadService(
+ NamespaceRepository namespaceRepository,
+ SkillRepository skillRepository,
+ SkillVersionRepository skillVersionRepository,
+ SkillTagRepository skillTagRepository,
+ ObjectStorageService objectStorageService,
+ VisibilityChecker visibilityChecker,
+ ApplicationEventPublisher eventPublisher) {
+ this.namespaceRepository = namespaceRepository;
+ this.skillRepository = skillRepository;
+ this.skillVersionRepository = skillVersionRepository;
+ this.skillTagRepository = skillTagRepository;
+ this.objectStorageService = objectStorageService;
+ this.visibilityChecker = visibilityChecker;
+ this.eventPublisher = eventPublisher;
+ }
+
+ public record DownloadResult(
+ InputStream content,
+ String filename,
+ long contentLength,
+ String contentType
+ ) {}
+
+ public DownloadResult downloadLatest(
+ String namespaceSlug,
+ String skillSlug,
+ Long currentUserId,
+ Map userNsRoles) {
+
+ Namespace namespace = findNamespace(namespaceSlug);
+ Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
+ .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug));
+
+ // Visibility check
+ if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
+ throw new SecurityException("Access denied to skill: " + skillSlug);
+ }
+
+ if (skill.getLatestVersionId() == null) {
+ throw new IllegalArgumentException("No published version available for skill: " + skillSlug);
+ }
+
+ SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId())
+ .orElseThrow(() -> new IllegalArgumentException("Latest version not found"));
+
+ return downloadVersion(skill, version);
+ }
+
+ public DownloadResult downloadVersion(
+ String namespaceSlug,
+ String skillSlug,
+ String versionStr,
+ Long currentUserId,
+ Map userNsRoles) {
+
+ Namespace namespace = findNamespace(namespaceSlug);
+ Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
+ .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug));
+
+ // Visibility check
+ if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
+ throw new SecurityException("Access denied to skill: " + skillSlug);
+ }
+
+ SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), versionStr)
+ .orElseThrow(() -> new IllegalArgumentException("Version not found: " + versionStr));
+
+ return downloadVersion(skill, version);
+ }
+
+ public DownloadResult downloadByTag(
+ String namespaceSlug,
+ String skillSlug,
+ String tagName,
+ Long currentUserId,
+ Map userNsRoles) {
+
+ Namespace namespace = findNamespace(namespaceSlug);
+ Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
+ .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug));
+
+ // Visibility check
+ if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
+ throw new SecurityException("Access denied to skill: " + skillSlug);
+ }
+
+ SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName)
+ .orElseThrow(() -> new IllegalArgumentException("Tag not found: " + tagName));
+
+ if (tag.getVersionId() == null) {
+ throw new IllegalArgumentException("Tag does not point to a version: " + tagName);
+ }
+
+ SkillVersion version = skillVersionRepository.findById(tag.getVersionId())
+ .orElseThrow(() -> new IllegalArgumentException("Version not found for tag: " + tagName));
+
+ return downloadVersion(skill, version);
+ }
+
+ private DownloadResult downloadVersion(Skill skill, SkillVersion 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");
+ }
+
+ ObjectMetadata metadata = objectStorageService.getMetadata(storageKey);
+ InputStream content = objectStorageService.getObject(storageKey);
+
+ // Publish download event
+ eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId()));
+
+ String filename = String.format("%s-%s.zip", skill.getSlug(), version.getVersion());
+
+ return new DownloadResult(content, filename, metadata.size(), metadata.contentType());
+ }
+
+ private Namespace findNamespace(String slug) {
+ return namespaceRepository.findBySlug(slug)
+ .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug));
+ }
+}
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
new file mode 100644
index 00000000..a9b15d71
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java
@@ -0,0 +1,195 @@
+package com.iflytek.skillhub.domain.skill.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.skill.*;
+import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
+import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
+import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
+import com.iflytek.skillhub.domain.skill.validation.PrePublishValidator;
+import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
+import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
+import com.iflytek.skillhub.storage.ObjectStorageService;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.ByteArrayInputStream;
+import java.security.MessageDigest;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.List;
+
+@Service
+public class SkillPublishService {
+
+ private final NamespaceRepository namespaceRepository;
+ private final NamespaceMemberRepository namespaceMemberRepository;
+ private final SkillRepository skillRepository;
+ private final SkillVersionRepository skillVersionRepository;
+ private final SkillFileRepository skillFileRepository;
+ private final ObjectStorageService objectStorageService;
+ private final SkillPackageValidator skillPackageValidator;
+ private final SkillMetadataParser skillMetadataParser;
+ private final PrePublishValidator prePublishValidator;
+ private final ApplicationEventPublisher eventPublisher;
+ private final ObjectMapper objectMapper;
+
+ public SkillPublishService(
+ NamespaceRepository namespaceRepository,
+ NamespaceMemberRepository namespaceMemberRepository,
+ SkillRepository skillRepository,
+ SkillVersionRepository skillVersionRepository,
+ SkillFileRepository skillFileRepository,
+ ObjectStorageService objectStorageService,
+ SkillPackageValidator skillPackageValidator,
+ SkillMetadataParser skillMetadataParser,
+ PrePublishValidator prePublishValidator,
+ ApplicationEventPublisher eventPublisher,
+ ObjectMapper objectMapper) {
+ this.namespaceRepository = namespaceRepository;
+ this.namespaceMemberRepository = namespaceMemberRepository;
+ this.skillRepository = skillRepository;
+ this.skillVersionRepository = skillVersionRepository;
+ this.skillFileRepository = skillFileRepository;
+ this.objectStorageService = objectStorageService;
+ this.skillPackageValidator = skillPackageValidator;
+ this.skillMetadataParser = skillMetadataParser;
+ this.prePublishValidator = prePublishValidator;
+ this.eventPublisher = eventPublisher;
+ this.objectMapper = objectMapper;
+ }
+
+ @Transactional
+ public SkillVersion publishFromEntries(
+ String namespaceSlug,
+ List entries,
+ Long publisherId,
+ SkillVisibility visibility) {
+
+ // 1. Find namespace by slug
+ Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
+ .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + namespaceSlug));
+
+ // 2. Check publisher is member
+ namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId)
+ .orElseThrow(() -> new IllegalArgumentException("Publisher is not a member of namespace: " + namespaceSlug));
+
+ // 3. Validate package
+ ValidationResult packageValidation = skillPackageValidator.validate(entries);
+ if (!packageValidation.passed()) {
+ throw new IllegalArgumentException("Package validation failed: " + 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"));
+
+ String skillMdContent = new String(skillMd.content());
+ SkillMetadata metadata = skillMetadataParser.parse(skillMdContent);
+
+ // 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()));
+ }
+
+ // 6. Find or create Skill record
+ Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), metadata.name())
+ .orElseGet(() -> {
+ Skill newSkill = new Skill(namespace.getId(), metadata.name(), 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());
+ }
+
+ // 8. Create SkillVersion
+ SkillVersion version = new SkillVersion(skill.getId(), metadata.version(), publisherId);
+ version.setStatus(SkillVersionStatus.PUBLISHED);
+ version.setPublishedAt(LocalDateTime.now());
+
+ // Store metadata as JSON
+ try {
+ String metadataJson = objectMapper.writeValueAsString(metadata);
+ version.setParsedMetadataJson(metadataJson);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to serialize metadata", e);
+ }
+
+ version = skillVersionRepository.save(version);
+
+ // 9. Upload each file to storage and compute SHA-256
+ List skillFiles = new ArrayList<>();
+ long totalSize = 0;
+
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ HexFormat hexFormat = HexFormat.of();
+
+ for (PackageEntry entry : entries) {
+ String storageKey = String.format("skills/%d/%d/%s", skill.getId(), version.getId(), entry.path());
+
+ // Upload to storage
+ objectStorageService.putObject(
+ storageKey,
+ new ByteArrayInputStream(entry.content()),
+ entry.size(),
+ entry.contentType()
+ );
+
+ // Compute SHA-256
+ byte[] hash = digest.digest(entry.content());
+ String sha256 = hexFormat.formatHex(hash);
+
+ // Create SkillFile record
+ SkillFile skillFile = new SkillFile(
+ version.getId(),
+ entry.path(),
+ entry.size(),
+ entry.contentType(),
+ sha256,
+ storageKey
+ );
+ skillFiles.add(skillFile);
+ totalSize += entry.size();
+
+ digest.reset();
+ }
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to process files", e);
+ }
+
+ // 10. Save SkillFile records
+ skillFileRepository.saveAll(skillFiles);
+
+ // 11. Update version stats
+ version.setFileCount(skillFiles.size());
+ version.setTotalSize(totalSize);
+ skillVersionRepository.save(version);
+
+ // 12. Update skill
+ skill.setLatestVersionId(version.getId());
+ skill.setDisplayName(metadata.name());
+ skill.setSummary(metadata.description());
+ skill.setUpdatedBy(publisherId);
+ skillRepository.save(skill);
+
+ // 13. Publish SkillPublishedEvent
+ eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId));
+
+ // 14. Return version
+ return version;
+ }
+}
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
new file mode 100644
index 00000000..79cc70d8
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java
@@ -0,0 +1,163 @@
+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.skill.*;
+import com.iflytek.skillhub.storage.ObjectStorageService;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+
+import java.io.InputStream;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+public class SkillQueryService {
+
+ private final NamespaceRepository namespaceRepository;
+ private final SkillRepository skillRepository;
+ private final SkillVersionRepository skillVersionRepository;
+ private final SkillFileRepository skillFileRepository;
+ private final ObjectStorageService objectStorageService;
+ private final VisibilityChecker visibilityChecker;
+
+ public SkillQueryService(
+ NamespaceRepository namespaceRepository,
+ SkillRepository skillRepository,
+ SkillVersionRepository skillVersionRepository,
+ SkillFileRepository skillFileRepository,
+ ObjectStorageService objectStorageService,
+ VisibilityChecker visibilityChecker) {
+ this.namespaceRepository = namespaceRepository;
+ this.skillRepository = skillRepository;
+ this.skillVersionRepository = skillVersionRepository;
+ this.skillFileRepository = skillFileRepository;
+ this.objectStorageService = objectStorageService;
+ this.visibilityChecker = visibilityChecker;
+ }
+
+ public record SkillDetailDTO(
+ Long id,
+ String slug,
+ String displayName,
+ String summary,
+ String visibility,
+ String status,
+ Long downloadCount,
+ Integer starCount,
+ String latestVersion,
+ Long namespaceId
+ ) {}
+
+ public SkillDetailDTO getSkillDetail(
+ String namespaceSlug,
+ String skillSlug,
+ Long currentUserId,
+ Map userNsRoles) {
+
+ Namespace namespace = findNamespace(namespaceSlug);
+ Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
+ .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug));
+
+ // Visibility check
+ if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
+ throw new SecurityException("Access denied to skill: " + skillSlug);
+ }
+
+ String latestVersion = null;
+ if (skill.getLatestVersionId() != null) {
+ SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null);
+ if (version != null) {
+ latestVersion = version.getVersion();
+ }
+ }
+
+ return new SkillDetailDTO(
+ skill.getId(),
+ skill.getSlug(),
+ skill.getDisplayName(),
+ skill.getSummary(),
+ skill.getVisibility().name(),
+ skill.getStatus().name(),
+ skill.getDownloadCount(),
+ skill.getStarCount(),
+ latestVersion,
+ skill.getNamespaceId()
+ );
+ }
+
+ public Page listSkillsByNamespace(
+ String namespaceSlug,
+ Long currentUserId,
+ Map userNsRoles,
+ Pageable pageable) {
+
+ Namespace namespace = findNamespace(namespaceSlug);
+ List allSkills = skillRepository.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE);
+
+ // Filter by visibility
+ List accessibleSkills = allSkills.stream()
+ .filter(skill -> visibilityChecker.canAccess(skill, currentUserId, userNsRoles))
+ .collect(Collectors.toList());
+
+ // Manual pagination
+ int start = (int) pageable.getOffset();
+ int end = Math.min(start + pageable.getPageSize(), accessibleSkills.size());
+ List pageContent = accessibleSkills.subList(start, end);
+
+ 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));
+
+ SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
+ .orElseThrow(() -> new IllegalArgumentException("Version not found: " + 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));
+
+ SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
+ .orElseThrow(() -> new IllegalArgumentException("Version not found: " + version));
+
+ List files = skillFileRepository.findByVersionId(skillVersion.getId());
+ SkillFile file = files.stream()
+ .filter(f -> f.getFilePath().equals(filePath))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("File not found: " + 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));
+
+ List publishedVersions = skillVersionRepository.findBySkillIdAndStatus(
+ skill.getId(), SkillVersionStatus.PUBLISHED);
+
+ // Manual pagination
+ int start = (int) pageable.getOffset();
+ int end = Math.min(start + pageable.getPageSize(), publishedVersions.size());
+ List pageContent = publishedVersions.subList(start, end);
+
+ return new PageImpl<>(pageContent, pageable, publishedVersions.size());
+ }
+
+ private Namespace findNamespace(String slug) {
+ return namespaceRepository.findBySlug(slug)
+ .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug));
+ }
+}
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
new file mode 100644
index 00000000..44ed334b
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java
@@ -0,0 +1,101 @@
+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.skill.*;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Service
+public class SkillTagService {
+
+ private static final String RESERVED_TAG_LATEST = "latest";
+
+ private final NamespaceRepository namespaceRepository;
+ private final SkillRepository skillRepository;
+ private final SkillVersionRepository skillVersionRepository;
+ private final SkillTagRepository skillTagRepository;
+
+ public SkillTagService(
+ NamespaceRepository namespaceRepository,
+ SkillRepository skillRepository,
+ SkillVersionRepository skillVersionRepository,
+ SkillTagRepository skillTagRepository) {
+ this.namespaceRepository = namespaceRepository;
+ this.skillRepository = skillRepository;
+ this.skillVersionRepository = skillVersionRepository;
+ this.skillTagRepository = skillTagRepository;
+ }
+
+ 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));
+
+ return skillTagRepository.findBySkillId(skill.getId());
+ }
+
+ @Transactional
+ public SkillTag createOrMoveTag(
+ String namespaceSlug,
+ String skillSlug,
+ String tagName,
+ String targetVersion,
+ Long operatorId) {
+
+ // Reject "latest" tag
+ if (RESERVED_TAG_LATEST.equalsIgnoreCase(tagName)) {
+ throw new IllegalArgumentException("Tag name 'latest' is reserved");
+ }
+
+ Namespace namespace = findNamespace(namespaceSlug);
+ Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
+ .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug));
+
+ // Find target version
+ SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), targetVersion)
+ .orElseThrow(() -> new IllegalArgumentException("Version not found: " + targetVersion));
+
+ // Target must be PUBLISHED
+ if (version.getStatus() != SkillVersionStatus.PUBLISHED) {
+ throw new IllegalArgumentException("Target version must be PUBLISHED");
+ }
+
+ // Check if tag exists
+ SkillTag existingTag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName).orElse(null);
+
+ if (existingTag != null) {
+ // Move tag
+ existingTag.setVersionId(version.getId());
+ return skillTagRepository.save(existingTag);
+ } else {
+ // Create new tag
+ SkillTag newTag = new SkillTag(skill.getId(), tagName, version.getId(), operatorId);
+ return skillTagRepository.save(newTag);
+ }
+ }
+
+ @Transactional
+ public void deleteTag(String namespaceSlug, String skillSlug, String tagName, Long operatorId) {
+ // Reject "latest" tag
+ if (RESERVED_TAG_LATEST.equalsIgnoreCase(tagName)) {
+ throw new IllegalArgumentException("Tag name 'latest' is reserved and cannot be deleted");
+ }
+
+ Namespace namespace = findNamespace(namespaceSlug);
+ Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
+ .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug));
+
+ SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName)
+ .orElseThrow(() -> new IllegalArgumentException("Tag not found: " + tagName));
+
+ skillTagRepository.delete(tag);
+ }
+
+ private Namespace findNamespace(String slug) {
+ return namespaceRepository.findBySlug(slug)
+ .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug));
+ }
+}
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
new file mode 100644
index 00000000..1a8b1231
--- /dev/null
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java
@@ -0,0 +1,142 @@
+package com.iflytek.skillhub.domain.skill.service;
+
+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.skill.*;
+import com.iflytek.skillhub.storage.ObjectMetadata;
+import com.iflytek.skillhub.storage.ObjectStorageService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class SkillDownloadServiceTest {
+
+ @Mock
+ private NamespaceRepository namespaceRepository;
+ @Mock
+ private SkillRepository skillRepository;
+ @Mock
+ private SkillVersionRepository skillVersionRepository;
+ @Mock
+ private SkillTagRepository skillTagRepository;
+ @Mock
+ private ObjectStorageService objectStorageService;
+ @Mock
+ private VisibilityChecker visibilityChecker;
+ @Mock
+ private ApplicationEventPublisher eventPublisher;
+
+ private SkillDownloadService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new SkillDownloadService(
+ namespaceRepository,
+ skillRepository,
+ skillVersionRepository,
+ skillTagRepository,
+ objectStorageService,
+ visibilityChecker,
+ eventPublisher
+ );
+ }
+
+ @Test
+ void testDownloadLatest_Success() throws Exception {
+ // Arrange
+ String namespaceSlug = "test-ns";
+ String skillSlug = "test-skill";
+ Long userId = 100L;
+ Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
+ setId(namespace, 1L);
+ Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
+ setId(skill, 1L);
+ skill.setLatestVersionId(10L);
+
+ SkillVersion version = new SkillVersion(1L, "1.0.0", userId);
+ setId(version, 10L);
+ String storageKey = "packages/1/10/bundle.zip";
+ InputStream content = new ByteArrayInputStream("test".getBytes());
+ ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
+
+ 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.findById(10L)).thenReturn(Optional.of(version));
+ when(objectStorageService.exists(storageKey)).thenReturn(true);
+ when(objectStorageService.getMetadata(storageKey)).thenReturn(metadata);
+ when(objectStorageService.getObject(storageKey)).thenReturn(content);
+
+ // Act
+ SkillDownloadService.DownloadResult result = service.downloadLatest(namespaceSlug, skillSlug, userId, userNsRoles);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals("test-skill-1.0.0.zip", result.filename());
+ assertEquals(1000L, result.contentLength());
+ verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class));
+ }
+
+ @Test
+ void testDownloadByTag_Success() throws Exception {
+ // Arrange
+ String namespaceSlug = "test-ns";
+ String skillSlug = "test-skill";
+ String tagName = "stable";
+ Long userId = 100L;
+ Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
+ setId(namespace, 1L);
+ Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
+ setId(skill, 1L);
+ SkillTag tag = new SkillTag(1L, tagName, 10L, userId);
+ SkillVersion version = new SkillVersion(1L, "1.0.0", userId);
+ setId(version, 10L);
+ String storageKey = "packages/1/10/bundle.zip";
+ InputStream content = new ByteArrayInputStream("test".getBytes());
+ ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
+
+ 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(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag));
+ when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
+ when(objectStorageService.exists(storageKey)).thenReturn(true);
+ when(objectStorageService.getMetadata(storageKey)).thenReturn(metadata);
+ when(objectStorageService.getObject(storageKey)).thenReturn(content);
+
+ // Act
+ SkillDownloadService.DownloadResult result = service.downloadByTag(namespaceSlug, skillSlug, tagName, userId, userNsRoles);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals("test-skill-1.0.0.zip", result.filename());
+ verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class));
+ }
+
+ private void setId(Object entity, Long id) throws Exception {
+ Field idField = entity.getClass().getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(entity, id);
+ }
+}
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
new file mode 100644
index 00000000..792d00fa
--- /dev/null
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java
@@ -0,0 +1,154 @@
+package com.iflytek.skillhub.domain.skill.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
+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.skill.*;
+import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
+import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
+import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
+import com.iflytek.skillhub.domain.skill.validation.PrePublishValidator;
+import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
+import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
+import com.iflytek.skillhub.storage.ObjectStorageService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+
+import java.lang.reflect.Field;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class SkillPublishServiceTest {
+
+ @Mock
+ private NamespaceRepository namespaceRepository;
+ @Mock
+ private NamespaceMemberRepository namespaceMemberRepository;
+ @Mock
+ private SkillRepository skillRepository;
+ @Mock
+ private SkillVersionRepository skillVersionRepository;
+ @Mock
+ private SkillFileRepository skillFileRepository;
+ @Mock
+ private ObjectStorageService objectStorageService;
+ @Mock
+ private SkillPackageValidator skillPackageValidator;
+ @Mock
+ private SkillMetadataParser skillMetadataParser;
+ @Mock
+ private PrePublishValidator prePublishValidator;
+ @Mock
+ private ApplicationEventPublisher eventPublisher;
+
+ private SkillPublishService service;
+ private ObjectMapper objectMapper;
+
+ @BeforeEach
+ void setUp() {
+ objectMapper = new ObjectMapper();
+ service = new SkillPublishService(
+ namespaceRepository,
+ namespaceMemberRepository,
+ skillRepository,
+ skillVersionRepository,
+ skillFileRepository,
+ objectStorageService,
+ skillPackageValidator,
+ skillMetadataParser,
+ prePublishValidator,
+ eventPublisher,
+ objectMapper
+ );
+ }
+
+ @Test
+ void testPublishFromEntries_Success() throws Exception {
+ // Arrange
+ String namespaceSlug = "test-ns";
+ Long publisherId = 100L;
+ 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);
+ setId(namespace, 1L);
+ NamespaceMember member = mock(NamespaceMember.class);
+ SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
+
+ Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
+ setId(skill, 1L);
+ SkillVersion version = new SkillVersion(1L, "1.0.0", publisherId);
+ setId(version, 10L);
+
+ when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
+ when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
+ when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
+ when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
+ when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
+ when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(skill));
+ when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
+ when(skillVersionRepository.save(any())).thenReturn(version);
+ when(skillRepository.save(any())).thenReturn(skill);
+
+ // Act
+ SkillVersion result = service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC);
+
+ // Assert
+ assertNotNull(result);
+ verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
+ verify(skillFileRepository).saveAll(anyList());
+ verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString());
+ }
+
+ @Test
+ void testPublishFromEntries_NamespaceNotFound() {
+ // Arrange
+ String namespaceSlug = "nonexistent";
+ when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.empty());
+
+ // Act & Assert
+ assertThrows(IllegalArgumentException.class, () ->
+ service.publishFromEntries(namespaceSlug, List.of(), 100L, SkillVisibility.PUBLIC)
+ );
+ }
+
+ @Test
+ void testPublishFromEntries_NotAMember() throws Exception {
+ // Arrange
+ String namespaceSlug = "test-ns";
+ Long publisherId = 100L;
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
+ 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, () ->
+ service.publishFromEntries(namespaceSlug, List.of(), publisherId, SkillVisibility.PUBLIC)
+ );
+ }
+
+ private void setId(Object entity, Long id) throws Exception {
+ Field idField = entity.getClass().getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(entity, id);
+ }
+}
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
new file mode 100644
index 00000000..bdead154
--- /dev/null
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java
@@ -0,0 +1,176 @@
+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.skill.*;
+import com.iflytek.skillhub.storage.ObjectStorageService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class SkillQueryServiceTest {
+
+ @Mock
+ private NamespaceRepository namespaceRepository;
+ @Mock
+ private SkillRepository skillRepository;
+ @Mock
+ private SkillVersionRepository skillVersionRepository;
+ @Mock
+ private SkillFileRepository skillFileRepository;
+ @Mock
+ private ObjectStorageService objectStorageService;
+ @Mock
+ private VisibilityChecker visibilityChecker;
+
+ private SkillQueryService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new SkillQueryService(
+ namespaceRepository,
+ skillRepository,
+ skillVersionRepository,
+ skillFileRepository,
+ objectStorageService,
+ visibilityChecker
+ );
+ }
+
+ @Test
+ void testGetSkillDetail_Success() throws Exception {
+ // Arrange
+ String namespaceSlug = "test-ns";
+ String skillSlug = "test-skill";
+ Long userId = 100L;
+ Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
+ setId(namespace, 1L);
+ Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
+ setId(skill, 1L);
+ skill.setDisplayName("Test Skill");
+ skill.setSummary("Test Summary");
+ skill.setLatestVersionId(10L);
+
+ SkillVersion version = new SkillVersion(1L, "1.0.0", userId);
+ setId(version, 10L);
+
+ 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.findById(10L)).thenReturn(Optional.of(version));
+
+ // Act
+ SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(skillSlug, result.slug());
+ assertEquals("Test Skill", result.displayName());
+ assertEquals("1.0.0", result.latestVersion());
+ }
+
+ @Test
+ void testGetSkillDetail_AccessDenied() throws Exception {
+ // Arrange
+ String namespaceSlug = "test-ns";
+ String skillSlug = "test-skill";
+ Long userId = 100L;
+ Map userNsRoles = Map.of();
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
+ setId(namespace, 1L);
+ Skill skill = new Skill(1L, skillSlug, 200L, SkillVisibility.PRIVATE);
+ setId(skill, 1L);
+
+ when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
+ when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
+ when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false);
+
+ // Act & Assert
+ assertThrows(SecurityException.class, () ->
+ service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles)
+ );
+ }
+
+ @Test
+ void testListSkillsByNamespace() throws Exception {
+ // Arrange
+ String namespaceSlug = "test-ns";
+ Long userId = 100L;
+ Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
+ Pageable pageable = PageRequest.of(0, 10);
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
+ setId(namespace, 1L);
+ Skill skill1 = new Skill(1L, "skill1", userId, SkillVisibility.PUBLIC);
+ setId(skill1, 1L);
+ Skill skill2 = new Skill(1L, "skill2", userId, SkillVisibility.PRIVATE);
+ setId(skill2, 2L);
+
+ when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
+ when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)).thenReturn(List.of(skill1, skill2));
+ when(visibilityChecker.canAccess(skill1, userId, userNsRoles)).thenReturn(true);
+ when(visibilityChecker.canAccess(skill2, userId, userNsRoles)).thenReturn(false);
+
+ // Act
+ Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
+
+ // Assert
+ assertEquals(1, result.getTotalElements());
+ assertEquals("skill1", result.getContent().get(0).getSlug());
+ }
+
+ @Test
+ void testListFiles() throws Exception {
+ // Arrange
+ String namespaceSlug = "test-ns";
+ String skillSlug = "test-skill";
+ String version = "1.0.0";
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
+ setId(namespace, 1L);
+ Skill skill = new Skill(1L, skillSlug, 100L, SkillVisibility.PUBLIC);
+ setId(skill, 1L);
+ SkillVersion skillVersion = new SkillVersion(1L, version, 100L);
+ setId(skillVersion, 1L);
+ SkillFile file1 = new SkillFile(1L, "file1.txt", 100L, "text/plain", "hash1", "key1");
+
+ when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
+ when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
+ 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);
+
+ // Assert
+ assertEquals(1, result.size());
+ assertEquals("file1.txt", result.get(0).getFilePath());
+ }
+
+ private void setId(Object entity, Long id) throws Exception {
+ Field idField = entity.getClass().getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(entity, id);
+ }
+}
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
new file mode 100644
index 00000000..e597670f
--- /dev/null
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java
@@ -0,0 +1,159 @@
+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.skill.*;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.lang.reflect.Field;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class SkillTagServiceTest {
+
+ @Mock
+ private NamespaceRepository namespaceRepository;
+ @Mock
+ private SkillRepository skillRepository;
+ @Mock
+ private SkillVersionRepository skillVersionRepository;
+ @Mock
+ private SkillTagRepository skillTagRepository;
+
+ private SkillTagService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new SkillTagService(
+ namespaceRepository,
+ skillRepository,
+ skillVersionRepository,
+ skillTagRepository
+ );
+ }
+
+ @Test
+ void testCreateTag_Success() throws Exception {
+ // Arrange
+ String namespaceSlug = "test-ns";
+ String skillSlug = "test-skill";
+ String tagName = "stable";
+ String targetVersion = "1.0.0";
+ Long operatorId = 100L;
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
+ setId(namespace, 1L);
+ Skill skill = new Skill(1L, skillSlug, operatorId, SkillVisibility.PUBLIC);
+ setId(skill, 1L);
+ SkillVersion version = new SkillVersion(1L, targetVersion, operatorId);
+ setId(version, 1L);
+ version.setStatus(SkillVersionStatus.PUBLISHED);
+ SkillTag tag = new SkillTag(1L, tagName, 1L, operatorId);
+
+ when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
+ 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());
+ when(skillTagRepository.save(any())).thenReturn(tag);
+
+ // Act
+ SkillTag result = service.createOrMoveTag(namespaceSlug, skillSlug, tagName, targetVersion, operatorId);
+
+ // Assert
+ assertNotNull(result);
+ verify(skillTagRepository).save(any(SkillTag.class));
+ }
+
+ @Test
+ void testCreateTag_RejectLatest() {
+ // Arrange
+ String namespaceSlug = "test-ns";
+ String skillSlug = "test-skill";
+ String tagName = "latest";
+ String targetVersion = "1.0.0";
+ Long operatorId = 100L;
+
+ // Act & Assert
+ assertThrows(IllegalArgumentException.class, () ->
+ service.createOrMoveTag(namespaceSlug, skillSlug, tagName, targetVersion, operatorId)
+ );
+ }
+
+ @Test
+ void testDeleteTag_Success() throws Exception {
+ // Arrange
+ String namespaceSlug = "test-ns";
+ String skillSlug = "test-skill";
+ String tagName = "stable";
+ Long operatorId = 100L;
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
+ 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(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
+ when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag));
+
+ // Act
+ service.deleteTag(namespaceSlug, skillSlug, tagName, operatorId);
+
+ // Assert
+ verify(skillTagRepository).delete(tag);
+ }
+
+ @Test
+ void testDeleteTag_RejectLatest() {
+ // Arrange
+ String namespaceSlug = "test-ns";
+ String skillSlug = "test-skill";
+ String tagName = "latest";
+ Long operatorId = 100L;
+
+ // Act & Assert
+ assertThrows(IllegalArgumentException.class, () ->
+ service.deleteTag(namespaceSlug, skillSlug, tagName, operatorId)
+ );
+ }
+
+ @Test
+ void testListTags() throws Exception {
+ // Arrange
+ String namespaceSlug = "test-ns";
+ String skillSlug = "test-skill";
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
+ setId(namespace, 1L);
+ Skill skill = new Skill(1L, skillSlug, 100L, SkillVisibility.PUBLIC);
+ setId(skill, 1L);
+ SkillTag tag1 = new SkillTag(1L, "stable", 1L, 100L);
+ SkillTag tag2 = new SkillTag(1L, "beta", 2L, 100L);
+
+ when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
+ when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
+ when(skillTagRepository.findBySkillId(1L)).thenReturn(List.of(tag1, tag2));
+
+ // Act
+ List result = service.listTags(namespaceSlug, skillSlug);
+
+ // Assert
+ assertEquals(2, result.size());
+ }
+
+ private void setId(Object entity, Long id) throws Exception {
+ Field idField = entity.getClass().getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(entity, id);
+ }
+}