From 5cc595b0ff1e750cf664c91d02deeb60b7aa506c Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 18:26:50 +0800 Subject: [PATCH] feat(cli): add skill package check endpoint --- README.md | 95 +++++--------- .../skillhub/controller/CliController.java | 67 +++++++++- .../skillhub/dto/SkillCheckResponse.java | 10 ++ .../controller/CliControllerTest.java | 123 ++++++++++++++++++ .../src/test/resources/application-test.yml | 1 + .../skillhub/auth/config/SecurityConfig.java | 1 + 6 files changed, 234 insertions(+), 63 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillCheckResponse.java diff --git a/README.md b/README.md index ba69e7a0..15efd249 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,58 @@ # SkillHub -AI Skill 共享平台 — 发布、发现、管理 AI 技能包。 +An enterprise-grade AI skill registry — publish, discover, and +manage reusable skill packages across your organization. -## 快速开始 +SkillHub is a self-hosted platform that gives teams a private, +governed place to share AI skills. Publish a skill package, push +it to a namespace, and let others find it through search or +install it via CLI. Built for on-premise deployment behind your +firewall, with the same polish you'd expect from a public registry. -### 前置条件 +## Highlights + +- **Publish & Version** — Upload skill packages with semantic + versioning, custom tags (`beta`, `stable`), and automatic + `latest` tracking. +- **Discover** — Full-text search with filters by namespace, + downloads, ratings, and recency. Visibility rules ensure + users only see what they should. +- **Namespaces** — Organize skills under team or global scopes. + Each namespace has its own members, roles (Owner / Admin / + Member), and publishing policies. +- **Review Workflow** — Team admins review within their namespace; + platform admins gate promotions to the global scope. Every + action is audit-logged. +- **CLI-First** — Native REST API plus a compatibility layer for + existing ClawHub CLI tools — no client changes needed. +- **Pluggable Storage** — Local filesystem for development, S3 for + production. Swap via config. + +## Quick Start + +### Prerequisites - Docker & Docker Compose -- JDK 21+(开发模式) -- Node.js 20+ / pnpm(前端开发) -### 一键启动(生产模式) +### One command ```bash make prod-up ``` -访问: -- 前端: http://localhost -- API: http://localhost:8080 -- 健康检查: http://localhost:8080/actuator/health +Then open http://localhost in your browser. -### 开发模式 +### Development ```bash -# 启动基础设施(PostgreSQL + Redis + MinIO) +# Start infrastructure (PostgreSQL, Redis, MinIO) make dev -# 启动后端 +# Backend (in one terminal) make dev-server -# 启动前端(另一个终端) +# Frontend (in another terminal) make dev-web ``` -## 项目结构 - -``` -skillhub/ -├── server/ # Spring Boot 后端 -│ ├── skillhub-app/ # 应用层 (Controller, DTO, Config) -│ ├── skillhub-domain/ # 领域层 (Entity, Service, Repository) -│ ├── skillhub-auth/ # 认证授权模块 -│ ├── skillhub-search/ # 搜索模块 (PostgreSQL 全文检索) -│ ├── skillhub-storage/ # 存储模块 (本地/S3) -│ └── skillhub-infra/ # 基础设施层 (JPA 实现) -├── web/ # React 前端 -├── docker-compose.yml # 开发环境(仅基础设施) -├── docker-compose.prod.yml # 生产环境(全栈) -└── Makefile # 常用命令 -``` - -## 常用命令 - -```bash -make help # 查看所有命令 -make dev # 启动开发基础设施 -make dev-server # 启动后端开发服务器 -make dev-web # 启动前端开发服务器 -make test # 运行后端测试 -make test-web # 运行前端测试 -make build # 构建后端 -make build-web # 构建前端 -make prod-up # 一键启动全部服务 -make prod-down # 停止全部服务 -make logs # 查看后端日志 -make db-reset # 重置数据库 -make clean # 清理构建产物 -``` - -## 技术栈 - -- **后端:** Spring Boot 3, JDK 21, PostgreSQL 16, Redis 7, Flyway, Spring Security (OAuth2 + 本地认证) -- **前端:** React 19, TypeScript, Vite, TanStack Router/Query, shadcn/ui, Tailwind CSS -- **存储:** MinIO (S3 兼容) / 本地文件系统 -- **运维:** Docker, Nginx - -## License - -MIT +Run `make help` to see all available commands. diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java index 985c3790..cf0273ec 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java @@ -1,21 +1,34 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +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.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.CliWhoamiResponse; +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.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; @RestController @RequestMapping("/api/v1/cli") public class CliController extends BaseApiController { - public CliController(ApiResponseFactory responseFactory) { + private final SkillPackageValidator skillPackageValidator; + + public CliController(ApiResponseFactory responseFactory, + SkillPackageValidator skillPackageValidator) { super(responseFactory); + this.skillPackageValidator = skillPackageValidator; } @GetMapping("/whoami") @@ -26,4 +39,50 @@ public class CliController extends BaseApiController { return ok("response.success.read", CliWhoamiResponse.from(principal)); } + + @PostMapping("/check") + public ApiResponse check(@RequestParam("file") MultipartFile file) throws IOException { + List entries = extractZipEntries(file); + 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); + } + + private List extractZipEntries(MultipartFile file) throws IOException { + List entries = new ArrayList<>(); + + try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { + ZipEntry zipEntry; + while ((zipEntry = zis.getNextEntry()) != null) { + if (!zipEntry.isDirectory()) { + byte[] content = zis.readAllBytes(); + entries.add(new PackageEntry( + zipEntry.getName(), + content, + content.length, + determineContentType(zipEntry.getName()) + )); + } + zis.closeEntry(); + } + } + + return entries; + } + + private String determineContentType(String filename) { + if (filename.endsWith(".py")) return "text/x-python"; + if (filename.endsWith(".json")) return "application/json"; + if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; + if (filename.endsWith(".txt")) return "text/plain"; + if (filename.endsWith(".md")) return "text/markdown"; + return "application/octet-stream"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillCheckResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillCheckResponse.java new file mode 100644 index 00000000..2cf68fbc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillCheckResponse.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +public record SkillCheckResponse( + boolean valid, + List errors, + int fileCount, + long totalSize +) {} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java index 6e3d27d2..13a781b2 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.controller; +import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import org.junit.jupiter.api.Test; @@ -7,17 +8,22 @@ 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.mock.web.MockMultipartFile; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import java.io.ByteArrayOutputStream; import java.util.List; import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -32,6 +38,9 @@ class CliControllerTest { @MockBean private NamespaceMemberRepository namespaceMemberRepository; + @MockBean + private DeviceAuthService deviceAuthService; + @Test void whoamiShouldReturnUnauthorizedForAnonymousRequest() throws Exception { mockMvc.perform(get("/api/v1/cli/whoami")) @@ -68,4 +77,118 @@ class CliControllerTest { .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } + + @Test + void checkShouldReturnValidForValidPackage() throws Exception { + byte[] zipBytes = createValidSkillZip(); + MockMultipartFile file = new MockMultipartFile( + "file", + "skill.zip", + "application/zip", + zipBytes + ); + + mockMvc.perform(multipart("/api/v1/cli/check").file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.valid").value(true)) + .andExpect(jsonPath("$.data.errors").isEmpty()) + .andExpect(jsonPath("$.data.fileCount").value(2)) + .andExpect(jsonPath("$.data.totalSize").isNumber()); + } + + @Test + void checkShouldReturnInvalidForMissingSkillMd() throws Exception { + byte[] zipBytes = createInvalidSkillZip(); + MockMultipartFile file = new MockMultipartFile( + "file", + "skill.zip", + "application/zip", + zipBytes + ); + + mockMvc.perform(multipart("/api/v1/cli/check").file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.valid").value(false)) + .andExpect(jsonPath("$.data.errors").isNotEmpty()) + .andExpect(jsonPath("$.data.errors[0]").value("Missing required file: SKILL.md at root")); + } + + @Test + void checkShouldReturnInvalidForDisallowedExtension() throws Exception { + byte[] zipBytes = createZipWithDisallowedFile(); + MockMultipartFile file = new MockMultipartFile( + "file", + "skill.zip", + "application/zip", + zipBytes + ); + + mockMvc.perform(multipart("/api/v1/cli/check").file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.valid").value(false)) + .andExpect(jsonPath("$.data.errors").isNotEmpty()); + } + + private byte[] createValidSkillZip() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + String skillMdContent = """ + --- + name: test-skill + description: A test skill + version: 1.0.0 + --- + # Test Skill + This is a test skill. + """; + ZipEntry skillMdEntry = new ZipEntry("SKILL.md"); + zos.putNextEntry(skillMdEntry); + zos.write(skillMdContent.getBytes()); + zos.closeEntry(); + + ZipEntry readmeEntry = new ZipEntry("README.md"); + zos.putNextEntry(readmeEntry); + zos.write("# README\nThis is a readme.".getBytes()); + zos.closeEntry(); + } + return baos.toByteArray(); + } + + private byte[] createInvalidSkillZip() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + ZipEntry readmeEntry = new ZipEntry("README.md"); + zos.putNextEntry(readmeEntry); + zos.write("# README".getBytes()); + zos.closeEntry(); + } + return baos.toByteArray(); + } + + private byte[] createZipWithDisallowedFile() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + String skillMdContent = """ + --- + name: test-skill + description: A test skill + version: 1.0.0 + --- + # Test Skill + """; + ZipEntry skillMdEntry = new ZipEntry("SKILL.md"); + zos.putNextEntry(skillMdEntry); + zos.write(skillMdContent.getBytes()); + zos.closeEntry(); + + ZipEntry exeEntry = new ZipEntry("malware.exe"); + zos.putNextEntry(exeEntry); + zos.write("bad content".getBytes()); + zos.closeEntry(); + } + return baos.toByteArray(); + } } diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index 5254e8c1..65a2d145 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -17,6 +17,7 @@ spring: autoconfigure: exclude: - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration security: oauth2: client: diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 4a418d36..ab002cbf 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -67,6 +67,7 @@ public class SecurityConfig { "/api/v1/auth/providers", "/api/v1/auth/me", "/api/v1/cli/auth/device/**", + "/api/v1/cli/check", "/actuator/health", "/v3/api-docs/**", "/swagger-ui/**",