This commit is contained in:
tww 2026-03-15 16:02:54 +08:00
parent 19cbd08252
commit f38fea85b5
23 changed files with 653 additions and 522 deletions

View file

@ -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()
);
}
}

View file

@ -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();
}
}

View file

@ -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");
}
}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.compat.dto;
public record ClawHubDeleteResponse(
boolean ok
) {
public ClawHubDeleteResponse() {
this(true);
}
}

View file

@ -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);
}
}

View file

@ -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
) {}
}

View file

@ -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
) {}
}

View file

@ -1,8 +0,0 @@
package com.iflytek.skillhub.compat.dto;
public record ClawHubSkillItem(
String canonicalSlug,
String description,
String latestVersion,
int starCount
) {}

View file

@ -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
) {}
}
}

View file

@ -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
) {}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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
) {}
}

View file

@ -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()
));
}
}

View file

@ -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);
}
}

View file

@ -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";
}
}

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -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"),

View file

@ -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);
}

View file

@ -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
);
}

View file

@ -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);
}
}

View file

@ -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);
}