feat(cli): add skill package check endpoint

This commit is contained in:
vsxd 2026-03-12 18:26:50 +08:00
parent f47377cff1
commit 5cc595b0ff
6 changed files with 234 additions and 63 deletions

View file

@ -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.

View file

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

View file

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

View file

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

View file

@ -17,6 +17,7 @@ spring:
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.session.SessionAutoConfiguration
- org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
security:
oauth2:
client:

View file

@ -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/**",