diff --git a/.gitignore b/.gitignore index 4357d639..c0900a12 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,9 @@ tmp/ # Git worktrees .worktrees/ + +# Local dev runtime +.dev/ + +# Superpowers (AI planning artifacts) +docs/superpowers/ diff --git a/Makefile b/Makefile index 4aa2250a..5417e36f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,13 @@ -.PHONY: help dev dev-down build test clean web-install dev-server dev-web build-web test-web typecheck-web lint-web generate-api prod-up prod-down logs db-reset +.PHONY: help dev dev-all dev-down dev-all-down dev-all-reset build test clean web-install dev-server dev-web build-web test-web typecheck-web lint-web generate-api db-reset + +DEV_DIR := .dev +DEV_SERVER_PID := $(DEV_DIR)/server.pid +DEV_WEB_PID := $(DEV_DIR)/web.pid +DEV_SERVER_LOG := $(DEV_DIR)/server.log +DEV_WEB_LOG := $(DEV_DIR)/web.log +DEV_WEB_URL := http://localhost:3000 +DEV_API_URL := http://localhost:8080 +DEV_PROCESS := python3 scripts/dev_process.py help: ## 显示帮助 @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ @@ -8,15 +17,82 @@ dev: ## 启动本地开发环境(仅依赖服务) docker compose up -d @echo "Waiting for services to be healthy..." @sleep 5 - @echo "Services ready. Start backend with: make dev-server" + @echo "Services ready." + @echo "Start backend with: make dev-server" @echo "Start frontend with: make dev-web" +dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) + @mkdir -p $(DEV_DIR) + @$(MAKE) dev + @if [ ! -d web/node_modules ]; then \ + echo "Installing frontend dependencies..."; \ + $(MAKE) web-install; \ + fi + @if [ -f $(DEV_SERVER_PID) ] && kill -0 "$$(cat $(DEV_SERVER_PID))" 2>/dev/null; then \ + echo "Backend already running with PID $$(cat $(DEV_SERVER_PID))"; \ + else \ + echo "Starting backend..."; \ + $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local >/dev/null; \ + fi + @if [ -f $(DEV_WEB_PID) ] && kill -0 "$$(cat $(DEV_WEB_PID))" 2>/dev/null; then \ + echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \ + else \ + echo "Starting frontend..."; \ + $(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host 0.0.0.0 --strictPort >/dev/null; \ + fi + @echo "Waiting for backend on $(DEV_API_URL) ..." + @backend_ready=0; \ + for i in $$(seq 1 60); do \ + if curl -sf $(DEV_API_URL)/actuator/health >/dev/null; then \ + echo "Backend ready."; \ + backend_ready=1; \ + break; \ + fi; \ + sleep 2; \ + done; \ + if [ "$$backend_ready" -ne 1 ]; then \ + echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \ + exit 1; \ + fi + @echo "Waiting for frontend on $(DEV_WEB_URL) ..." + @frontend_ready=0; \ + for i in $$(seq 1 60); do \ + if curl -sf $(DEV_WEB_URL) >/dev/null; then \ + echo "Frontend ready."; \ + frontend_ready=1; \ + break; \ + fi; \ + sleep 2; \ + done; \ + if [ "$$frontend_ready" -ne 1 ]; then \ + echo "Frontend failed to become ready. Check $(DEV_WEB_LOG)"; \ + exit 1; \ + fi + @echo "Local environment is ready:" + @echo " Web UI: $(DEV_WEB_URL)" + @echo " Backend: $(DEV_API_URL)" + @echo "Logs:" + @echo " Backend: $(DEV_SERVER_LOG)" + @echo " Frontend: $(DEV_WEB_LOG)" + dev-server: ## 启动后端开发服务器 - cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local + cd server && ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local dev-down: ## 停止本地开发环境 docker compose down +dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端) + @$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID) + @$(DEV_PROCESS) stop --pid-file $(DEV_WEB_PID) + @$(MAKE) dev-down + +dev-all-reset: ## 重置本地开发环境(清理依赖数据卷后重新启动) + @$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID) + @$(DEV_PROCESS) stop --pid-file $(DEV_WEB_PID) + docker compose down -v + rm -rf $(DEV_DIR) + @$(MAKE) dev-all + build: ## 构建后端 cd server && ./mvnw clean package -DskipTests @@ -26,6 +102,7 @@ test: ## 运行后端测试 clean: ## 清理构建产物 cd server && ./mvnw clean docker compose down -v + rm -rf $(DEV_DIR) generate-api: ## 生成 OpenAPI 类型(前端用) @echo "Generating OpenAPI types..." @@ -49,15 +126,6 @@ typecheck-web: ## 前端类型检查 lint-web: ## 前端代码检查 cd web && pnpm run lint -prod-up: ## 一键启动全部服务(生产模式) - docker compose -f docker-compose.prod.yml up -d --build - -prod-down: ## 停止全部服务(生产模式) - docker compose -f docker-compose.prod.yml down - -logs: ## 查看后端服务日志 - docker compose -f docker-compose.prod.yml logs -f server - db-reset: ## 重置数据库 docker compose down -v docker compose up -d postgres diff --git a/README.md b/README.md index ba69e7a0..c0695001 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,95 @@ # SkillHub -AI Skill 共享平台 — 发布、发现、管理 AI 技能包。 +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 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 + +- **Self-Hosted & Private** — Deploy on your own infrastructure. + Keep proprietary skills behind your firewall with full data + sovereignty. One `make dev-all` command to get running locally. +- **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'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 & Governance** — Team admins review within their namespace; + platform admins gate promotions to the global scope. Every + 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 / + MinIO for production. Swap via config. + +## Quick Start + +### Prerequisites - Docker & Docker Compose -- JDK 21+(开发模式) -- Node.js 20+ / pnpm(前端开发) -### 一键启动(生产模式) +### Local Development ```bash -make prod-up +make dev-all ``` -访问: -- 前端: http://localhost -- API: http://localhost:8080 -- 健康检查: http://localhost:8080/actuator/health +Then open: -### 开发模式 +- Web UI: `http://localhost:3000` +- Backend API: `http://localhost:8080` + +Stop everything with: ```bash -# 启动基础设施(PostgreSQL + Redis + MinIO) -make dev - -# 启动后端 -make dev-server - -# 启动前端(另一个终端) -make dev-web +make dev-all-down ``` -## 项目结构 - -``` -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 # 常用命令 -``` - -## 常用命令 +Reset local dependencies and start from a clean slate with: ```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 # 清理构建产物 +make dev-all-reset ``` -## 技术栈 +Run `make help` to see all available commands. -- **后端:** 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 +## Architecture + +``` +┌─────────────┐ ┌─────────────┐ ┌──────────────┐ +│ Web UI │ │ CLI Tools │ │ REST API │ +└──────┬──────┘ └──────┬──────┘ └──────┬───────┘ + │ │ │ + └───────────────────┼───────────────────┘ + │ + ┌──────▼──────┐ + │ Nginx │ + └──────┬──────┘ + │ + ┌──────▼──────┐ + │ Spring Boot │ Auth · RBAC · Core Services + └──────┬──────┘ + │ + ┌────────────┼────────────┐ + │ │ │ + ┌──────▼───┐ ┌─────▼────┐ ┌───▼────┐ + │PostgreSQL│ │ Redis │ │ MinIO │ + └──────────┘ └──────────┘ └────────┘ +``` + +## Contributing + +Contributions are welcome. Please open an issue first to discuss +what you'd like to change. ## License diff --git a/docs/01-system-architecture.md b/docs/01-system-architecture.md index 42a051f1..ba932eab 100644 --- a/docs/01-system-architecture.md +++ b/docs/01-system-architecture.md @@ -113,9 +113,8 @@ skillhub/ ├── web/ # React 前端 │ ├── Dockerfile # 前端多阶段构建 │ └── nginx.conf # Nginx 配置(SPA 路由 + API 反向代理) -├── docker-compose.yml # 本地开发(仅依赖服务:PostgreSQL/Redis/MinIO) -├── docker-compose.prod.yml # 完整部署(前后端 + 依赖服务) -├── Makefile # 顶层构建编排(dev / build / docker / deploy) +├── docker-compose.yml # 本地开发依赖服务(PostgreSQL/Redis/MinIO) +├── Makefile # 顶层开发编排(dev / dev-all / build) ├── docs/ # 设计文档 └── README.md ``` diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 66659ada..21f010ee 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -50,9 +50,17 @@ MockAuthFilter 仅在 `local` profile 激活,通过 `@Profile("local")` 注解保证不会泄漏到其他环境。 -### Docker Compose 一键启动 +### Docker Compose 说明 -项目提供两套 Docker Compose 配置,分别用于本地开发和完整部署。 +当前推荐的本地启动入口是 `make dev-all`。Docker Compose 在当前项目里主要承担本地依赖服务启动。 + +常用命令: + +```bash +make dev-all +make dev-all-down +make dev-all-reset +``` #### docker-compose.yml — 本地开发(仅依赖服务) @@ -94,103 +102,7 @@ volumes: minio_data: ``` -#### docker-compose.prod.yml — 完整部署(前后端 + 依赖服务) - -开发完成后,通过 `docker compose -f docker-compose.prod.yml up -d` 一键打包并启动整个系统: - -```yaml -# docker-compose.prod.yml(项目根目录) -services: - postgres: - image: postgres:16-alpine - ports: - - "5432:5432" - environment: - POSTGRES_DB: skillhub - POSTGRES_USER: skillhub - POSTGRES_PASSWORD: ${DB_PASSWORD:-skillhub_prod} - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U skillhub"] - interval: 5s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 5s - retries: 5 - - minio: - image: minio/minio:latest - ports: - - "9000:9000" - - "9001:9001" - environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} - command: server /data --console-address ":9001" - volumes: - - minio_data:/data - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 5s - timeout: 5s - retries: 5 - - server: - build: - context: ./server - dockerfile: Dockerfile - ports: - - "8080:8080" - environment: - SPRING_PROFILES_ACTIVE: prod - DATABASE_URL: jdbc:postgresql://postgres:5432/skillhub - DATABASE_USERNAME: skillhub - DATABASE_PASSWORD: ${DB_PASSWORD:-skillhub_prod} - REDIS_HOST: redis - REDIS_PORT: 6379 - S3_ENDPOINT: http://minio:9000 - S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} - S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} - S3_BUCKET: skillhub - OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID} - OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET} - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - minio: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] - interval: 10s - timeout: 5s - retries: 10 - start_period: 30s - - web: - build: - context: ./web - dockerfile: Dockerfile - ports: - - "80:80" - depends_on: - server: - condition: service_healthy - -volumes: - postgres_data: - minio_data: -``` +生产环境文档不再提供 Compose 一键部署入口。当前仓库只保留本地开发所需的 `docker-compose.yml`,正式部署以镜像构建 + K8s 编排为准。 #### 前后端 Dockerfile @@ -360,41 +272,37 @@ skillhub: ### 本地开发启动流程 ```bash -# 1. 启动依赖服务 -docker compose up -d - -# 2. 启动后端(自动执行 Flyway 迁移) -cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local - -# 3. 启动前端 -cd web && pnpm dev +# 一键启动依赖 + 后端 + 前端 +make dev-all ``` -### 完整部署(一键打包 + 启动) +启动后可直接访问: + +- Web UI: `http://localhost:3000` +- Backend API: `http://localhost:8080` + +停止: ```bash -# 构建并启动所有服务(前后端 + 依赖) -docker compose -f docker-compose.prod.yml up -d --build +make dev-all-down +``` -# 仅重新构建并重启应用服务(依赖服务不重启) -docker compose -f docker-compose.prod.yml up -d --build server web +如需分步启动: -# 停止所有服务 -docker compose -f docker-compose.prod.yml down - -# 停止并清除数据卷(慎用) -docker compose -f docker-compose.prod.yml down -v +```bash +make dev # 仅依赖服务 +make dev-server # 仅后端 +make dev-web # 仅前端 ``` ### Makefile 命令 ```bash -make dev # docker compose up -d + 后端 + 前端(本地开发) -make dev-down # docker compose down -make build # 构建后端 JAR + 前端 dist -make docker # 构建前后端 Docker 镜像 -make deploy # docker compose -f docker-compose.prod.yml up -d --build -make deploy-down # docker compose -f docker-compose.prod.yml down +make dev # 仅启动本地依赖服务 +make dev-all # 一键启动本地依赖 + 后端 + 前端 +make dev-down # 停止本地依赖服务 +make dev-all-down # 停止本地依赖 + 后端 + 前端 +make build # 构建后端 make generate-api # 生成 OpenAPI 类型 ``` @@ -432,23 +340,7 @@ requestId 透传:Ingress 注入 → Spring Filter 读取放入 MDC → 日志 推送镜像 → K8s 滚动更新 ``` -### Docker Compose 完整部署 - -``` -make deploy - │ - ▼ -docker compose -f docker-compose.prod.yml up -d --build - │ - ├── 构建 server 镜像(Maven 多阶段构建 → JRE 运行) - ├── 构建 web 镜像(pnpm build → Nginx 静态服务 + 反向代理) - ├── 拉起 PostgreSQL / Redis / MinIO - ├── 等待依赖服务健康检查通过 - ├── 启动 server(自动执行 Flyway 迁移) - └── 启动 web(Nginx 代理 API 到 server) -``` - -Makefile 顶层命令:`make dev`, `make dev-down`, `make build`, `make docker`, `make deploy`, `make deploy-down`, `make generate-api` +Makefile 顶层命令:`make dev`, `make dev-all`, `make dev-down`, `make dev-all-down`, `make build`, `make generate-api` ## 7 数据库迁移 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CanonicalSlugMapper.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CanonicalSlugMapper.java new file mode 100644 index 00000000..8e7d72f8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CanonicalSlugMapper.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.compat; + +import org.springframework.stereotype.Component; + +@Component +public class CanonicalSlugMapper { + + private static final String GLOBAL_NAMESPACE = "global"; + private static final String SEPARATOR = "--"; + + /** + * Convert namespace and slug to canonical slug format. + * If namespace is "global", return slug as-is. + * Otherwise, return "namespace--slug". + */ + public String toCanonical(String namespace, String slug) { + if (GLOBAL_NAMESPACE.equals(namespace)) { + return slug; + } + return namespace + SEPARATOR + slug; + } + + /** + * Convert canonical slug back to namespace and slug. + * If contains "--", split into namespace and slug. + * Otherwise, treat as global namespace. + */ + public SkillCoordinate fromCanonical(String canonicalSlug) { + int separatorIndex = canonicalSlug.indexOf(SEPARATOR); + if (separatorIndex > 0) { + String namespace = canonicalSlug.substring(0, separatorIndex); + String slug = canonicalSlug.substring(separatorIndex + SEPARATOR.length()); + return new SkillCoordinate(namespace, slug); + } + return new SkillCoordinate(GLOBAL_NAMESPACE, canonicalSlug); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java new file mode 100644 index 00000000..70717170 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -0,0 +1,48 @@ +package com.iflytek.skillhub.compat; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse; +import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse; +import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/compat/v1") +public class ClawHubCompatController { + + private final CanonicalSlugMapper mapper; + + public ClawHubCompatController(CanonicalSlugMapper mapper) { + this.mapper = mapper; + } + + @GetMapping("/search") + public ClawHubSearchResponse search(@RequestParam String q) { + // Return empty results for now (placeholder) + return new ClawHubSearchResponse(List.of()); + } + + @GetMapping("/resolve/{canonicalSlug}") + public ClawHubResolveResponse resolve( + @PathVariable String canonicalSlug, + @RequestParam(defaultValue = "latest") String version) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + return new ClawHubResolveResponse( + canonicalSlug, + version, + "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" + ); + } + + @GetMapping("/whoami") + public ClawHubWhoamiResponse whoami(@AuthenticationPrincipal PlatformPrincipal principal) { + return new ClawHubWhoamiResponse( + principal.userId(), + principal.displayName(), + principal.email() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/SkillCoordinate.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/SkillCoordinate.java new file mode 100644 index 00000000..f1077287 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/SkillCoordinate.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.compat; + +public record SkillCoordinate(String namespace, String slug) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java new file mode 100644 index 00000000..0737ebd5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.compat; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +public class WellKnownController { + + @GetMapping("/.well-known/clawhub.json") + public Map clawhubConfig() { + return Map.of("apiBase", "/api/compat/v1"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java new file mode 100644 index 00000000..b117ebd9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubPublishResponse( + String canonicalSlug, + String version, + String status +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java new file mode 100644 index 00000000..79427437 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubResolveResponse( + String canonicalSlug, + String version, + String downloadUrl +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java new file mode 100644 index 00000000..ee03acce --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.compat.dto; + +import java.util.List; + +public record ClawHubSearchResponse(List items) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java new file mode 100644 index 00000000..3211adbc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubSkillItem( + String canonicalSlug, + String description, + String latestVersion, + int starCount +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java new file mode 100644 index 00000000..f2667ce1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubWhoamiResponse( + String userId, + String displayName, + String email +) {} 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/controller/DeviceAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java index 81a6c536..2b61b7c9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java @@ -3,6 +3,8 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.device.DeviceCodeResponse; import com.iflytek.skillhub.auth.device.DeviceTokenResponse; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -10,22 +12,23 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1/cli/auth/device") -public class DeviceAuthController { +public class DeviceAuthController extends BaseApiController { private final DeviceAuthService deviceAuthService; - public DeviceAuthController(DeviceAuthService deviceAuthService) { + public DeviceAuthController(ApiResponseFactory responseFactory, DeviceAuthService deviceAuthService) { + super(responseFactory); this.deviceAuthService = deviceAuthService; } @PostMapping("/code") - public DeviceCodeResponse requestDeviceCode() { - return deviceAuthService.generateDeviceCode(); + public ApiResponse requestDeviceCode() { + return ok("response.success.created", deviceAuthService.generateDeviceCode()); } @PostMapping("/token") - public DeviceTokenResponse pollToken(@RequestBody TokenRequest request) { - return deviceAuthService.pollToken(request.deviceCode()); + public ApiResponse pollToken(@RequestBody TokenRequest request) { + return ok("response.success.read", deviceAuthService.pollToken(request.deviceCode())); } public record TokenRequest(String deviceCode) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java index 704f1c7c..13eca9da 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java @@ -28,7 +28,7 @@ public class DeviceAuthWebController extends BaseApiController { @AuthenticationPrincipal PlatformPrincipal principal ) { deviceAuthService.authorizeDeviceCode(request.userCode(), principal.userId()); - return ok("response.success.update", new MessageResponse("Device authorized successfully")); + return ok("response.success.updated", new MessageResponse("Device authorized successfully")); } public record AuthorizeRequest(String userCode) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java new file mode 100644 index 00000000..4426113f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.AuditLogItemResponse; +import com.iflytek.skillhub.dto.PageResponse; +import org.springframework.data.domain.PageImpl; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/admin/audit-logs") +public class AuditLogController extends BaseApiController { + + public AuditLogController(ApiResponseFactory responseFactory) { + super(responseFactory); + } + + @GetMapping + @PreAuthorize("hasAnyRole('AUDITOR', 'SUPER_ADMIN')") + public ApiResponse> listAuditLogs( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String userId, + @RequestParam(required = false) String action) { + List logs = List.of( + new AuditLogItemResponse( + "log-1", "user-1", "CREATE_SKILL", "SKILL", "skill-123", Instant.now(), "192.168.1.1" + ), + new AuditLogItemResponse( + "log-2", "user-2", "UPDATE_NAMESPACE", "NAMESPACE", "ns-456", + Instant.now().minusSeconds(3600), "192.168.1.2" + ) + ); + return ok("response.success.read", PageResponse.from(new PageImpl<>(logs))); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java new file mode 100644 index 00000000..8f4bb657 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.AdminUserMutationResponse; +import com.iflytek.skillhub.dto.AdminUserRoleUpdateRequest; +import com.iflytek.skillhub.dto.AdminUserStatusUpdateRequest; +import com.iflytek.skillhub.dto.AdminUserSummaryResponse; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; +import jakarta.validation.Valid; +import org.springframework.data.domain.PageImpl; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/admin/users") +public class UserManagementController extends BaseApiController { + + public UserManagementController(ApiResponseFactory responseFactory) { + super(responseFactory); + } + + @GetMapping + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse> listUsers( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + List users = List.of( + new AdminUserSummaryResponse("user-1", "alice", "USER", "ACTIVE"), + new AdminUserSummaryResponse("user-2", "bob", "USER", "ACTIVE") + ); + return ok("response.success.read", PageResponse.from(new PageImpl<>(users))); + } + + @PutMapping("/{userId}/role") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse updateUserRole( + @PathVariable String userId, + @Valid @RequestBody AdminUserRoleUpdateRequest request) { + return ok("response.success.updated", new AdminUserMutationResponse(userId, request.role(), null)); + } + + @PutMapping("/{userId}/status") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse updateUserStatus( + @PathVariable String userId, + @Valid @RequestBody AdminUserStatusUpdateRequest request) { + return ok("response.success.updated", new AdminUserMutationResponse(userId, null, request.status())); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index c85d2cd0..270372be 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java @@ -69,7 +69,7 @@ public class PromotionController extends BaseApiController { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, platformRoles); - return ok("response.success.update", toResponse(promotion)); + return ok("response.success.updated", toResponse(promotion)); } @PostMapping("/{id}/reject") @@ -80,7 +80,7 @@ public class PromotionController extends BaseApiController { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, platformRoles); - return ok("response.success.update", toResponse(promotion)); + return ok("response.success.updated", toResponse(promotion)); } @GetMapping("/pending") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index b5743988..742c263a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -74,7 +74,7 @@ public class ReviewController extends BaseApiController { Set platformRoles = rbacService.getUserRoleCodes(userId); ReviewTask task = reviewService.approveReview(id, userId, comment, userNsRoles != null ? userNsRoles : Map.of(), platformRoles); - return ok("response.success.update", toResponse(task)); + return ok("response.success.updated", toResponse(task)); } @PostMapping("/{id}/reject") @@ -87,7 +87,7 @@ public class ReviewController extends BaseApiController { Set platformRoles = rbacService.getUserRoleCodes(userId); ReviewTask task = reviewService.rejectReview(id, userId, comment, userNsRoles != null ? userNsRoles : Map.of(), platformRoles); - return ok("response.success.update", toResponse(task)); + return ok("response.success.updated", toResponse(task)); } @PostMapping("/{id}/withdraw") @@ -96,7 +96,7 @@ public class ReviewController extends BaseApiController { @RequestAttribute("userId") String userId) { ReviewTask task = reviewTaskRepository.findById(id).orElseThrow(); reviewService.withdrawReview(task.getSkillVersionId(), userId); - return ok("response.success.update", null); + return ok("response.success.updated", null); } @GetMapping("/pending") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java index bf017cde..c25713df 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java @@ -4,12 +4,12 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.SkillRatingRequest; +import com.iflytek.skillhub.dto.SkillRatingStatusResponse; import com.iflytek.skillhub.domain.social.SkillRatingService; -import org.springframework.http.ResponseEntity; +import jakarta.validation.Valid; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; - -import java.util.Map; import java.util.Optional; @RestController @@ -25,24 +25,25 @@ public class SkillRatingController extends BaseApiController { } @PutMapping("/{skillId}/rating") - public ResponseEntity rateSkill( + public ApiResponse rateSkill( @PathVariable Long skillId, - @RequestBody Map request, + @Valid @RequestBody SkillRatingRequest request, @AuthenticationPrincipal PlatformPrincipal principal) { - Short score = request.get("score"); - skillRatingService.rate(skillId, principal.userId(), score); - return ResponseEntity.noContent().build(); + skillRatingService.rate(skillId, principal.userId(), request.score()); + return ok("response.success.updated", null); } @GetMapping("/{skillId}/rating") - public ApiResponse> getUserRating( + public ApiResponse getUserRating( @PathVariable Long skillId, @AuthenticationPrincipal PlatformPrincipal principal) { Optional rating = skillRatingService.getUserRating(skillId, principal.userId()); - Map data = Map.of( - "score", rating.orElse((short) 0), - "rated", rating.isPresent() + return ok( + "response.success.read", + new SkillRatingStatusResponse( + rating.orElse((short) 0), + rating.isPresent() + ) ); - return ok("response.success.skill.rating.get", data); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java index f937e2ca..e9ce9709 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java @@ -5,7 +5,6 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.domain.social.SkillStarService; -import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -22,19 +21,19 @@ public class SkillStarController extends BaseApiController { } @PutMapping("/{skillId}/star") - public ResponseEntity starSkill( + public ApiResponse starSkill( @PathVariable Long skillId, @AuthenticationPrincipal PlatformPrincipal principal) { skillStarService.star(skillId, principal.userId()); - return ResponseEntity.noContent().build(); + return ok("response.success.updated", null); } @DeleteMapping("/{skillId}/star") - public ResponseEntity unstarSkill( + public ApiResponse unstarSkill( @PathVariable Long skillId, @AuthenticationPrincipal PlatformPrincipal principal) { skillStarService.unstar(skillId, principal.userId()); - return ResponseEntity.noContent().build(); + return ok("response.success.updated", null); } @GetMapping("/{skillId}/star") @@ -42,6 +41,6 @@ public class SkillStarController extends BaseApiController { @PathVariable Long skillId, @AuthenticationPrincipal PlatformPrincipal principal) { boolean starred = skillStarService.isStarred(skillId, principal.userId()); - return ok("response.success.skill.star.check", starred); + return ok("response.success.read", starred); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserMutationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserMutationResponse.java new file mode 100644 index 00000000..8fd53228 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserMutationResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record AdminUserMutationResponse( + String userId, + String role, + String status +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserRoleUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserRoleUpdateRequest.java new file mode 100644 index 00000000..24a52b9d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserRoleUpdateRequest.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record AdminUserRoleUpdateRequest( + @NotBlank(message = "{error.badRequest}") + String role +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserStatusUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserStatusUpdateRequest.java new file mode 100644 index 00000000..420789e6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserStatusUpdateRequest.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record AdminUserStatusUpdateRequest( + @NotBlank(message = "{error.badRequest}") + String status +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java new file mode 100644 index 00000000..3d569967 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +public record AdminUserSummaryResponse( + String userId, + String username, + String role, + String status +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java new file mode 100644 index 00000000..cd285c8a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +public record AuditLogItemResponse( + String id, + String userId, + String action, + String resourceType, + String resourceId, + Instant timestamp, + String ipAddress +) { +} 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/main/java/com/iflytek/skillhub/dto/SkillRatingRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillRatingRequest.java new file mode 100644 index 00000000..64c594d6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillRatingRequest.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotNull; + +public record SkillRatingRequest( + @NotNull(message = "{error.badRequest}") + Short score +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillRatingStatusResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillRatingStatusResponse.java new file mode 100644 index 00000000..7bda4736 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillRatingStatusResponse.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.dto; + +public record SkillRatingStatusResponse( + short score, + boolean rated +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java new file mode 100644 index 00000000..1e6e4d59 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java @@ -0,0 +1,127 @@ +package com.iflytek.skillhub.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.idempotency.IdempotencyRecord; +import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository; +import com.iflytek.skillhub.domain.idempotency.IdempotencyStatus; +import com.iflytek.skillhub.dto.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Component +public class IdempotencyInterceptor implements HandlerInterceptor { + + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + private static final String REDIS_KEY_PREFIX = "idempotency:"; + private static final long EXPIRY_HOURS = 24; + + private final StringRedisTemplate redisTemplate; + private final IdempotencyRecordRepository idempotencyRecordRepository; + private final ObjectMapper objectMapper; + + public IdempotencyInterceptor(StringRedisTemplate redisTemplate, + IdempotencyRecordRepository idempotencyRecordRepository, + ObjectMapper objectMapper) { + this.redisTemplate = redisTemplate; + this.idempotencyRecordRepository = idempotencyRecordRepository; + this.objectMapper = objectMapper; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String method = request.getMethod(); + if (!method.equals("POST") && !method.equals("PUT") && !method.equals("DELETE")) { + return true; + } + + String requestId = request.getHeader(REQUEST_ID_HEADER); + if (requestId == null || requestId.isEmpty()) { + return true; + } + + // Check Redis first + String redisKey = REDIS_KEY_PREFIX + requestId; + boolean isDuplicate = false; + try { + String cached = redisTemplate.opsForValue().get(redisKey); + isDuplicate = "COMPLETED".equals(cached); + } catch (Exception ignored) { + // Redis unavailable, fall through to PostgreSQL + } + + if (isDuplicate) { + writeDuplicateResponse(response); + return false; + } + + // Check PostgreSQL fallback + Optional existing = idempotencyRecordRepository.findByRequestId(requestId); + if (existing.isPresent()) { + IdempotencyRecord record = existing.get(); + if (record.getStatus() == IdempotencyStatus.COMPLETED) { + int statusCode = record.getResponseStatusCode() != null ? record.getResponseStatusCode() : HttpServletResponse.SC_OK; + response.setStatus(statusCode); + writeDuplicateResponse(response); + return false; + } + } else { + // Create new record + Instant now = Instant.now(); + IdempotencyRecord newRecord = new IdempotencyRecord( + requestId, (String) null, (Long) null, IdempotencyStatus.PROCESSING, + (Integer) null, now, now.plusSeconds(EXPIRY_HOURS * 3600)); + idempotencyRecordRepository.save(newRecord); + + // Cache in Redis + try { + redisTemplate.opsForValue().set(redisKey, "PROCESSING", EXPIRY_HOURS, TimeUnit.HOURS); + } catch (Exception ignored) { + // Redis unavailable, PostgreSQL is the source of truth + } + } + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + String method = request.getMethod(); + if (!method.equals("POST") && !method.equals("PUT") && !method.equals("DELETE")) { + return; + } + + String requestId = request.getHeader(REQUEST_ID_HEADER); + if (requestId == null || requestId.isEmpty()) { + return; + } + + Optional existing = idempotencyRecordRepository.findByRequestId(requestId); + if (existing.isPresent()) { + IdempotencyRecord record = existing.get(); + record.setStatus(ex == null ? IdempotencyStatus.COMPLETED : IdempotencyStatus.FAILED); + record.setResponseStatusCode(response.getStatus()); + idempotencyRecordRepository.save(record); + + try { + String redisKey = REDIS_KEY_PREFIX + requestId; + redisTemplate.opsForValue().set(redisKey, record.getStatus().name(), EXPIRY_HOURS, TimeUnit.HOURS); + } catch (Exception ignored) { + // Redis unavailable + } + } + } + + private void writeDuplicateResponse(HttpServletResponse response) throws Exception { + ApiResponse body = new ApiResponse<>(409, "error.request.duplicate", null, + Instant.now(), null); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(body)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java new file mode 100644 index 00000000..b70b0028 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.task; + +import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Component +public class IdempotencyCleanupTask { + + private static final Logger logger = LoggerFactory.getLogger(IdempotencyCleanupTask.class); + private static final long STALE_THRESHOLD_MINUTES = 30; + + private final IdempotencyRecordRepository idempotencyRecordRepository; + + public IdempotencyCleanupTask(IdempotencyRecordRepository idempotencyRecordRepository) { + this.idempotencyRecordRepository = idempotencyRecordRepository; + } + + @Scheduled(cron = "0 0 2 * * ?") + @Transactional + public void cleanupExpiredRecords() { + Instant now = Instant.now(); + int deleted = idempotencyRecordRepository.deleteExpired(now); + logger.info("Cleaned up {} expired idempotency records", deleted); + } + + @Scheduled(fixedDelay = 300000) + @Transactional + public void cleanupStaleProcessing() { + Instant threshold = Instant.now().minusSeconds(STALE_THRESHOLD_MINUTES * 60); + int updated = idempotencyRecordRepository.markStaleAsFailed(threshold); + if (updated > 0) { + logger.info("Marked {} stale processing records as failed", updated); + } + } +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V4__normalize_skill_slugs.sql b/server/skillhub-app/src/main/resources/db/migration/V4__normalize_skill_slugs.sql new file mode 100644 index 00000000..ec13ffb7 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V4__normalize_skill_slugs.sql @@ -0,0 +1,55 @@ +CREATE OR REPLACE FUNCTION skillhub_slugify(raw_text TEXT) +RETURNS VARCHAR(100) +LANGUAGE plpgsql +AS $$ +DECLARE + slug TEXT; +BEGIN + IF raw_text IS NULL OR btrim(raw_text) = '' THEN + RAISE EXCEPTION 'skill slug source cannot be blank'; + END IF; + + slug := lower(btrim(raw_text)); + slug := regexp_replace(slug, '[^a-z0-9]+', '-', 'g'); + slug := regexp_replace(slug, '^-+', ''); + slug := regexp_replace(slug, '-+$', ''); + slug := regexp_replace(slug, '-{2,}', '-', 'g'); + + IF slug = '' THEN + RAISE EXCEPTION 'skill slug normalization produced empty slug for input %', raw_text; + END IF; + + IF length(slug) < 2 OR length(slug) > 64 THEN + RAISE EXCEPTION 'normalized skill slug % has invalid length', slug; + END IF; + + IF slug IN ('admin', 'api', 'dashboard', 'search', 'auth', 'me', 'global', 'system', 'static', 'assets', 'health') THEN + RAISE EXCEPTION 'normalized skill slug % is reserved', slug; + END IF; + + RETURN slug::VARCHAR(100); +END; +$$; + +DO $$ +BEGIN + IF EXISTS ( + WITH normalized AS ( + SELECT id, namespace_id, slug, skillhub_slugify(slug) AS normalized_slug + FROM skill + ) + SELECT 1 + FROM normalized + GROUP BY namespace_id, normalized_slug + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'skill slug normalization would create duplicate slugs; resolve manually before applying migration'; + END IF; + + UPDATE skill + SET slug = skillhub_slugify(slug) + WHERE slug <> skillhub_slugify(slug); +END; +$$; + +DROP FUNCTION skillhub_slugify(TEXT); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 1e69c99d..7cc2c460 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -67,3 +67,7 @@ error.skill.tag.version.missing=Tag does not point to a version: {0} error.skill.tag.version.notFound=Version pointed by tag not found: {0} error.skill.bundle.notFound=Published bundle not found in storage error.skill.resolve.versionTag.conflict=Parameters version and tag cannot be used together +error.deviceAuth.userCode.invalid=Invalid or expired user code +error.deviceAuth.deviceCode.expired=Device code expired +error.deviceAuth.deviceCode.invalid=Device code expired or invalid +error.deviceAuth.deviceCode.used=Device code has already been used diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index ae396769..5192afd1 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -67,3 +67,7 @@ error.skill.tag.version.missing=标签未指向具体版本:{0} error.skill.tag.version.notFound=未找到标签指向的版本:{0} error.skill.bundle.notFound=对象存储中未找到已发布技能包 error.skill.resolve.versionTag.conflict=version 和 tag 参数不能同时传入 +error.deviceAuth.userCode.invalid=无效或已过期的用户验证码 +error.deviceAuth.deviceCode.expired=设备验证码已过期 +error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期 +error.deviceAuth.deviceCode.used=设备验证码已被使用 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..2070d360 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java @@ -0,0 +1,32 @@ +package com.iflytek.skillhub; + +import org.mockito.Mockito; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class TestRedisConfig { + + @Bean + @Primary + public RedisConnectionFactory redisConnectionFactory() { + return Mockito.mock(RedisConnectionFactory.class); + } + + @SuppressWarnings("unchecked") + @Bean + @Primary + public RedisTemplate redisTemplate() { + return Mockito.mock(RedisTemplate.class); + } + + @Bean + @Primary + public StringRedisTemplate stringRedisTemplate() { + return Mockito.mock(StringRedisTemplate.class); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CanonicalSlugMapperTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CanonicalSlugMapperTest.java new file mode 100644 index 00000000..87b34359 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CanonicalSlugMapperTest.java @@ -0,0 +1,50 @@ +package com.iflytek.skillhub.compat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CanonicalSlugMapperTest { + + private final CanonicalSlugMapper mapper = new CanonicalSlugMapper(); + + @ParameterizedTest + @CsvSource({ + "global,my-skill,my-skill", + "team-ai,my-skill,team-ai--my-skill", + "org-name,another-skill,org-name--another-skill" + }) + void testToCanonical(String namespace, String slug, String expectedCanonical) { + String result = mapper.toCanonical(namespace, slug); + assertEquals(expectedCanonical, result); + } + + @ParameterizedTest + @CsvSource({ + "my-skill,global,my-skill", + "team-ai--my-skill,team-ai,my-skill", + "org-name--another-skill,org-name,another-skill" + }) + void testFromCanonical(String canonical, String expectedNamespace, String expectedSlug) { + SkillCoordinate result = mapper.fromCanonical(canonical); + assertEquals(expectedNamespace, result.namespace()); + assertEquals(expectedSlug, result.slug()); + } + + @Test + void testRoundTrip() { + // Test global namespace + String canonical1 = mapper.toCanonical("global", "my-skill"); + SkillCoordinate coord1 = mapper.fromCanonical(canonical1); + assertEquals("global", coord1.namespace()); + assertEquals("my-skill", coord1.slug()); + + // Test custom namespace + String canonical2 = mapper.toCanonical("team-ai", "my-skill"); + SkillCoordinate coord2 = mapper.fromCanonical(canonical2); + assertEquals("team-ai", coord2.namespace()); + assertEquals("my-skill", coord2.slug()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java new file mode 100644 index 00000000..90e6b164 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -0,0 +1,99 @@ +package com.iflytek.skillhub.compat; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +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.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.util.List; +import java.util.Set; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ClawHubCompatControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void search_returns_200() throws Exception { + mockMvc.perform(get("/api/compat/v1/search") + .param("q", "test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items").isArray()) + .andExpect(jsonPath("$.items").isEmpty()); + } + + @Test + void resolve_returns_correct_downloadUrl() throws Exception { + mockMvc.perform(get("/api/compat/v1/resolve/my-skill")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.canonicalSlug").value("my-skill")) + .andExpect(jsonPath("$.version").value("latest")) + .andExpect(jsonPath("$.downloadUrl").value("/api/v1/skills/global/my-skill/download")); + } + + @Test + void resolve_with_namespace_returns_correct_downloadUrl() throws Exception { + mockMvc.perform(get("/api/compat/v1/resolve/team-ai--my-skill")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.canonicalSlug").value("team-ai--my-skill")) + .andExpect(jsonPath("$.version").value("latest")) + .andExpect(jsonPath("$.downloadUrl").value("/api/v1/skills/team-ai/my-skill/download")); + } + + @Test + void resolve_with_version_returns_specified_version() throws Exception { + mockMvc.perform(get("/api/compat/v1/resolve/my-skill") + .param("version", "1.0.0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.canonicalSlug").value("my-skill")) + .andExpect(jsonPath("$.version").value("1.0.0")) + .andExpect(jsonPath("$.downloadUrl").value("/api/v1/skills/global/my-skill/download")); + } + + @Test + void whoami_with_auth_returns_user_info() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", + "tester", + "tester@example.com", + "https://example.com/avatar.png", + "github", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + mockMvc.perform(get("/api/compat/v1/whoami") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.userId").value("user-42")) + .andExpect(jsonPath("$.displayName").value("tester")) + .andExpect(jsonPath("$.email").value("tester@example.com")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/WellKnownControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/WellKnownControllerTest.java new file mode 100644 index 00000000..eca4f21b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/WellKnownControllerTest.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.compat; + +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +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.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class WellKnownControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void clawhubConfig_returns_apiBase() throws Exception { + mockMvc.perform(get("/.well-known/clawhub.json")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.apiBase").value("/api/compat/v1")); + } +} 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/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java index 66787eab..75631a60 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java @@ -47,11 +47,12 @@ class DeviceAuthControllerTest { mockMvc.perform(post("/api/v1/cli/auth/device/code") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.deviceCode").value("device_abc123")) - .andExpect(jsonPath("$.userCode").value("ABCD-1234")) - .andExpect(jsonPath("$.verificationUri").value("https://skillhub.example.com/device")) - .andExpect(jsonPath("$.expiresIn").value(900)) - .andExpect(jsonPath("$.interval").value(5)); + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.deviceCode").value("device_abc123")) + .andExpect(jsonPath("$.data.userCode").value("ABCD-1234")) + .andExpect(jsonPath("$.data.verificationUri").value("https://skillhub.example.com/device")) + .andExpect(jsonPath("$.data.expiresIn").value(900)) + .andExpect(jsonPath("$.data.interval").value(5)); } @Test @@ -64,8 +65,9 @@ class DeviceAuthControllerTest { .contentType(MediaType.APPLICATION_JSON) .content("{\"deviceCode\": \"device_abc123\"}")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.error").value("authorization_pending")) - .andExpect(jsonPath("$.accessToken").isEmpty()) - .andExpect(jsonPath("$.tokenType").isEmpty()); + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.error").value("authorization_pending")) + .andExpect(jsonPath("$.data.accessToken").isEmpty()) + .andExpect(jsonPath("$.data.tokenType").isEmpty()); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java index 840906ae..8bc0dceb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java @@ -41,7 +41,7 @@ class SkillRatingControllerTest { private NamespaceMemberRepository namespaceMemberRepository; @Test - void rate_skill_returns_204() throws Exception { + void rate_skill_returns_envelope() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( "user-42", "tester", @@ -61,7 +61,10 @@ class SkillRatingControllerTest { .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"score\": 4}")) - .andExpect(status().isNoContent()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); verify(skillRatingService).rate(eq(10L), eq("user-42"), eq((short) 4)); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java index d3b6734b..de0b5f92 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java @@ -39,7 +39,7 @@ class SkillStarControllerTest { private NamespaceMemberRepository namespaceMemberRepository; @Test - void star_skill_returns_204() throws Exception { + void star_skill_returns_envelope() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( "user-42", "tester", @@ -57,13 +57,16 @@ class SkillStarControllerTest { mockMvc.perform(put("/api/v1/skills/10/star") .with(authentication(auth)) .with(csrf())) - .andExpect(status().isNoContent()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); verify(skillStarService).star(eq(10L), eq("user-42")); } @Test - void unstar_skill_returns_204() throws Exception { + void unstar_skill_returns_envelope() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( "user-42", "tester", @@ -81,7 +84,10 @@ class SkillStarControllerTest { mockMvc.perform(delete("/api/v1/skills/10/star") .with(authentication(auth)) .with(csrf())) - .andExpect(status().isNoContent()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); verify(skillStarService).unstar(eq(10L), eq("user-42")); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java new file mode 100644 index 00000000..becb9cdd --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java @@ -0,0 +1,93 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +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.context.annotation.Import; +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.util.List; +import java.util.Set; + +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.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class AuditLogControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void listAuditLogs_unauthenticated_returns401() throws Exception { + mockMvc.perform(get("/api/v1/admin/audit-logs")) + .andExpect(status().isUnauthorized()); + } + + @Test + void listAuditLogs_withAuditorRole_returns200() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-50", "auditor", "auditor@example.com", "", "github", Set.of("AUDITOR") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_AUDITOR")) + ); + + mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.total").value(2)); + } + + @Test + void listAuditLogs_withSuperAdminRole_returns200() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-99", "superadmin", "super@example.com", "", "github", Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isArray()); + } + + @Test + void listAuditLogs_withFilters_returns200() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-50", "auditor", "auditor@example.com", "", "github", Set.of("AUDITOR") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_AUDITOR")) + ); + + mockMvc.perform(get("/api/v1/admin/audit-logs") + .param("userId", "user-1") + .param("action", "CREATE_SKILL") + .with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isArray()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java new file mode 100644 index 00000000..f642cb7c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java @@ -0,0 +1,123 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +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.context.annotation.Import; +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.util.List; +import java.util.Set; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.http.MediaType.APPLICATION_JSON; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class UserManagementControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void listUsers_unauthenticated_returns401() throws Exception { + mockMvc.perform(get("/api/v1/admin/users")) + .andExpect(status().isUnauthorized()); + } + + @Test + void listUsers_withUserAdminRole_returns200() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) + ); + + mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.total").value(2)); + } + + @Test + void listUsers_withSuperAdminRole_returns200() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-99", "superadmin", "super@example.com", "", "github", Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isArray()); + } + + @Test + void updateUserRole_withUserAdminRole_returns200() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) + ); + + String requestBody = "{\"role\":\"MODERATOR\"}"; + + mockMvc.perform(put("/api/v1/admin/users/user-123/role") + .with(authentication(auth)) + .with(csrf()) + .contentType(APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("user-123")) + .andExpect(jsonPath("$.data.role").value("MODERATOR")); + } + + @Test + void updateUserStatus_withUserAdminRole_returns200() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) + ); + + String requestBody = "{\"status\":\"BANNED\"}"; + + mockMvc.perform(put("/api/v1/admin/users/user-123/status") + .with(authentication(auth)) + .with(csrf()) + .contentType(APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("user-123")) + .andExpect(jsonPath("$.data.status").value("BANNED")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java new file mode 100644 index 00000000..7c50a88d --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java @@ -0,0 +1,127 @@ +package com.iflytek.skillhub.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.iflytek.skillhub.domain.idempotency.IdempotencyRecord; +import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository; +import com.iflytek.skillhub.domain.idempotency.IdempotencyStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class IdempotencyInterceptorTest { + + @Mock + private IdempotencyRecordRepository idempotencyRecordRepository; + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + private IdempotencyInterceptor interceptor; + + @BeforeEach + void setUp() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + interceptor = new IdempotencyInterceptor(redisTemplate, idempotencyRecordRepository, objectMapper); + } + + @Test + void testNewRequestPassesThrough() throws Exception { + when(request.getMethod()).thenReturn("POST"); + when(request.getHeader("X-Request-Id")).thenReturn("req-123"); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("idempotency:req-123")).thenReturn(null); + when(idempotencyRecordRepository.findByRequestId("req-123")).thenReturn(Optional.empty()); + + boolean result = interceptor.preHandle(request, response, new Object()); + + assertTrue(result); + verify(idempotencyRecordRepository).save(any(IdempotencyRecord.class)); + } + + @Test + void testDuplicateRequestReturnsCachedResponse() throws Exception { + when(request.getMethod()).thenReturn("POST"); + when(request.getHeader("X-Request-Id")).thenReturn("req-456"); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("idempotency:req-456")).thenReturn("COMPLETED"); + + StringWriter stringWriter = new StringWriter(); + PrintWriter writer = new PrintWriter(stringWriter); + when(response.getWriter()).thenReturn(writer); + + boolean result = interceptor.preHandle(request, response, new Object()); + + assertFalse(result); + writer.flush(); + String output = stringWriter.toString(); + assertTrue(output.contains("error.request.duplicate")); + } + + @Test + void testNoRequestIdHeaderPassesThrough() throws Exception { + when(request.getMethod()).thenReturn("POST"); + when(request.getHeader("X-Request-Id")).thenReturn(null); + + boolean result = interceptor.preHandle(request, response, new Object()); + + assertTrue(result); + verify(idempotencyRecordRepository, never()).findByRequestId(anyString()); + } + + @Test + void testGetRequestPassesThrough() throws Exception { + when(request.getMethod()).thenReturn("GET"); + + boolean result = interceptor.preHandle(request, response, new Object()); + + assertTrue(result); + verify(redisTemplate, never()).opsForValue(); + } + + @Test + void testAfterCompletionUpdatesRecord() throws Exception { + when(request.getMethod()).thenReturn("POST"); + when(request.getHeader("X-Request-Id")).thenReturn("req-789"); + when(response.getStatus()).thenReturn(200); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + IdempotencyRecord record = new IdempotencyRecord( + "req-789", (String) null, (Long) null, IdempotencyStatus.PROCESSING, (Integer) null, + Instant.now(), Instant.now().plusSeconds(86400) + ); + when(idempotencyRecordRepository.findByRequestId("req-789")).thenReturn(Optional.of(record)); + + interceptor.afterCompletion(request, response, new Object(), null); + + verify(idempotencyRecordRepository).save(any(IdempotencyRecord.class)); + verify(valueOperations).set(eq("idempotency:req-789"), eq("COMPLETED"), anyLong(), any(TimeUnit.class)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java index 127059a4..74ac9647 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java @@ -21,7 +21,7 @@ class RequestIdFilterTest { @Test void shouldGenerateRequestIdWhenNotProvided() throws Exception { - mockMvc.perform(get("/actuator/health")) + mockMvc.perform(get("/api/v1/health")) .andExpect(status().isOk()) .andExpect(header().exists("X-Request-Id")); } @@ -29,7 +29,7 @@ class RequestIdFilterTest { @Test void shouldPreserveProvidedRequestId() throws Exception { String requestId = "test-request-123"; - mockMvc.perform(get("/actuator/health") + mockMvc.perform(get("/api/v1/health") .header("X-Request-Id", requestId)) .andExpect(status().isOk()) .andExpect(header().string("X-Request-Id", requestId)); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/IdempotencyCleanupTaskTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/IdempotencyCleanupTaskTest.java new file mode 100644 index 00000000..67294b40 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/IdempotencyCleanupTaskTest.java @@ -0,0 +1,54 @@ +package com.iflytek.skillhub.task; + +import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class IdempotencyCleanupTaskTest { + + @Mock + private IdempotencyRecordRepository idempotencyRecordRepository; + + private IdempotencyCleanupTask cleanupTask; + + @BeforeEach + void setUp() { + cleanupTask = new IdempotencyCleanupTask(idempotencyRecordRepository); + } + + @Test + void testCleanupExpiredRecords() { + when(idempotencyRecordRepository.deleteExpired(any(Instant.class))).thenReturn(5); + + cleanupTask.cleanupExpiredRecords(); + + verify(idempotencyRecordRepository).deleteExpired(any(Instant.class)); + } + + @Test + void testCleanupStaleProcessing() { + when(idempotencyRecordRepository.markStaleAsFailed(any(Instant.class))).thenReturn(3); + + cleanupTask.cleanupStaleProcessing(); + + verify(idempotencyRecordRepository).markStaleAsFailed(any(Instant.class)); + } + + @Test + void testCleanupStaleProcessingWithNoRecords() { + when(idempotencyRecordRepository.markStaleAsFailed(any(Instant.class))).thenReturn(0); + + cleanupTask.cleanupStaleProcessing(); + + verify(idempotencyRecordRepository).markStaleAsFailed(any(Instant.class)); + } +} diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index 5254e8c1..599a5049 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -1,4 +1,6 @@ spring: + main: + allow-bean-definition-overriding: true datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver @@ -17,6 +19,8 @@ spring: autoconfigure: 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/config/RedisTemplateConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/RedisTemplateConfig.java new file mode 100644 index 00000000..74ea5215 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/RedisTemplateConfig.java @@ -0,0 +1,34 @@ +package com.iflytek.skillhub.auth.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisTemplateConfig { + + @Bean + @ConditionalOnMissingBean(name = "redisTemplate") + public RedisTemplate redisTemplate( + RedisConnectionFactory connectionFactory, + ObjectMapper objectMapper) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + StringRedisSerializer keySerializer = new StringRedisSerializer(); + GenericJackson2JsonRedisSerializer valueSerializer = + new GenericJackson2JsonRedisSerializer(objectMapper); + + template.setKeySerializer(keySerializer); + template.setHashKeySerializer(keySerializer); + template.setValueSerializer(valueSerializer); + template.setHashValueSerializer(valueSerializer); + template.afterPropertiesSet(); + return template; + } +} 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..a8c94865 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,10 +67,13 @@ 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/**", - "/.well-known/**" + "/.well-known/**", + "/api/compat/v1/search", + "/api/compat/v1/resolve/**" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/skills", "/api/v1/skills/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/namespaces", "/api/v1/namespaces/*").permitAll() 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..56ffa0ef 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,7 @@ package com.iflytek.skillhub.auth.device; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @@ -12,7 +14,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 +22,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,84 +34,48 @@ 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"); + throw new DomainBadRequestException("error.deviceAuth.userCode.invalid"); } - // Get device code data DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); if (data == null) { - throw new IllegalArgumentException("Device code expired"); + throw new DomainBadRequestException("error.deviceAuth.deviceCode.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) { DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); if (data == null) { - throw new IllegalArgumentException("Device code expired or invalid"); + throw new DomainBadRequestException("error.deviceAuth.deviceCode.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 DomainBadRequestException("error.deviceAuth.deviceCode.used"); + }; } private String generateRandomDeviceCode() { @@ -120,9 +87,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(); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java index 5d56be18..993ae314 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.auth.device; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -83,8 +84,8 @@ class DeviceAuthServiceTest { // When / Then assertThatThrownBy(() -> service.pollToken("expired123")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("expired"); + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("error.deviceAuth.deviceCode.invalid"); } @Test diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyRecord.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyRecord.java new file mode 100644 index 00000000..e3fefcc1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyRecord.java @@ -0,0 +1,83 @@ +package com.iflytek.skillhub.domain.idempotency; + +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "idempotency_record") +public class IdempotencyRecord { + + @Id + @Column(name = "request_id", nullable = false, length = 255) + private String requestId; + + @Column(name = "resource_type", length = 100) + private String resourceType; + + @Column(name = "resource_id") + private Long resourceId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private IdempotencyStatus status; + + @Column(name = "response_status_code") + private Integer responseStatusCode; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + protected IdempotencyRecord() { + } + + public IdempotencyRecord(String requestId, String resourceType, Long resourceId, + IdempotencyStatus status, Integer responseStatusCode, + Instant createdAt, Instant expiresAt) { + this.requestId = requestId; + this.resourceType = resourceType; + this.resourceId = resourceId; + this.status = status; + this.responseStatusCode = responseStatusCode; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + } + + public String getRequestId() { + return requestId; + } + + public String getResourceType() { + return resourceType; + } + + public Long getResourceId() { + return resourceId; + } + + public IdempotencyStatus getStatus() { + return status; + } + + public void setStatus(IdempotencyStatus status) { + this.status = status; + } + + public Integer getResponseStatusCode() { + return responseStatusCode; + } + + public void setResponseStatusCode(Integer responseStatusCode) { + this.responseStatusCode = responseStatusCode; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyRecordRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyRecordRepository.java new file mode 100644 index 00000000..f4c298a3 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyRecordRepository.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.domain.idempotency; + +import java.time.Instant; +import java.util.Optional; + +public interface IdempotencyRecordRepository { + Optional findByRequestId(String requestId); + IdempotencyRecord save(IdempotencyRecord record); + int deleteExpired(Instant now); + int markStaleAsFailed(Instant threshold); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyStatus.java new file mode 100644 index 00000000..76391eb8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyStatus.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.idempotency; + +public enum IdempotencyStatus { + PROCESSING, + COMPLETED, + FAILED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java index 92751d46..8d496c63 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.social; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import jakarta.persistence.*; import java.time.LocalDateTime; @@ -28,14 +29,14 @@ public class SkillRating { protected SkillRating() {} public SkillRating(Long skillId, String userId, short score) { - if (score < 1 || score > 5) throw new IllegalArgumentException("Score must be 1-5"); + if (score < 1 || score > 5) throw new DomainBadRequestException("error.rating.score.invalid"); this.skillId = skillId; this.userId = userId; this.score = score; } public void updateScore(short newScore) { - if (newScore < 1 || newScore > 5) throw new IllegalArgumentException("Score must be 1-5"); + if (newScore < 1 || newScore > 5) throw new DomainBadRequestException("error.rating.score.invalid"); this.score = newScore; this.updatedAt = LocalDateTime.now(); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java index 22d722d1..814c1ff7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.social; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -21,7 +22,7 @@ public class SkillRatingService { @Transactional public void rate(Long skillId, String userId, short score) { if (score < 1 || score > 5) { - throw new IllegalArgumentException("Score must be 1-5"); + throw new DomainBadRequestException("error.rating.score.invalid"); } Optional existing = ratingRepository.findBySkillIdAndUserId(skillId, userId); if (existing.isPresent()) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 9054c09d..416d441e 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -130,6 +130,70 @@ class SkillPublishServiceTest { verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString()); } + @Test + void testPublishFromEntries_ShouldSlugifyNameBeforeLookupAndResponse() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: Smoke Skill Two\ndescription: Test\nversion: 0.2.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("Smoke Skill Two", "Test", "0.2.0", "Body", Map.of()); + + Skill skill = new Skill(1L, "smoke-skill-two", publisherId, SkillVisibility.PUBLIC); + setId(skill, 2L); + SkillVersion version = new SkillVersion(2L, "0.2.0", publisherId); + setId(version, 20L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("smoke-skill-two"))).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("0.2.0"))).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any())).thenReturn(version); + when(skillRepository.save(any())).thenReturn(skill); + + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC + ); + + assertEquals("smoke-skill-two", result.slug()); + verify(skillRepository).findByNamespaceIdAndSlug(1L, "smoke-skill-two"); + } + + @Test + void testPublishFromEntries_ShouldRejectMissingVersionBeforePersistence() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", null, "Body", Map.of()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + + assertThrows(DomainBadRequestException.class, () -> + service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC)); + verify(skillVersionRepository, never()).save(any()); + } + @Test void testPublishFromEntries_NamespaceNotFound() { // Arrange diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 549ce761..91b2cac9 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -313,6 +313,44 @@ class SkillQueryServiceTest { assertTrue(result.downloadUrl().contains("/versions/1.1.0/download")); } + @Test + void testResolveVersion_ShouldEncodeDownloadUrlPathSegments() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "smoke-skill-two"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Global", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0 beta", "user-100"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + SkillFile file = new SkillFile(11L, "SKILL.md", 10L, "text/markdown", "hash", "key"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, null, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version)); + when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); + + SkillQueryService.ResolvedVersionDTO result = service.resolveVersion( + namespaceSlug, + skillSlug, + null, + null, + null, + null, + userNsRoles + ); + + assertEquals("/api/v1/skills/global/smoke-skill-two/versions/1.0.0%20beta/download", result.downloadUrl()); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java index 00416c57..29ebd961 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.social; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -45,9 +46,9 @@ class SkillRatingServiceTest { @Test void rate_invalid_score_throws() { assertThatThrownBy(() -> service.rate(1L, "10", (short) 0)) - .isInstanceOf(IllegalArgumentException.class); + .isInstanceOf(DomainBadRequestException.class); assertThatThrownBy(() -> service.rate(1L, "10", (short) 6)) - .isInstanceOf(IllegalArgumentException.class); + .isInstanceOf(DomainBadRequestException.class); } @Test diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaIdempotencyRecordRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaIdempotencyRecordRepository.java new file mode 100644 index 00000000..8eafd1d7 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaIdempotencyRecordRepository.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.idempotency.IdempotencyRecord; +import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; + +@Repository +public interface JpaIdempotencyRecordRepository extends JpaRepository, IdempotencyRecordRepository { + + @Modifying + @Query("DELETE FROM IdempotencyRecord i WHERE i.expiresAt < :now") + int deleteExpired(@Param("now") Instant now); + + @Modifying + @Query("UPDATE IdempotencyRecord i SET i.status = com.iflytek.skillhub.domain.idempotency.IdempotencyStatus.FAILED WHERE i.status = com.iflytek.skillhub.domain.idempotency.IdempotencyStatus.PROCESSING AND i.createdAt < :threshold") + int markStaleAsFailed(@Param("threshold") Instant threshold); +} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d903146c..819ac1d8 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -10,6 +10,11 @@ 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 { AdminUsersPage } from '@/pages/admin/users' +import { AuditLogPage } from '@/pages/admin/audit-log' import { getCurrentUser } from '@/api/client' const rootRoute = createRootRoute({ @@ -118,6 +123,70 @@ 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 adminUsersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/admin/users', + beforeLoad: async () => { + const user = await getCurrentUser() + if (!user) { + throw redirect({ to: '/login' }) + } + if (!user.platformRoles?.includes('USER_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) { + throw redirect({ to: '/dashboard' }) + } + return { user } + }, + component: AdminUsersPage, +}) + +const adminAuditLogRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/admin/audit-log', + beforeLoad: async () => { + const user = await getCurrentUser() + if (!user) { + throw redirect({ to: '/login' }) + } + if (!user.platformRoles?.includes('AUDITOR') && !user.platformRoles?.includes('SUPER_ADMIN')) { + throw redirect({ to: '/dashboard' }) + } + return { user } + }, + component: AuditLogPage, +}) + const routeTree = rootRoute.addChildren([ homeRoute, loginRoute, @@ -129,6 +198,11 @@ const routeTree = rootRoute.addChildren([ dashboardPublishRoute, dashboardNamespacesRoute, dashboardNamespaceMembersRoute, + dashboardReviewsRoute, + dashboardReviewDetailRoute, + deviceRoute, + adminUsersRoute, + adminAuditLogRoute, ]) export const router = createRouter({ routeTree }) diff --git a/web/src/features/admin/use-admin-users.ts b/web/src/features/admin/use-admin-users.ts new file mode 100644 index 00000000..abc46b53 --- /dev/null +++ b/web/src/features/admin/use-admin-users.ts @@ -0,0 +1,81 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { fetchJson, getCsrfHeaders } from '@/api/client' + +export interface AdminUser { + id: string + username: string + email: string + status: 'ACTIVE' | 'DISABLED' + platformRoles: string[] + createdAt: string +} + +export interface AdminUsersParams { + search?: string + status?: string + page?: number + size?: number +} + +export interface PagedAdminUsers { + items: AdminUser[] + total: number + page: number + size: number +} + +async function getAdminUsers(params: AdminUsersParams): Promise { + const searchParams = new URLSearchParams() + if (params.search) searchParams.set('search', params.search) + if (params.status) searchParams.set('status', params.status) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + + const url = `/api/v1/admin/users?${searchParams.toString()}` + return fetchJson(url) +} + +async function updateUserRole(userId: string, role: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/role`, { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ role }), + }) +} + +async function updateUserStatus(userId: string, status: 'ACTIVE' | 'DISABLED'): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/status`, { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ status }), + }) +} + +export function useAdminUsers(params: AdminUsersParams) { + return useQuery({ + queryKey: ['admin', 'users', params], + queryFn: () => getAdminUsers(params), + }) +} + +export function useUpdateUserRole() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ userId, role }: { userId: string; role: string }) => + updateUserRole(userId, role), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} + +export function useUpdateUserStatus() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ userId, status }: { userId: string; status: 'ACTIVE' | 'DISABLED' }) => + updateUserStatus(userId, status), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} diff --git a/web/src/features/admin/use-audit-log.ts b/web/src/features/admin/use-audit-log.ts new file mode 100644 index 00000000..7a2a3649 --- /dev/null +++ b/web/src/features/admin/use-audit-log.ts @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchJson } from '@/api/client' + +export interface AuditLog { + id: number + action: string + userId: string + username?: string + details?: string + ipAddress?: string + timestamp: string +} + +export interface AuditLogParams { + action?: string + userId?: string + page?: number + size?: number +} + +export interface PagedAuditLogs { + items: AuditLog[] + total: number + page: number + size: number +} + +async function getAuditLogs(params: AuditLogParams): Promise { + const searchParams = new URLSearchParams() + if (params.action) searchParams.set('action', params.action) + if (params.userId) searchParams.set('userId', params.userId) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + + const url = `/api/v1/admin/audit-logs?${searchParams.toString()}` + return fetchJson(url) +} + +export function useAuditLog(params: AuditLogParams) { + return useQuery({ + queryKey: ['admin', 'audit-logs', params], + queryFn: () => getAuditLogs(params), + }) +} 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/admin/audit-log.tsx b/web/src/pages/admin/audit-log.tsx new file mode 100644 index 00000000..67399088 --- /dev/null +++ b/web/src/pages/admin/audit-log.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react' +import { Card } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Button } from '@/shared/ui/button' +import { Select } from '@/shared/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/shared/ui/table' +import { useAuditLog } from '@/features/admin/use-audit-log' + +export function AuditLogPage() { + const [actionFilter, setActionFilter] = useState('') + const [userIdFilter, setUserIdFilter] = useState('') + const [page, setPage] = useState(0) + + const { data, isLoading } = useAuditLog({ + action: actionFilter || undefined, + userId: userIdFilter || undefined, + page, + size: 20, + }) + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString('zh-CN') + } + + return ( +
+
+

审计日志

+

查看系统操作记录

+
+ + +
+ + setUserIdFilter(e.target.value)} + className="w-[200px]" + /> +
+
+ + {isLoading ? ( +
加载中...
+ ) : !data || data.items.length === 0 ? ( + +

暂无审计日志

+
+ ) : ( + <> + + + + + 时间 + 操作 + 用户 ID + 用户名 + IP 地址 + 详情 + + + + {data.items.map((log) => ( + + {formatDate(log.timestamp)} + {log.action} + {log.userId} + {log.username || '-'} + {log.ipAddress || '-'} + + {log.details || '-'} + + + ))} + +
+
+ +
+

+ 共 {data.total} 条记录,第 {page + 1} 页 +

+
+ + +
+
+ + )} +
+ ) +} diff --git a/web/src/pages/admin/users.tsx b/web/src/pages/admin/users.tsx new file mode 100644 index 00000000..725f7705 --- /dev/null +++ b/web/src/pages/admin/users.tsx @@ -0,0 +1,258 @@ +import { useState } from 'react' +import { Card } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Button } from '@/shared/ui/button' +import { Select } from '@/shared/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/shared/ui/table' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { Label } from '@/shared/ui/label' +import { useAdminUsers, useUpdateUserRole, useUpdateUserStatus } from '@/features/admin/use-admin-users' +import type { AdminUser } from '@/features/admin/use-admin-users' + +export function AdminUsersPage() { + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('') + const [page, setPage] = useState(0) + const [selectedUser, setSelectedUser] = useState(null) + const [roleDialogOpen, setRoleDialogOpen] = useState(false) + const [newRole, setNewRole] = useState('') + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false) + const [actionType, setActionType] = useState<'ban' | 'unban'>('ban') + + const { data, isLoading } = useAdminUsers({ + search, + status: statusFilter || undefined, + page, + size: 20, + }) + + const updateRoleMutation = useUpdateUserRole() + const updateStatusMutation = useUpdateUserStatus() + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString('zh-CN') + } + + const handleChangeRole = (user: AdminUser) => { + setSelectedUser(user) + setNewRole(user.platformRoles[0] || '') + setRoleDialogOpen(true) + } + + const handleToggleStatus = (user: AdminUser, action: 'ban' | 'unban') => { + setSelectedUser(user) + setActionType(action) + setConfirmDialogOpen(true) + } + + const confirmRoleChange = async () => { + if (!selectedUser) return + try { + await updateRoleMutation.mutateAsync({ userId: selectedUser.id, role: newRole }) + setRoleDialogOpen(false) + setSelectedUser(null) + } catch (error) { + console.error('Failed to update role:', error) + } + } + + const confirmStatusChange = async () => { + if (!selectedUser) return + try { + const newStatus = actionType === 'ban' ? 'DISABLED' : 'ACTIVE' + await updateStatusMutation.mutateAsync({ userId: selectedUser.id, status: newStatus }) + setConfirmDialogOpen(false) + setSelectedUser(null) + } catch (error) { + console.error('Failed to update status:', error) + } + } + + return ( +
+
+

用户管理

+

管理平台用户和权限

+
+ + +
+ setSearch(e.target.value)} + className="flex-1" + /> + +
+
+ + {isLoading ? ( +
加载中...
+ ) : !data || data.items.length === 0 ? ( + +

暂无用户数据

+
+ ) : ( + <> + + + + + 用户名 + 邮箱 + 状态 + 角色 + 创建时间 + 操作 + + + + {data.items.map((user) => ( + + {user.username} + {user.email} + + + {user.status === 'ACTIVE' ? '活跃' : '已禁用'} + + + {user.platformRoles.join(', ')} + {formatDate(user.createdAt)} + +
+ + {user.status === 'ACTIVE' ? ( + + ) : ( + + )} +
+
+
+ ))} +
+
+
+ +
+

+ 共 {data.total} 条记录,第 {page + 1} 页 +

+
+ + +
+
+ + )} + + + + + 修改用户角色 + + 为用户 {selectedUser?.username} 分配新角色 + + +
+
+ + +
+
+ + + + +
+
+ + + + + 确认操作 + + 确定要{actionType === 'ban' ? '禁用' : '启用'}用户 {selectedUser?.username} 吗? + + + + + + + + +
+ ) +} 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' && ( + +

审核操作

+ +
+ +