mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
feat(cli): add skill package check endpoint
This commit is contained in:
parent
f47377cff1
commit
5cc595b0ff
6 changed files with 234 additions and 63 deletions
95
README.md
95
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.
|
||||
|
|
|
|||
|
|
@ -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<SkillCheckResponse> check(@RequestParam("file") MultipartFile file) throws IOException {
|
||||
List<PackageEntry> 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<PackageEntry> extractZipEntries(MultipartFile file) throws IOException {
|
||||
List<PackageEntry> 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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record SkillCheckResponse(
|
||||
boolean valid,
|
||||
List<String> errors,
|
||||
int fileCount,
|
||||
long totalSize
|
||||
) {}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ spring:
|
|||
autoconfigure:
|
||||
exclude:
|
||||
- org.springframework.boot.autoconfigure.session.SessionAutoConfiguration
|
||||
- org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
|
|
|
|||
|
|
@ -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/**",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue