mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-10 03:27:54 +00:00
commit
583bf5ba42
23 changed files with 653 additions and 522 deletions
|
|
@ -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<ClawHubSkillItem> items = response.items().stream()
|
||||
.map(item -> new ClawHubSkillItem(
|
||||
mapper.toCanonical(item.namespace(), item.slug()),
|
||||
item.summary(),
|
||||
item.latestVersion(),
|
||||
item.starCount()))
|
||||
List<ClawHubSearchResponse.ClawHubSearchResult> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<ClawHubSearchResponse.ClawHubSearchResult> 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<Long, NamespaceRole> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles) {
|
||||
return facade.getSkill(slug, userId, userNsRoles);
|
||||
}
|
||||
|
||||
@GetMapping("/download")
|
||||
public ResponseEntity<Void> download(
|
||||
@RequestParam String slug,
|
||||
@RequestParam(required = false) String version,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
String location = facade.resolveDownloadUrl(slug, version, userId, userNsRoles);
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header(HttpHeaders.LOCATION, location)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,6 @@ public class WellKnownController {
|
|||
|
||||
@GetMapping("/.well-known/clawhub.json")
|
||||
public Map<String, String> clawhubConfig() {
|
||||
return Map.of("apiBase", "/api/compat/v1");
|
||||
return Map.of("apiBase", "/api/v1");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
public record ClawHubDeleteResponse(
|
||||
boolean ok
|
||||
) {
|
||||
public ClawHubDeleteResponse() {
|
||||
this(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,15 @@ package com.iflytek.skillhub.compat.dto;
|
|||
|
||||
import java.util.List;
|
||||
|
||||
public record ClawHubSearchResponse(List<ClawHubSkillItem> items) {}
|
||||
public record ClawHubSearchResponse(
|
||||
List<ClawHubSearchResult> results
|
||||
) {
|
||||
public record ClawHubSearchResult(
|
||||
String slug,
|
||||
String displayName,
|
||||
String summary,
|
||||
String version,
|
||||
double score,
|
||||
Long updatedAt
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
public record ClawHubSkillItem(
|
||||
String canonicalSlug,
|
||||
String description,
|
||||
String latestVersion,
|
||||
int starCount
|
||||
) {}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ClawHubSkillListResponse(
|
||||
List<SkillListItem> 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
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CliWhoamiResponse> 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<SkillCheckResponse> check(@RequestParam("file") MultipartFile file) throws IOException {
|
||||
List<PackageEntry> 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<ResolveVersionResponse> 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<Long, NamespaceRole> 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()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PublishResponse> 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<PackageEntry> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> tags,
|
||||
ForkOf forkOf
|
||||
) {
|
||||
public record ForkOf(String slug, String version) {}
|
||||
}
|
||||
|
||||
public record ExtractedPackage(PublishPayload payload, List<PackageEntry> entries) {}
|
||||
|
||||
public ExtractedPackage extract(MultipartFile[] files, String payloadJson) throws IOException {
|
||||
PublishPayload payload = objectMapper.readValue(payloadJson, PublishPayload.class);
|
||||
|
||||
List<PackageEntry> entries = new ArrayList<>();
|
||||
Set<String> 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> getHeaders(HttpServletRequest request) {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
Enumeration<String> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.<Long, NamespaceRole>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.<Long, NamespaceRole>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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ScopeRule> 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"),
|
||||
|
|
|
|||
|
|
@ -12,4 +12,6 @@ public interface SkillRepository {
|
|||
Skill save(Skill skill);
|
||||
List<Skill> findByOwnerId(String ownerId);
|
||||
void incrementDownloadCount(Long skillId);
|
||||
List<Skill> findBySlug(String slug);
|
||||
Optional<Skill> findByNamespaceSlugAndSlug(String namespaceSlug, String slug);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,4 +61,14 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
|
|||
public void incrementDownloadCount(Long skillId) {
|
||||
delegate.incrementDownloadCount(skillId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Skill> findBySlug(String slug) {
|
||||
return delegate.findBySlug(slug);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Skill> findByNamespaceSlugAndSlug(String namespaceSlug, String slug) {
|
||||
return delegate.findByNamespaceSlugAndSlug(namespaceSlug, slug);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,4 +33,9 @@ public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRep
|
|||
@Transactional
|
||||
@Query("UPDATE Skill s SET s.downloadCount = s.downloadCount + 1 WHERE s.id = :skillId")
|
||||
void incrementDownloadCount(@Param("skillId") Long skillId);
|
||||
|
||||
List<Skill> 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<Skill> findByNamespaceSlugAndSlug(@Param("namespaceSlug") String namespaceSlug, @Param("slug") String slug);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue