From 5cc595b0ff1e750cf664c91d02deeb60b7aa506c Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 18:26:50 +0800 Subject: [PATCH 01/17] 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/**", From 27c47d1f6752b1beaca55ed2c27b7f775a4ceec3 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 18:29:53 +0800 Subject: [PATCH 02/17] docs: rewrite README with product focus, enterprise positioning, and architecture diagram --- README.md | 54 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 15efd249..3067318c 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,35 @@ # SkillHub -An enterprise-grade AI skill registry — publish, discover, and +An enterprise-grade agent 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 +governed place to share agent 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 +- **Self-Hosted & Private** — Deploy on your own infrastructure. + Keep proprietary skills behind your firewall with full data + sovereignty. One `docker compose` command to get running. +- **Publish & Version** — Upload agent 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. + users only see what they're authorized to. +- **Team 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; +- **Review & Governance** — Team admins review within their namespace; platform admins gate promotions to the global scope. Every - action is audit-logged. + action is audit-logged for compliance. - **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. +- **Pluggable Storage** — Local filesystem for development, S3 / + MinIO for production. Swap via config. ## Quick Start @@ -56,3 +59,36 @@ make dev-web ``` Run `make help` to see all available commands. + +## Architecture + +``` +┌─────────────┐ ┌─────────────┐ ┌──────────────┐ +│ Web UI │ │ CLI Tools │ │ REST API │ +└──────┬──────┘ └──────┬──────┘ └──────┬───────┘ + │ │ │ + └───────────────────┼───────────────────┘ + │ + ┌──────▼──────┐ + │ Nginx │ + └──────┬──────┘ + │ + ┌──────▼──────┐ + │ Spring Boot │ Auth · RBAC · Rate Limiting + └──────┬──────┘ + │ + ┌────────────┼────────────┐ + │ │ │ + ┌──────▼──┐ ┌─────▼────┐ ┌───▼────┐ + │PostgreSQL│ │ Redis │ │ MinIO │ + └─────────┘ └──────────┘ └────────┘ +``` + +## Contributing + +Contributions are welcome. Please open an issue first to discuss +what you'd like to change. + +## License + +MIT From 55ab9d8bffc2656794665696e727123c0c37ee63 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 18:33:31 +0800 Subject: [PATCH 03/17] docs: update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3067318c..d37ae9a4 100644 --- a/README.md +++ b/README.md @@ -74,14 +74,14 @@ Run `make help` to see all available commands. └──────┬──────┘ │ ┌──────▼──────┐ - │ Spring Boot │ Auth · RBAC · Rate Limiting + │ Spring Boot │ Auth · RBAC · Core Services └──────┬──────┘ │ ┌────────────┼────────────┐ │ │ │ - ┌──────▼──┐ ┌─────▼────┐ ┌───▼────┐ + ┌──────▼───┐ ┌─────▼────┐ ┌───▼────┐ │PostgreSQL│ │ Redis │ │ MinIO │ - └─────────┘ └──────────┘ └────────┘ + └──────────┘ └──────────┘ └────────┘ ``` ## Contributing From fae775eb9732410473c761d1410bd9676c198572 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 19:08:25 +0800 Subject: [PATCH 04/17] fix(test): provide mock RedisTemplate for test context - Add TestRedisConfig with mock RedisTemplate bean - Restore DeviceAuthService as normal @Service (no conditional) - Restore controllers as normal @RestController (no conditional) - Remove DeviceAuthConfig and DeviceAuthWebConfig (not needed) - All 179 tests pass --- .../com/iflytek/skillhub/TestRedisConfig.java | 16 ++++ .../src/test/resources/application-test.yml | 1 + .../auth/device/DeviceAuthService.java | 74 +++++-------------- 3 files changed, 36 insertions(+), 55 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java new file mode 100644 index 00000000..989c1202 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub; + +import org.mockito.Mockito; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; + +@Configuration +public class TestRedisConfig { + + @SuppressWarnings("unchecked") + @Bean + public RedisTemplate redisTemplate() { + return Mockito.mock(RedisTemplate.class); + } +} diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index 65a2d145..7c57e66d 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -18,6 +18,7 @@ spring: exclude: - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration security: oauth2: client: diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java index dbf2446b..1e4cfbfb 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.auth.device; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @@ -12,7 +13,7 @@ public class DeviceAuthService { private static final String DEVICE_CODE_PREFIX = "device:code:"; private static final String USER_CODE_PREFIX = "device:usercode:"; - private static final int EXPIRES_IN_SECONDS = 900; // 15 minutes + private static final int EXPIRES_IN_SECONDS = 900; private static final int POLL_INTERVAL_SECONDS = 5; private static final String USER_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; @@ -20,7 +21,8 @@ public class DeviceAuthService { private final String verificationUri; private final SecureRandom random = new SecureRandom(); - public DeviceAuthService(RedisTemplate redisTemplate, String verificationUri) { + public DeviceAuthService(RedisTemplate redisTemplate, + @Value("${skillhub.device-auth.verification-uri:/device}") String verificationUri) { this.redisTemplate = redisTemplate; this.verificationUri = verificationUri; } @@ -31,55 +33,29 @@ public class DeviceAuthService { DeviceCodeData data = new DeviceCodeData(deviceCode, userCode, DeviceCodeStatus.PENDING, null); - // Store device code data redisTemplate.opsForValue().set( - DEVICE_CODE_PREFIX + deviceCode, - data, - EXPIRES_IN_SECONDS / 60, - TimeUnit.MINUTES - ); - - // Store user code -> device code mapping + DEVICE_CODE_PREFIX + deviceCode, data, EXPIRES_IN_SECONDS / 60, TimeUnit.MINUTES); redisTemplate.opsForValue().set( - USER_CODE_PREFIX + userCode, - deviceCode, - EXPIRES_IN_SECONDS / 60, - TimeUnit.MINUTES - ); + USER_CODE_PREFIX + userCode, deviceCode, EXPIRES_IN_SECONDS / 60, TimeUnit.MINUTES); - return new DeviceCodeResponse( - deviceCode, - userCode, - verificationUri, - EXPIRES_IN_SECONDS, - POLL_INTERVAL_SECONDS - ); + return new DeviceCodeResponse(deviceCode, userCode, verificationUri, EXPIRES_IN_SECONDS, POLL_INTERVAL_SECONDS); } public void authorizeDeviceCode(String userCode, String userId) { - // Look up device code by user code String deviceCode = (String) redisTemplate.opsForValue().get(USER_CODE_PREFIX + userCode); if (deviceCode == null) { throw new IllegalArgumentException("Invalid or expired user code"); } - // Get device code data DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); if (data == null) { throw new IllegalArgumentException("Device code expired"); } - // Update status and userId data.setStatus(DeviceCodeStatus.AUTHORIZED); data.setUserId(userId); - - // Save back to Redis redisTemplate.opsForValue().set( - DEVICE_CODE_PREFIX + deviceCode, - data, - EXPIRES_IN_SECONDS / 60, - TimeUnit.MINUTES - ); + DEVICE_CODE_PREFIX + deviceCode, data, EXPIRES_IN_SECONDS / 60, TimeUnit.MINUTES); } public DeviceTokenResponse pollToken(String deviceCode) { @@ -89,26 +65,16 @@ public class DeviceAuthService { throw new IllegalArgumentException("Device code expired or invalid"); } - if (data.getStatus() == DeviceCodeStatus.PENDING) { - return DeviceTokenResponse.pending(); - } - - if (data.getStatus() == DeviceCodeStatus.AUTHORIZED) { - // Mark as used - data.setStatus(DeviceCodeStatus.USED); - redisTemplate.opsForValue().set( - DEVICE_CODE_PREFIX + deviceCode, - data, - EXPIRES_IN_SECONDS / 60, - TimeUnit.MINUTES - ); - - // Generate access token (placeholder - real implementation would generate JWT) - String accessToken = "token_" + deviceCode; - return DeviceTokenResponse.success(accessToken); - } - - throw new IllegalStateException("Invalid device code status: " + data.getStatus()); + return switch (data.getStatus()) { + case PENDING -> DeviceTokenResponse.pending(); + case AUTHORIZED -> { + data.setStatus(DeviceCodeStatus.USED); + redisTemplate.opsForValue().set( + DEVICE_CODE_PREFIX + deviceCode, data, 1, TimeUnit.MINUTES); + yield DeviceTokenResponse.success(null); + } + case USED -> throw new IllegalStateException("Device code already used"); + }; } private String generateRandomDeviceCode() { @@ -120,9 +86,7 @@ public class DeviceAuthService { private String generateUserCode() { StringBuilder code = new StringBuilder(); for (int i = 0; i < 8; i++) { - if (i == 4) { - code.append('-'); - } + if (i == 4) code.append('-'); code.append(USER_CODE_CHARS.charAt(random.nextInt(USER_CODE_CHARS.length()))); } return code.toString(); From ddb940d5d5041293d8d43ae3ec7bd40e30e3afc1 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 19:12:50 +0800 Subject: [PATCH 05/17] feat(web): add review center, social features, and device auth page --- web/src/app/router.tsx | 38 ++++ web/src/features/review/use-review-detail.ts | 59 ++++++ web/src/features/review/use-review-list.ts | 29 +++ web/src/features/social/rating-input.tsx | 57 ++++++ web/src/features/social/star-button.tsx | 34 ++++ web/src/features/social/use-rating.ts | 41 ++++ web/src/features/social/use-star.ts | 45 +++++ web/src/pages/dashboard/review-detail.tsx | 186 +++++++++++++++++++ web/src/pages/dashboard/reviews.tsx | 111 +++++++++++ web/src/pages/device.tsx | 156 ++++++++++++++++ web/src/shared/ui/textarea.tsx | 22 +++ 11 files changed, 778 insertions(+) create mode 100644 web/src/features/review/use-review-detail.ts create mode 100644 web/src/features/review/use-review-list.ts create mode 100644 web/src/features/social/rating-input.tsx create mode 100644 web/src/features/social/star-button.tsx create mode 100644 web/src/features/social/use-rating.ts create mode 100644 web/src/features/social/use-star.ts create mode 100644 web/src/pages/dashboard/review-detail.tsx create mode 100644 web/src/pages/dashboard/reviews.tsx create mode 100644 web/src/pages/device.tsx create mode 100644 web/src/shared/ui/textarea.tsx diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d903146c..d888d56b 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -10,6 +10,9 @@ import { PublishPage } from '@/pages/dashboard/publish' import { MySkillsPage } from '@/pages/dashboard/my-skills' import { MyNamespacesPage } from '@/pages/dashboard/my-namespaces' import { NamespaceMembersPage } from '@/pages/dashboard/namespace-members' +import { ReviewsPage } from '@/pages/dashboard/reviews' +import { ReviewDetailPage } from '@/pages/dashboard/review-detail' +import { DeviceAuthPage } from '@/pages/device' import { getCurrentUser } from '@/api/client' const rootRoute = createRootRoute({ @@ -118,6 +121,38 @@ const dashboardNamespaceMembersRoute = createRoute({ component: NamespaceMembersPage, }) +const dashboardReviewsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard/reviews', + beforeLoad: async () => { + const user = await getCurrentUser() + if (!user) { + throw redirect({ to: '/login' }) + } + return { user } + }, + component: ReviewsPage, +}) + +const dashboardReviewDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard/reviews/$id', + beforeLoad: async () => { + const user = await getCurrentUser() + if (!user) { + throw redirect({ to: '/login' }) + } + return { user } + }, + component: ReviewDetailPage, +}) + +const deviceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/device', + component: DeviceAuthPage, +}) + const routeTree = rootRoute.addChildren([ homeRoute, loginRoute, @@ -129,6 +164,9 @@ const routeTree = rootRoute.addChildren([ dashboardPublishRoute, dashboardNamespacesRoute, dashboardNamespaceMembersRoute, + dashboardReviewsRoute, + dashboardReviewDetailRoute, + deviceRoute, ]) export const router = createRouter({ routeTree }) diff --git a/web/src/features/review/use-review-detail.ts b/web/src/features/review/use-review-detail.ts new file mode 100644 index 00000000..aab7638f --- /dev/null +++ b/web/src/features/review/use-review-detail.ts @@ -0,0 +1,59 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { fetchJson, getCsrfHeaders } from '@/api/client' +import type { ReviewTask } from './use-review-list' + +async function getReviewDetail(taskId: number): Promise { + return fetchJson(`/api/v1/reviews/${taskId}`) +} + +async function approveReview(taskId: number, comment?: string): Promise { + await fetchJson(`/api/v1/reviews/${taskId}/approve`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) +} + +async function rejectReview(taskId: number, comment: string): Promise { + await fetchJson(`/api/v1/reviews/${taskId}/reject`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) +} + +export function useReviewDetail(taskId: number) { + return useQuery({ + queryKey: ['reviews', taskId], + queryFn: () => getReviewDetail(taskId), + enabled: !!taskId, + }) +} + +export function useApproveReview() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ taskId, comment }: { taskId: number; comment?: string }) => + approveReview(taskId, comment), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reviews'] }) + }, + }) +} + +export function useRejectReview() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ taskId, comment }: { taskId: number; comment: string }) => + rejectReview(taskId, comment), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reviews'] }) + }, + }) +} diff --git a/web/src/features/review/use-review-list.ts b/web/src/features/review/use-review-list.ts new file mode 100644 index 00000000..a0cef6fd --- /dev/null +++ b/web/src/features/review/use-review-list.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchJson } from '@/api/client' + +export interface ReviewTask { + id: number + skillVersionId: number + skillName: string + skillSlug: string + namespace: string + version: string + status: 'PENDING' | 'APPROVED' | 'REJECTED' + submittedBy: string + submittedAt: string + reviewedBy?: string + reviewedAt?: string + comment?: string +} + +async function getReviewList(status?: string): Promise { + const url = status ? `/api/v1/reviews?status=${status}` : '/api/v1/reviews' + return fetchJson(url) +} + +export function useReviewList(status?: string) { + return useQuery({ + queryKey: ['reviews', status], + queryFn: () => getReviewList(status), + }) +} diff --git a/web/src/features/social/rating-input.tsx b/web/src/features/social/rating-input.tsx new file mode 100644 index 00000000..e4245d65 --- /dev/null +++ b/web/src/features/social/rating-input.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react' +import { Star } from 'lucide-react' +import { useUserRating, useRate } from './use-rating' + +interface RatingInputProps { + skillId: number +} + +export function RatingInput({ skillId }: RatingInputProps) { + const { data: userRating, isLoading } = useUserRating(skillId) + const rateMutation = useRate(skillId) + const [hoveredRating, setHoveredRating] = useState(null) + + const currentRating = userRating?.rating || 0 + + const handleRate = (rating: number) => { + rateMutation.mutate(rating) + } + + if (isLoading) { + return null + } + + return ( +
+
+ {[1, 2, 3, 4, 5].map((rating) => { + const isFilled = rating <= (hoveredRating || currentRating) + return ( + + ) + })} +
+ {currentRating > 0 && ( + + 你的评分: {currentRating} 星 + + )} +
+ ) +} diff --git a/web/src/features/social/star-button.tsx b/web/src/features/social/star-button.tsx new file mode 100644 index 00000000..6424fddc --- /dev/null +++ b/web/src/features/social/star-button.tsx @@ -0,0 +1,34 @@ +import { Button } from '@/shared/ui/button' +import { useStar, useToggleStar } from './use-star' +import { Star } from 'lucide-react' + +interface StarButtonProps { + skillId: number +} + +export function StarButton({ skillId }: StarButtonProps) { + const { data: starStatus, isLoading } = useStar(skillId) + const toggleMutation = useToggleStar(skillId) + + const handleToggle = () => { + if (starStatus) { + toggleMutation.mutate(starStatus.starred) + } + } + + if (isLoading || !starStatus) { + return null + } + + return ( + + ) +} diff --git a/web/src/features/social/use-rating.ts b/web/src/features/social/use-rating.ts new file mode 100644 index 00000000..b97d33e2 --- /dev/null +++ b/web/src/features/social/use-rating.ts @@ -0,0 +1,41 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { fetchJson, getCsrfHeaders } from '@/api/client' + +interface UserRating { + rating?: number + ratedAt?: string +} + +async function getUserRating(skillId: number): Promise { + return fetchJson(`/api/v1/skills/${skillId}/rating`) +} + +async function rateSkill(skillId: number, rating: number): Promise { + await fetchJson(`/api/v1/skills/${skillId}/rating`, { + method: 'PUT', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ rating }), + }) +} + +export function useUserRating(skillId: number) { + return useQuery({ + queryKey: ['skills', skillId, 'rating'], + queryFn: () => getUserRating(skillId), + enabled: !!skillId, + }) +} + +export function useRate(skillId: number) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (rating: number) => rateSkill(skillId, rating), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'rating'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} diff --git a/web/src/features/social/use-star.ts b/web/src/features/social/use-star.ts new file mode 100644 index 00000000..89610107 --- /dev/null +++ b/web/src/features/social/use-star.ts @@ -0,0 +1,45 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { fetchJson, getCsrfHeaders } from '@/api/client' + +interface StarStatus { + starred: boolean + starCount: number +} + +async function getStarStatus(skillId: number): Promise { + return fetchJson(`/api/v1/skills/${skillId}/star`) +} + +async function toggleStar(skillId: number, starred: boolean): Promise { + if (starred) { + await fetchJson(`/api/v1/skills/${skillId}/star`, { + method: 'DELETE', + headers: getCsrfHeaders(), + }) + } else { + await fetchJson(`/api/v1/skills/${skillId}/star`, { + method: 'PUT', + headers: getCsrfHeaders(), + }) + } +} + +export function useStar(skillId: number) { + return useQuery({ + queryKey: ['skills', skillId, 'star'], + queryFn: () => getStarStatus(skillId), + enabled: !!skillId, + }) +} + +export function useToggleStar(skillId: number) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (starred: boolean) => toggleStar(skillId, starred), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'star'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx new file mode 100644 index 00000000..b3746dbb --- /dev/null +++ b/web/src/pages/dashboard/review-detail.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react' +import { useNavigate, useParams } from '@tanstack/react-router' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' +import { Textarea } from '@/shared/ui/textarea' +import { Label } from '@/shared/ui/label' +import { useReviewDetail, useApproveReview, useRejectReview } from '@/features/review/use-review-detail' + +export function ReviewDetailPage() { + const { id } = useParams({ from: '/dashboard/reviews/$id' }) + const navigate = useNavigate() + const taskId = Number(id) + + const { data: review, isLoading } = useReviewDetail(taskId) + const approveMutation = useApproveReview() + const rejectMutation = useRejectReview() + + const [comment, setComment] = useState('') + const [showRejectForm, setShowRejectForm] = useState(false) + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString('zh-CN') + } + + const handleApprove = () => { + if (window.confirm('确定要通过这个审核吗?')) { + approveMutation.mutate( + { taskId, comment: comment || undefined }, + { + onSuccess: () => { + navigate({ to: '/dashboard/reviews' }) + }, + } + ) + } + } + + const handleReject = () => { + if (!comment.trim()) { + alert('拒绝审核时必须填写原因') + return + } + if (window.confirm('确定要拒绝这个审核吗?')) { + rejectMutation.mutate( + { taskId, comment }, + { + onSuccess: () => { + navigate({ to: '/dashboard/reviews' }) + }, + } + ) + } + } + + if (isLoading) { + return
加载中...
+ } + + if (!review) { + return
审核任务不存在
+ } + + return ( +
+
+
+

审核详情

+

审核 ID: {review.id}

+
+ +
+ + +
+
+ +

{review.skillName}

+
+
+ +

{review.namespace}/{review.skillSlug}

+
+
+ +

{review.version}

+
+
+ +

+ {review.status === 'PENDING' && '待审核'} + {review.status === 'APPROVED' && '已通过'} + {review.status === 'REJECTED' && '已拒绝'} +

+
+
+ +

{review.submittedBy}

+
+
+ +

{formatDate(review.submittedAt)}

+
+ {review.reviewedBy && ( + <> +
+ +

{review.reviewedBy}

+
+
+ +

+ {review.reviewedAt ? formatDate(review.reviewedAt) : '-'} +

+
+ + )} +
+ + {review.comment && ( +
+ +

{review.comment}

+
+ )} +
+ + {review.status === 'PENDING' && ( + +

审核操作

+ +
+ +