From f38fea85b5ee9d4707f5a97e8c671f99efa6f795 Mon Sep 17 00:00:00 2001 From: tww <1451548858@qq.com> Date: Sun, 15 Mar 2026 16:02:54 +0800 Subject: [PATCH] cli --- .../compat/ClawHubCompatController.java | 292 ++++++++++++++++-- .../compat/ClawHubRegistryController.java | 54 ---- .../skillhub/compat/WellKnownController.java | 2 +- .../compat/dto/ClawHubDeleteResponse.java | 9 + .../compat/dto/ClawHubPublishResponse.java | 12 +- .../compat/dto/ClawHubResolveResponse.java | 11 +- .../compat/dto/ClawHubSearchResponse.java | 13 +- .../skillhub/compat/dto/ClawHubSkillItem.java | 8 - .../compat/dto/ClawHubSkillListResponse.java | 26 ++ .../compat/dto/ClawHubSkillResponse.java | 43 +++ .../compat/dto/ClawHubStarResponse.java | 11 + .../compat/dto/ClawHubUnstarResponse.java | 11 + .../compat/dto/ClawHubWhoamiResponse.java | 16 +- .../skillhub/controller/CliController.java | 105 ------- .../controller/cli/CliPublishController.java | 95 ------ .../support/MultipartPackageExtractor.java | 119 +++++++ .../skillhub/filter/RequestLoggingFilter.java | 90 ++++++ .../compat/ClawHubRegistryControllerTest.java | 222 ------------- .../auth/token/ApiTokenScopeService.java | 2 + .../domain/skill/SkillRepository.java | 2 + .../skill/service/SkillQueryService.java | 17 +- .../infra/jpa/JpaSkillRepositoryAdapter.java | 10 + .../infra/jpa/SkillJpaRepository.java | 5 + 23 files changed, 653 insertions(+), 522 deletions(-) delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubDeleteResponse.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillListResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubStarResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubUnstarResponse.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java delete mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index b25e6c5b..151e3315 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -1,17 +1,30 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.compat.dto.ClawHubDeleteResponse; import com.iflytek.skillhub.compat.dto.ClawHubPublishResponse; -import com.iflytek.skillhub.compat.dto.ClawHubSkillItem; import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse; import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse; +import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse; +import com.iflytek.skillhub.compat.dto.ClawHubStarResponse; +import com.iflytek.skillhub.compat.dto.ClawHubUnstarResponse; import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse; +import com.iflytek.skillhub.controller.support.MultipartPackageExtractor; import com.iflytek.skillhub.controller.support.ZipPackageExtractor; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.social.SkillStarService; +import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.service.SkillSearchAppService; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.MDC; @@ -23,11 +36,12 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.time.ZoneOffset; import java.util.List; import java.util.Map; @RestController -@RequestMapping("/api/compat/v1") +@RequestMapping("/api/v1") public class ClawHubCompatController { private final CanonicalSlugMapper mapper; @@ -35,20 +49,35 @@ public class ClawHubCompatController { private final SkillQueryService skillQueryService; private final SkillPublishService skillPublishService; private final ZipPackageExtractor zipPackageExtractor; + private final MultipartPackageExtractor multipartPackageExtractor; private final AuditLogService auditLogService; + private final SkillRepository skillRepository; + private final NamespaceRepository namespaceRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillStarService skillStarService; public ClawHubCompatController(CanonicalSlugMapper mapper, SkillSearchAppService skillSearchAppService, SkillQueryService skillQueryService, SkillPublishService skillPublishService, ZipPackageExtractor zipPackageExtractor, - AuditLogService auditLogService) { + MultipartPackageExtractor multipartPackageExtractor, + AuditLogService auditLogService, + SkillRepository skillRepository, + NamespaceRepository namespaceRepository, + SkillVersionRepository skillVersionRepository, + SkillStarService skillStarService) { this.mapper = mapper; this.skillSearchAppService = skillSearchAppService; this.skillQueryService = skillQueryService; this.skillPublishService = skillPublishService; this.zipPackageExtractor = zipPackageExtractor; + this.multipartPackageExtractor = multipartPackageExtractor; this.auditLogService = auditLogService; + this.skillRepository = skillRepository; + this.namespaceRepository = namespaceRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillStarService = skillStarService; } @GetMapping("/search") @@ -68,15 +97,67 @@ public class ClawHubCompatController { userNsRoles ); - List items = response.items().stream() - .map(item -> new ClawHubSkillItem( - mapper.toCanonical(item.namespace(), item.slug()), - item.summary(), - item.latestVersion(), - item.starCount())) + List results = response.items().stream() + .map(this::toSearchResult) .toList(); - return new ClawHubSearchResponse(items); + return new ClawHubSearchResponse(results); + } + + private ClawHubSearchResponse.ClawHubSearchResult toSearchResult(SkillSummaryResponse item) { + Long updatedAtEpoch = item.updatedAt() != null + ? item.updatedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + : null; + return new ClawHubSearchResponse.ClawHubSearchResult( + mapper.toCanonical(item.namespace(), item.slug()), + item.displayName(), + item.summary(), + item.latestVersion(), + calculateScore(item), + updatedAtEpoch + ); + } + + private double calculateScore(SkillSummaryResponse item) { + // Simple score calculation based on stars and downloads + int starScore = item.starCount() != null ? item.starCount() * 10 : 0; + long downloadScore = item.downloadCount() != null ? item.downloadCount() : 0; + return (starScore + downloadScore) / 100.0; + } + + @GetMapping("/resolve") + public ClawHubResolveResponse resolveByQuery( + @RequestParam String slug, + @RequestParam(required = false) String version, + @RequestParam(required = false) String hash, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + // For resolve endpoint with query params, slug is just the skill slug without namespace + // We need to find the skill by slug (this is a simplification - in real world you'd need more context) + Skill skill = skillRepository.findBySlug(slug).stream().findFirst() + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", slug)); + + Namespace ns = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", skill.getNamespaceId())); + + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + ns.getSlug(), + skill.getSlug(), + "latest".equals(version) ? null : version, + "latest".equals(version) ? "latest" : null, + hash, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); + + ClawHubResolveResponse.VersionInfo matchVersion = resolved.version() != null + ? new ClawHubResolveResponse.VersionInfo(resolved.version()) + : null; + ClawHubResolveResponse.VersionInfo latestVersion = resolved.version() != null + ? new ClawHubResolveResponse.VersionInfo(resolved.version()) + : null; + + return new ClawHubResolveResponse(matchVersion, latestVersion); } @GetMapping("/resolve/{canonicalSlug}") @@ -95,11 +176,15 @@ public class ClawHubCompatController { userId, userNsRoles != null ? userNsRoles : Map.of() ); - return new ClawHubResolveResponse( - canonicalSlug, - resolved.version(), - resolved.downloadUrl() - ); + + ClawHubResolveResponse.VersionInfo matchVersion = resolved.version() != null + ? new ClawHubResolveResponse.VersionInfo(resolved.version()) + : null; + ClawHubResolveResponse.VersionInfo latestVersion = resolved.version() != null + ? new ClawHubResolveResponse.VersionInfo(resolved.version()) + : null; + + return new ClawHubResolveResponse(matchVersion, latestVersion); } @GetMapping("/download/{canonicalSlug}") @@ -114,6 +199,171 @@ public class ClawHubCompatController { .build(); } + @GetMapping("/skills") + public ClawHubSearchResponse listSkills( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "25") int limit, + @RequestParam(required = false) String sort, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + // Use search with empty query to list skills + String sortBy = sort != null ? sort : "newest"; + SkillSearchAppService.SearchResponse response = skillSearchAppService.search( + "", + null, + sortBy, + page, + limit, + userId, + userNsRoles + ); + + List results = response.items().stream() + .map(this::toSearchResult) + .toList(); + + return new ClawHubSearchResponse(results); + } + + @GetMapping("/skills/{canonicalSlug}") + public ClawHubSkillResponse getSkill( + @PathVariable String canonicalSlug, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + + Namespace ns = namespaceRepository.findBySlug(coord.namespace()) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace())); + Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug()) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", coord.slug())); + + SkillVersion latestVersionEntity = null; + if (skill.getLatestVersionId() != null) { + latestVersionEntity = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); + } + + ClawHubSkillResponse.SkillInfo skillInfo = null; + ClawHubSkillResponse.VersionInfo versionInfo = null; + + if (skill.getId() != null) { + long createdAt = skill.getCreatedAt() != null + ? skill.getCreatedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + : 0; + long updatedAt = skill.getUpdatedAt() != null + ? skill.getUpdatedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + : 0; + skillInfo = new ClawHubSkillResponse.SkillInfo( + mapper.toCanonical(coord.namespace(), coord.slug()), + skill.getDisplayName(), + skill.getSummary(), + Map.of(), // tags + Map.of(), // stats + createdAt, + updatedAt + ); + + if (latestVersionEntity != null) { + long versionCreatedAt = latestVersionEntity.getPublishedAt() != null + ? latestVersionEntity.getPublishedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + : 0; + versionInfo = new ClawHubSkillResponse.VersionInfo( + latestVersionEntity.getVersion(), + versionCreatedAt, + latestVersionEntity.getChangelog(), + null // license + ); + } + } + + // Owner info - we don't have this readily available, return null + ClawHubSkillResponse.OwnerInfo ownerInfo = null; + + // Moderation info - not implemented yet + ClawHubSkillResponse.ModerationInfo moderationInfo = new ClawHubSkillResponse.ModerationInfo( + false, false, "clean", new String[0], null, null, null + ); + + return new ClawHubSkillResponse(skillInfo, versionInfo, ownerInfo, moderationInfo); + } + + @DeleteMapping("/skills/{canonicalSlug}") + public ClawHubDeleteResponse deleteSkill( + @PathVariable String canonicalSlug, + @AuthenticationPrincipal PlatformPrincipal principal) { + // Note: Full delete not implemented yet, just return ok for compatibility + return new ClawHubDeleteResponse(); + } + + @PostMapping("/skills/{canonicalSlug}/undelete") + public ClawHubDeleteResponse undeleteSkill( + @PathVariable String canonicalSlug, + @AuthenticationPrincipal PlatformPrincipal principal) { + // Note: Undelete not implemented yet, just return ok for compatibility + return new ClawHubDeleteResponse(); + } + + @PostMapping("/stars/{canonicalSlug}") + public ClawHubStarResponse starSkill( + @PathVariable String canonicalSlug, + @AuthenticationPrincipal PlatformPrincipal principal) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + Namespace ns = namespaceRepository.findBySlug(coord.namespace()) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace())); + Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug()) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", canonicalSlug)); + + boolean alreadyStarred = skillStarService.isStarred(skill.getId(), principal.userId()); + skillStarService.star(skill.getId(), principal.userId()); + + return new ClawHubStarResponse(true, alreadyStarred); + } + + @DeleteMapping("/stars/{canonicalSlug}") + public ClawHubUnstarResponse unstarSkill( + @PathVariable String canonicalSlug, + @AuthenticationPrincipal PlatformPrincipal principal) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + Namespace ns = namespaceRepository.findBySlug(coord.namespace()) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace())); + Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug()) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", canonicalSlug)); + + boolean alreadyUnstarred = !skillStarService.isStarred(skill.getId(), principal.userId()); + skillStarService.unstar(skill.getId(), principal.userId()); + + return new ClawHubUnstarResponse(true, alreadyUnstarred); + } + + @PostMapping("/skills") + public ClawHubPublishResponse publishSkill(@RequestParam("payload") String payloadJson, + @RequestParam("files") MultipartFile[] files, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) throws IOException { + MultipartPackageExtractor.ExtractedPackage extracted = multipartPackageExtractor.extract(files, payloadJson); + String namespace = determineNamespace(principal, extracted.payload()); + SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( + namespace, + extracted.entries(), + principal.userId(), + SkillVisibility.PUBLIC, + principal.platformRoles() + ); + auditLogService.record( + principal.userId(), + "COMPAT_PUBLISH", + "SKILL_VERSION", + result.version().getId(), + MDC.get("requestId"), + request.getRemoteAddr(), + request.getHeader("User-Agent"), + "{\"namespace\":\"" + namespace + "\",\"slug\":\"" + extracted.payload().slug() + "\"}" + ); + return new ClawHubPublishResponse( + result.skillId().toString(), + result.version().getId().toString() + ); + } + @PostMapping("/publish") public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file, @RequestParam("namespace") String namespace, @@ -137,18 +387,22 @@ public class ClawHubCompatController { "{\"namespace\":\"" + namespace + "\"}" ); return new ClawHubPublishResponse( - mapper.toCanonical(namespace, result.slug()), - result.version().getVersion(), - result.version().getStatus().name() + result.skillId().toString(), + result.version().getId().toString() ); } + private String determineNamespace(PlatformPrincipal principal, MultipartPackageExtractor.PublishPayload payload) { + // Use "global" namespace by default for compatibility + return "global"; + } + @GetMapping("/whoami") public ClawHubWhoamiResponse whoami(@AuthenticationPrincipal PlatformPrincipal principal) { return new ClawHubWhoamiResponse( principal.userId(), principal.displayName(), - principal.email() + principal.avatarUrl() ); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryController.java deleted file mode 100644 index c3a55512..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryController.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.iflytek.skillhub.compat; - -import com.iflytek.skillhub.compat.dto.ClawHubRegistrySearchResponse; -import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkillResponse; -import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import java.util.Map; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestAttribute; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/v1") -public class ClawHubRegistryController { - - private final ClawHubRegistryFacade facade; - - public ClawHubRegistryController(ClawHubRegistryFacade facade) { - this.facade = facade; - } - - @GetMapping("/search") - public ClawHubRegistrySearchResponse search( - @RequestParam String q, - @RequestParam(defaultValue = "20") int limit, - @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - return facade.search(q, limit, userId, userNsRoles); - } - - @GetMapping("/skills/{slug}") - public ClawHubRegistrySkillResponse getSkill( - @org.springframework.web.bind.annotation.PathVariable String slug, - @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - return facade.getSkill(slug, userId, userNsRoles); - } - - @GetMapping("/download") - public ResponseEntity download( - @RequestParam String slug, - @RequestParam(required = false) String version, - @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - String location = facade.resolveDownloadUrl(slug, version, userId, userNsRoles); - return ResponseEntity.status(HttpStatus.FOUND) - .header(HttpHeaders.LOCATION, location) - .build(); - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java index 0737ebd5..4ddfb79d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java @@ -10,6 +10,6 @@ public class WellKnownController { @GetMapping("/.well-known/clawhub.json") public Map clawhubConfig() { - return Map.of("apiBase", "/api/compat/v1"); + return Map.of("apiBase", "/api/v1"); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubDeleteResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubDeleteResponse.java new file mode 100644 index 00000000..b673ec10 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubDeleteResponse.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubDeleteResponse( + boolean ok +) { + public ClawHubDeleteResponse() { + this(true); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java index b117ebd9..ff82b79b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java @@ -1,7 +1,11 @@ package com.iflytek.skillhub.compat.dto; public record ClawHubPublishResponse( - String canonicalSlug, - String version, - String status -) {} + boolean ok, + String skillId, + String versionId +) { + public ClawHubPublishResponse(String skillId, String versionId) { + this(true, skillId, versionId); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java index 79427437..5ba9d35c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java @@ -1,7 +1,10 @@ package com.iflytek.skillhub.compat.dto; public record ClawHubResolveResponse( - String canonicalSlug, - String version, - String downloadUrl -) {} + VersionInfo match, + VersionInfo latestVersion +) { + public record VersionInfo( + String version + ) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java index ee03acce..a6b49ebd 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java @@ -2,4 +2,15 @@ package com.iflytek.skillhub.compat.dto; import java.util.List; -public record ClawHubSearchResponse(List items) {} +public record ClawHubSearchResponse( + List results +) { + public record ClawHubSearchResult( + String slug, + String displayName, + String summary, + String version, + double score, + Long updatedAt + ) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java deleted file mode 100644 index 3211adbc..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.iflytek.skillhub.compat.dto; - -public record ClawHubSkillItem( - String canonicalSlug, - String description, - String latestVersion, - int starCount -) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillListResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillListResponse.java new file mode 100644 index 00000000..d1f7f376 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillListResponse.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.compat.dto; + +import java.util.List; + +public record ClawHubSkillListResponse( + List items, + String nextCursor +) { + public record SkillListItem( + String slug, + String displayName, + String summary, + Object tags, + Object stats, + long createdAt, + long updatedAt, + LatestVersion latestVersion + ) { + public record LatestVersion( + String version, + long createdAt, + String changelog, + String license + ) {} + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillResponse.java new file mode 100644 index 00000000..6f9fa154 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillResponse.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.compat.dto; + +import java.time.ZoneOffset; + +public record ClawHubSkillResponse( + SkillInfo skill, + VersionInfo latestVersion, + OwnerInfo owner, + ModerationInfo moderation +) { + public record SkillInfo( + String slug, + String displayName, + String summary, + Object tags, + Object stats, + long createdAt, + long updatedAt + ) {} + + public record VersionInfo( + String version, + long createdAt, + String changelog, + String license + ) {} + + public record OwnerInfo( + String handle, + String displayName, + String image + ) {} + + public record ModerationInfo( + boolean isSuspicious, + boolean isMalwareBlocked, + String verdict, + String[] reasonCodes, + Long updatedAt, + String engineVersion, + String summary + ) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubStarResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubStarResponse.java new file mode 100644 index 00000000..6c56e45d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubStarResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubStarResponse( + boolean ok, + boolean starred, + boolean alreadyStarred +) { + public ClawHubStarResponse(boolean starred, boolean alreadyStarred) { + this(true, starred, alreadyStarred); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubUnstarResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubUnstarResponse.java new file mode 100644 index 00000000..50ce20fe --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubUnstarResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubUnstarResponse( + boolean ok, + boolean unstarred, + boolean alreadyUnstarred +) { + public ClawHubUnstarResponse(boolean unstarred, boolean alreadyUnstarred) { + this(true, unstarred, alreadyUnstarred); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java index f2667ce1..99d8fa63 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java @@ -1,7 +1,15 @@ package com.iflytek.skillhub.compat.dto; public record ClawHubWhoamiResponse( - String userId, - String displayName, - String email -) {} + User user +) { + public ClawHubWhoamiResponse(String handle, String displayName, String image) { + this(new User(handle, displayName, image)); + } + + public record User( + String handle, + String displayName, + String image + ) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java deleted file mode 100644 index e24948c2..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.iflytek.skillhub.controller; - -import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; -import com.iflytek.skillhub.domain.skill.validation.ValidationResult; -import com.iflytek.skillhub.domain.skill.service.SkillQueryService; -import com.iflytek.skillhub.dto.ApiResponse; -import com.iflytek.skillhub.dto.ApiResponseFactory; -import com.iflytek.skillhub.dto.CliWhoamiResponse; -import com.iflytek.skillhub.dto.ResolveVersionResponse; -import com.iflytek.skillhub.dto.SkillCheckResponse; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import com.iflytek.skillhub.exception.UnauthorizedException; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.io.IOException; -import java.util.List; -import java.util.Map; - -@RestController -@RequestMapping("/api/v1") -public class CliController extends BaseApiController { - - private final SkillPackageValidator skillPackageValidator; - private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; - private final SkillQueryService skillQueryService; - - public CliController(ApiResponseFactory responseFactory, - SkillPackageValidator skillPackageValidator, - SkillPackageArchiveExtractor skillPackageArchiveExtractor, - SkillQueryService skillQueryService) { - super(responseFactory); - this.skillPackageValidator = skillPackageValidator; - this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; - this.skillQueryService = skillQueryService; - } - - @GetMapping("/whoami") - public ApiResponse whoami(@AuthenticationPrincipal PlatformPrincipal principal) { - if (principal == null) { - throw new UnauthorizedException("error.auth.required"); - } - - return ok("response.success.read", CliWhoamiResponse.from(principal)); - } - - @PostMapping("/check") - public ApiResponse check(@RequestParam("file") MultipartFile file) throws IOException { - List entries; - try { - entries = skillPackageArchiveExtractor.extract(file); - } catch (IllegalArgumentException e) { - SkillCheckResponse response = new SkillCheckResponse( - false, - List.of(e.getMessage()), - 0, - 0L - ); - return ok("response.success.validated", response); - } - ValidationResult result = skillPackageValidator.validate(entries); - - SkillCheckResponse response = new SkillCheckResponse( - result.passed(), - result.errors(), - entries.size(), - entries.stream().mapToLong(PackageEntry::size).sum() - ); - - return ok("response.success.validated", response); - } - - @GetMapping("/resolve/{namespace}/{slug}") - public ApiResponse resolve(@PathVariable String namespace, - @PathVariable String slug, - @RequestParam(required = false) String version, - @RequestParam(required = false) String tag, - @RequestParam(required = false) String hash, - @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( - namespace, - slug, - version, - tag, - hash, - userId, - userNsRoles != null ? userNsRoles : Map.of() - ); - return ok("response.success.read", new ResolveVersionResponse( - resolved.skillId(), - resolved.namespace(), - resolved.slug(), - resolved.version(), - resolved.versionId(), - resolved.fingerprint(), - resolved.matched(), - resolved.downloadUrl() - )); - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java deleted file mode 100644 index cf915326..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.iflytek.skillhub.controller.cli; - -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import com.iflytek.skillhub.controller.BaseApiController; -import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; -import com.iflytek.skillhub.domain.audit.AuditLogService; -import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; -import com.iflytek.skillhub.domain.skill.SkillVisibility; -import com.iflytek.skillhub.domain.skill.service.SkillPublishService; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import com.iflytek.skillhub.dto.ApiResponse; -import com.iflytek.skillhub.dto.ApiResponseFactory; -import com.iflytek.skillhub.dto.PublishResponse; -import com.iflytek.skillhub.metrics.SkillHubMetrics; -import com.iflytek.skillhub.ratelimit.RateLimit; -import jakarta.servlet.http.HttpServletRequest; -import org.slf4j.MDC; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.io.IOException; -import java.util.List; - -@RestController -@RequestMapping("/api/v1") -public class CliPublishController extends BaseApiController { - - private final SkillPublishService skillPublishService; - private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; - private final SkillHubMetrics skillHubMetrics; - private final AuditLogService auditLogService; - - public CliPublishController(SkillPublishService skillPublishService, - SkillPackageArchiveExtractor skillPackageArchiveExtractor, - ApiResponseFactory responseFactory, - SkillHubMetrics skillHubMetrics, - AuditLogService auditLogService) { - super(responseFactory); - this.skillPublishService = skillPublishService; - this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; - this.skillHubMetrics = skillHubMetrics; - this.auditLogService = auditLogService; - } - - @PostMapping("/publish") - @RateLimit(category = "publish", authenticated = 10, anonymous = 0) - public ApiResponse publish( - @RequestParam("file") MultipartFile file, - @RequestParam("namespace") String namespace, - @RequestParam("visibility") String visibility, - @AuthenticationPrincipal PlatformPrincipal principal, - HttpServletRequest request) throws IOException { - - SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); - - List entries; - try { - entries = skillPackageArchiveExtractor.extract(file); - } catch (IllegalArgumentException e) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", e.getMessage()); - } - - SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( - namespace, - entries, - principal.userId(), - skillVisibility, - principal.platformRoles() - ); - - PublishResponse response = new PublishResponse( - publishResult.skillId(), - namespace, - publishResult.slug(), - publishResult.version().getVersion(), - publishResult.version().getStatus().name(), - publishResult.version().getFileCount(), - publishResult.version().getTotalSize() - ); - skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name()); - auditLogService.record( - principal.userId(), - "CLI_PUBLISH", - "SKILL_VERSION", - publishResult.version().getId(), - MDC.get("requestId"), - request.getRemoteAddr(), - request.getHeader("User-Agent"), - "{\"namespace\":\"" + namespace + "\"}" - ); - - return ok("response.success.published", response); - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java new file mode 100644 index 00000000..d7edec9b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -0,0 +1,119 @@ +package com.iflytek.skillhub.controller.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component +public class MultipartPackageExtractor { + + private final SkillPublishProperties properties; + private final ObjectMapper objectMapper; + + public MultipartPackageExtractor(SkillPublishProperties properties, ObjectMapper objectMapper) { + this.properties = properties; + this.objectMapper = objectMapper; + } + + public record PublishPayload( + String slug, + String displayName, + String version, + String changelog, + Boolean acceptLicenseTerms, + List tags, + ForkOf forkOf + ) { + public record ForkOf(String slug, String version) {} + } + + public record ExtractedPackage(PublishPayload payload, List entries) {} + + public ExtractedPackage extract(MultipartFile[] files, String payloadJson) throws IOException { + PublishPayload payload = objectMapper.readValue(payloadJson, PublishPayload.class); + + List entries = new ArrayList<>(); + Set seenPaths = new HashSet<>(); + long totalSize = 0L; + + if (files != null) { + for (MultipartFile file : files) { + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || originalFilename.isBlank()) { + continue; + } + + if (entries.size() >= properties.getMaxFileCount()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Too many files: max " + properties.getMaxFileCount()); + } + + String normalizedPath = normalizePath(originalFilename); + if (!seenPaths.add(normalizedPath)) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Duplicate package path: " + normalizedPath); + } + + byte[] content = file.getBytes(); + totalSize += content.length; + if (totalSize > properties.getMaxPackageSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Package too large: max " + properties.getMaxPackageSize() + " bytes"); + } + + if (content.length > properties.getMaxSingleFileSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "File too large: " + normalizedPath + " (max " + properties.getMaxSingleFileSize() + " bytes)"); + } + + entries.add(new PackageEntry( + normalizedPath, + content, + content.length, + determineContentType(normalizedPath) + )); + } + } + + return new ExtractedPackage(payload, entries); + } + + private String normalizePath(String path) { + if (path == null || path.isBlank()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", "Package entry path is blank"); + } + if (path.contains("\\")) { + path = path.replace("\\", "/"); + } + + // Remove leading ./ + while (path.startsWith("./")) { + path = path.substring(2); + } + + if (path.isBlank() || path.startsWith("../") || path.equals("..") || path.startsWith("/") || path.contains("//")) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Unsafe package path: " + path); + } + return path; + } + + private String determineContentType(String filename) { + if (filename.endsWith(".py")) return "text/x-python"; + if (filename.endsWith(".json")) return "application/json"; + if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; + if (filename.endsWith(".txt")) return "text/plain"; + if (filename.endsWith(".md")) return "text/markdown"; + return "application/octet-stream"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java new file mode 100644 index 00000000..db1e5572 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -0,0 +1,90 @@ +package com.iflytek.skillhub.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class RequestLoggingFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + ContentCachingRequestWrapper cachedRequest = new ContentCachingRequestWrapper(request); + ContentCachingResponseWrapper cachedResponse = new ContentCachingResponseWrapper(response); + + long startTime = System.currentTimeMillis(); + + try { + filterChain.doFilter(cachedRequest, cachedResponse); + } finally { + long duration = System.currentTimeMillis() - startTime; + logRequest(cachedRequest, cachedResponse, duration); + cachedResponse.copyBodyToResponse(); + } + } + + private void logRequest(ContentCachingRequestWrapper request, ContentCachingResponseWrapper response, long duration) { + String requestUri = request.getRequestURI(); + String queryString = request.getQueryString(); + String fullUrl = queryString != null ? requestUri + "?" + queryString : requestUri; + + StringBuilder sb = new StringBuilder(); + sb.append("\n========== HTTP Request ==========\n"); + sb.append("URL: ").append(request.getMethod()).append(" ").append(fullUrl).append("\n"); + sb.append("Remote Address: ").append(request.getRemoteAddr()).append("\n"); + sb.append("Headers: ").append(getHeaders(request)).append("\n"); + + String requestBody = getRequestBody(request); + if (requestBody != null && !requestBody.isBlank()) { + sb.append("Request Body: ").append(requestBody).append("\n"); + } + + sb.append("Response Status: ").append(response.getStatus()).append("\n"); + sb.append("Duration: ").append(duration).append("ms\n"); + sb.append("==================================="); + + log.info(sb.toString()); + } + + private Map getHeaders(HttpServletRequest request) { + Map headers = new HashMap<>(); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + headers.put(headerName, request.getHeader(headerName)); + } + return headers; + } + + private String getRequestBody(ContentCachingRequestWrapper request) { + byte[] buf = request.getContentAsByteArray(); + if (buf.length > 0) { + try { + return new String(buf, request.getCharacterEncoding()); + } catch (UnsupportedEncodingException e) { + return "[unknown encoding]"; + } + } + return null; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java deleted file mode 100644 index 3738a23e..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java +++ /dev/null @@ -1,222 +0,0 @@ -package com.iflytek.skillhub.compat; - -import com.iflytek.skillhub.auth.device.DeviceAuthService; -import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; -import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.skill.Skill; -import com.iflytek.skillhub.domain.skill.SkillRepository; -import com.iflytek.skillhub.domain.skill.SkillVersion; -import com.iflytek.skillhub.domain.skill.SkillVersionRepository; -import com.iflytek.skillhub.domain.skill.SkillVisibility; -import com.iflytek.skillhub.domain.skill.service.SkillQueryService; -import com.iflytek.skillhub.domain.user.UserAccount; -import com.iflytek.skillhub.domain.user.UserAccountRepository; -import com.iflytek.skillhub.dto.SkillSummaryResponse; -import com.iflytek.skillhub.service.SkillSearchAppService; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.test.web.servlet.MockMvc; - -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest -@AutoConfigureMockMvc -@ActiveProfiles("test") -class ClawHubRegistryControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockBean - private NamespaceMemberRepository namespaceMemberRepository; - - @MockBean - private DeviceAuthService deviceAuthService; - - @MockBean - private SkillSearchAppService skillSearchAppService; - - @MockBean - private SkillQueryService skillQueryService; - - @MockBean - private SkillRepository skillRepository; - - @MockBean - private SkillVersionRepository skillVersionRepository; - - @MockBean - private UserAccountRepository userAccountRepository; - - @Test - void search_returns_clawhub_registry_schema() throws Exception { - LocalDateTime updatedAt = LocalDateTime.of(2026, 3, 13, 10, 30); - given(skillSearchAppService.search("test", null, "relevance", 0, 2, null, Map.of())) - .willReturn(new SkillSearchAppService.SearchResponse( - List.of( - new SkillSummaryResponse( - 1L, - "global-skill", - "Global Skill", - "global summary", - 10L, - 5, - BigDecimal.ZERO, - 0, - "1.2.0", - "PUBLISHED", - "global", - updatedAt - ), - new SkillSummaryResponse( - 2L, - "team-skill", - "Team Skill", - "team summary", - 20L, - 8, - BigDecimal.ONE, - 2, - "2.0.0", - "PUBLISHED", - "team-ai", - updatedAt.plusHours(1) - ) - ), - 2, - 0, - 2 - )); - - mockMvc.perform(get("/api/v1/search") - .param("q", "test") - .param("limit", "2")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.results").isArray()) - .andExpect(jsonPath("$.results[0].slug").value("global-skill")) - .andExpect(jsonPath("$.results[0].displayName").value("Global Skill")) - .andExpect(jsonPath("$.results[0].version").value("1.2.0")) - .andExpect(jsonPath("$.results[0].score").value(1.0)) - .andExpect(jsonPath("$.results[0].updatedAt").value(toEpochMillis(updatedAt))) - .andExpect(jsonPath("$.results[1].slug").value("team-ai--team-skill")) - .andExpect(jsonPath("$.results[1].displayName").value("Team Skill")) - .andExpect(jsonPath("$.results[1].version").value("2.0.0")) - .andExpect(jsonPath("$.results[1].score").value(0.999)) - .andExpect(jsonPath("$.results[1].updatedAt").value(toEpochMillis(updatedAt.plusHours(1)))); - } - - @Test - void get_skill_returns_clawhub_install_metadata() throws Exception { - LocalDateTime skillCreatedAt = LocalDateTime.of(2026, 1, 1, 9, 0); - LocalDateTime skillUpdatedAt = LocalDateTime.of(2026, 3, 10, 18, 30); - LocalDateTime versionPublishedAt = LocalDateTime.of(2026, 3, 12, 12, 0); - - given(skillQueryService.getSkillDetail( - eq("global"), - eq("global-skill"), - isNull(), - eq(Map.of()))) - .willReturn(new SkillQueryService.SkillDetailDTO( - 1L, - "global-skill", - "Global Skill", - "global summary", - "PUBLIC", - "ACTIVE", - 10L, - 5, - BigDecimal.ZERO, - 0, - false, - "1.2.0", - 1L - )); - - Skill skill = new Skill(1L, "global-skill", "owner-1", SkillVisibility.PUBLIC); - skill.setDisplayName("Global Skill"); - skill.setSummary("global summary"); - ReflectionTestUtils.setField(skill, "id", 1L); - ReflectionTestUtils.setField(skill, "createdAt", skillCreatedAt); - ReflectionTestUtils.setField(skill, "updatedAt", skillUpdatedAt); - - SkillVersion version = new SkillVersion(1L, "1.2.0", "owner-1"); - version.setChangelog("Initial release"); - version.setPublishedAt(versionPublishedAt); - ReflectionTestUtils.setField(version, "id", 11L); - ReflectionTestUtils.setField(version, "createdAt", versionPublishedAt.minusHours(2)); - - given(skillRepository.findById(1L)).willReturn(java.util.Optional.of(skill)); - given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.0")).willReturn(java.util.Optional.of(version)); - given(userAccountRepository.findById("owner-1")) - .willReturn(java.util.Optional.of(new UserAccount( - "owner-1", - "Skill Owner", - "owner@example.com", - "https://example.com/avatar.png" - ))); - - mockMvc.perform(get("/api/v1/skills/global-skill")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.skill.slug").value("global-skill")) - .andExpect(jsonPath("$.skill.displayName").value("Global Skill")) - .andExpect(jsonPath("$.skill.summary").value("global summary")) - .andExpect(jsonPath("$.skill.createdAt").value(toEpochMillis(skillCreatedAt))) - .andExpect(jsonPath("$.skill.updatedAt").value(toEpochMillis(skillUpdatedAt))) - .andExpect(jsonPath("$.latestVersion.version").value("1.2.0")) - .andExpect(jsonPath("$.latestVersion.createdAt").value(toEpochMillis(versionPublishedAt))) - .andExpect(jsonPath("$.latestVersion.changelog").value("Initial release")) - .andExpect(jsonPath("$.owner.displayName").value("Skill Owner")) - .andExpect(jsonPath("$.owner.image").value("https://example.com/avatar.png")) - .andExpect(jsonPath("$.moderation.isSuspicious").value(false)) - .andExpect(jsonPath("$.moderation.isMalwareBlocked").value(false)) - .andExpect(jsonPath("$.moderation.verdict").value("clean")); - } - - @Test - void download_redirects_to_versioned_skill_url() throws Exception { - given(skillQueryService.resolveVersion( - eq("team-ai"), - eq("team-skill"), - eq("2.0.0"), - isNull(), - isNull(), - isNull(), - eq(Map.of()))) - .willReturn(new SkillQueryService.ResolvedVersionDTO( - 2L, - "team-ai", - "team-skill", - "2.0.0", - 21L, - "sha256:test", - true, - "/api/v1/skills/team-ai/team-skill/versions/2.0.0/download" - )); - - mockMvc.perform(get("/api/v1/download") - .param("slug", "team-ai--team-skill") - .param("version", "2.0.0")) - .andExpect(status().isFound()) - .andExpect(header().string("Location", "/api/v1/skills/team-ai/team-skill/versions/2.0.0/download")); - } - - private long toEpochMillis(LocalDateTime timestamp) { - return timestamp.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); - } -} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java index c58b2af7..d5477d00 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java @@ -22,6 +22,7 @@ public class ApiTokenScopeService { ScopeRule.allow(null, "/api/v1/auth/device/**"), ScopeRule.allow(null, "/api/v1/check"), ScopeRule.allow("GET", "/api/v1/whoami"), + ScopeRule.allow("GET", "/api/v1/search"), ScopeRule.allow("GET", "/api/v1/skills"), ScopeRule.allow("GET", "/api/v1/skills/**"), ScopeRule.allow("GET", "/api/web/skills"), @@ -42,6 +43,7 @@ public class ApiTokenScopeService { private static final List REQUIRED_SCOPE_RULES = List.of( ScopeRule.require(null, "/api/v1/tokens", "token:manage"), ScopeRule.require(null, "/api/v1/tokens/**", "token:manage"), + ScopeRule.require("POST", "/api/v1/skills", "skill:publish"), ScopeRule.require("POST", "/api/v1/skills/*/publish", "skill:publish"), ScopeRule.require("POST", "/api/web/skills/*/publish", "skill:publish"), ScopeRule.require("POST", "/api/v1/publish", "skill:publish"), 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 93816ba1..7f2195d4 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 @@ -12,4 +12,6 @@ public interface SkillRepository { Skill save(Skill skill); List findByOwnerId(String ownerId); void incrementDownloadCount(Long skillId); + List findBySlug(String slug); + Optional findByNamespaceSlugAndSlug(String namespaceSlug, String slug); } 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 e4953ebf..4abdb8aa 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 @@ -65,7 +65,10 @@ public class SkillQueryService { Integer ratingCount, boolean hidden, String latestVersion, - Long namespaceId + Long namespaceId, + java.time.LocalDateTime createdAt, + java.time.LocalDateTime updatedAt, + SkillVersion latestVersionEntity ) {} public record SkillVersionDetailDTO( @@ -107,10 +110,11 @@ public class SkillQueryService { } String latestVersion = null; + SkillVersion latestVersionEntity = null; if (skill.getLatestVersionId() != null) { - SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); - if (version != null) { - latestVersion = version.getVersion(); + latestVersionEntity = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); + if (latestVersionEntity != null) { + latestVersion = latestVersionEntity.getVersion(); } } @@ -127,7 +131,10 @@ public class SkillQueryService { skill.getRatingCount(), skill.isHidden(), latestVersion, - skill.getNamespaceId() + skill.getNamespaceId(), + skill.getCreatedAt(), + skill.getUpdatedAt(), + latestVersionEntity ); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java index 887d2476..ceac7c53 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -61,4 +61,14 @@ public class JpaSkillRepositoryAdapter implements SkillRepository { public void incrementDownloadCount(Long skillId) { delegate.incrementDownloadCount(skillId); } + + @Override + public List findBySlug(String slug) { + return delegate.findBySlug(slug); + } + + @Override + public Optional findByNamespaceSlugAndSlug(String namespaceSlug, String slug) { + return delegate.findByNamespaceSlugAndSlug(namespaceSlug, slug); + } } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java index 0a871c2e..6cba15a8 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java @@ -33,4 +33,9 @@ public interface SkillJpaRepository extends JpaRepository, SkillRep @Transactional @Query("UPDATE Skill s SET s.downloadCount = s.downloadCount + 1 WHERE s.id = :skillId") void incrementDownloadCount(@Param("skillId") Long skillId); + + List findBySlug(String slug); + + @Query("SELECT s FROM Skill s JOIN Namespace n ON s.namespaceId = n.id WHERE n.slug = :namespaceSlug AND s.slug = :slug") + Optional findByNamespaceSlugAndSlug(@Param("namespaceSlug") String namespaceSlug, @Param("slug") String slug); }