Merge branch 'main' into feature/project-review

This commit is contained in:
tlzhu3 2026-03-12 20:50:19 +08:00
commit 4989aa2ecc
78 changed files with 3211 additions and 328 deletions

6
.gitignore vendored
View file

@ -57,3 +57,9 @@ tmp/
# Git worktrees
.worktrees/
# Local dev runtime
.dev/
# Superpowers (AI planning artifacts)
docs/superpowers/

View file

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

127
README.md
View file

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

View file

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

View file

@ -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 数据库迁移

View file

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

View file

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

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.compat;
public record SkillCoordinate(String namespace, String slug) {}

View file

@ -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<String, String> clawhubConfig() {
return Map.of("apiBase", "/api/compat/v1");
}
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.compat.dto;
public record ClawHubPublishResponse(
String canonicalSlug,
String version,
String status
) {}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.compat.dto;
public record ClawHubResolveResponse(
String canonicalSlug,
String version,
String downloadUrl
) {}

View file

@ -0,0 +1,5 @@
package com.iflytek.skillhub.compat.dto;
import java.util.List;
public record ClawHubSearchResponse(List<ClawHubSkillItem> items) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.compat.dto;
public record ClawHubSkillItem(
String canonicalSlug,
String description,
String latestVersion,
int starCount
) {}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.compat.dto;
public record ClawHubWhoamiResponse(
String userId,
String displayName,
String email
) {}

View file

@ -1,21 +1,34 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.CliWhoamiResponse;
import com.iflytek.skillhub.dto.SkillCheckResponse;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.iflytek.skillhub.exception.UnauthorizedException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@RestController
@RequestMapping("/api/v1/cli")
public class CliController extends BaseApiController {
public CliController(ApiResponseFactory responseFactory) {
private final SkillPackageValidator skillPackageValidator;
public CliController(ApiResponseFactory responseFactory,
SkillPackageValidator skillPackageValidator) {
super(responseFactory);
this.skillPackageValidator = skillPackageValidator;
}
@GetMapping("/whoami")
@ -26,4 +39,50 @@ public class CliController extends BaseApiController {
return ok("response.success.read", CliWhoamiResponse.from(principal));
}
@PostMapping("/check")
public ApiResponse<SkillCheckResponse> check(@RequestParam("file") MultipartFile file) throws IOException {
List<PackageEntry> entries = extractZipEntries(file);
ValidationResult result = skillPackageValidator.validate(entries);
SkillCheckResponse response = new SkillCheckResponse(
result.passed(),
result.errors(),
entries.size(),
entries.stream().mapToLong(PackageEntry::size).sum()
);
return ok("response.success.validated", response);
}
private List<PackageEntry> extractZipEntries(MultipartFile file) throws IOException {
List<PackageEntry> entries = new ArrayList<>();
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
ZipEntry zipEntry;
while ((zipEntry = zis.getNextEntry()) != null) {
if (!zipEntry.isDirectory()) {
byte[] content = zis.readAllBytes();
entries.add(new PackageEntry(
zipEntry.getName(),
content,
content.length,
determineContentType(zipEntry.getName())
));
}
zis.closeEntry();
}
}
return entries;
}
private String determineContentType(String filename) {
if (filename.endsWith(".py")) return "text/x-python";
if (filename.endsWith(".json")) return "application/json";
if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml";
if (filename.endsWith(".txt")) return "text/plain";
if (filename.endsWith(".md")) return "text/markdown";
return "application/octet-stream";
}
}

View file

@ -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<DeviceCodeResponse> requestDeviceCode() {
return ok("response.success.created", deviceAuthService.generateDeviceCode());
}
@PostMapping("/token")
public DeviceTokenResponse pollToken(@RequestBody TokenRequest request) {
return deviceAuthService.pollToken(request.deviceCode());
public ApiResponse<DeviceTokenResponse> pollToken(@RequestBody TokenRequest request) {
return ok("response.success.read", deviceAuthService.pollToken(request.deviceCode()));
}
public record TokenRequest(String deviceCode) {}

View file

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

View file

@ -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<PageResponse<AuditLogItemResponse>> listAuditLogs(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String userId,
@RequestParam(required = false) String action) {
List<AuditLogItemResponse> 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)));
}
}

View file

@ -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<PageResponse<AdminUserSummaryResponse>> listUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
List<AdminUserSummaryResponse> 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<AdminUserMutationResponse> 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<AdminUserMutationResponse> updateUserStatus(
@PathVariable String userId,
@Valid @RequestBody AdminUserStatusUpdateRequest request) {
return ok("response.success.updated", new AdminUserMutationResponse(userId, null, request.status()));
}
}

View file

@ -69,7 +69,7 @@ public class PromotionController extends BaseApiController {
String comment = request != null ? request.comment() : null;
Set<String> 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<String> 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")

View file

@ -74,7 +74,7 @@ public class ReviewController extends BaseApiController {
Set<String> 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<String> 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")

View file

@ -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<Void> rateSkill(
public ApiResponse<Void> rateSkill(
@PathVariable Long skillId,
@RequestBody Map<String, Short> 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<Map<String, Object>> getUserRating(
public ApiResponse<SkillRatingStatusResponse> getUserRating(
@PathVariable Long skillId,
@AuthenticationPrincipal PlatformPrincipal principal) {
Optional<Short> rating = skillRatingService.getUserRating(skillId, principal.userId());
Map<String, Object> 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);
}
}

View file

@ -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<Void> starSkill(
public ApiResponse<Void> 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<Void> unstarSkill(
public ApiResponse<Void> 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);
}
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
public record AdminUserMutationResponse(
String userId,
String role,
String status
) {
}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record AdminUserRoleUpdateRequest(
@NotBlank(message = "{error.badRequest}")
String role
) {
}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record AdminUserStatusUpdateRequest(
@NotBlank(message = "{error.badRequest}")
String status
) {
}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.dto;
public record AdminUserSummaryResponse(
String userId,
String username,
String role,
String status
) {
}

View file

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

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
import java.util.List;
public record SkillCheckResponse(
boolean valid,
List<String> errors,
int fileCount,
long totalSize
) {}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotNull;
public record SkillRatingRequest(
@NotNull(message = "{error.badRequest}")
Short score
) {
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.dto;
public record SkillRatingStatusResponse(
short score,
boolean rated
) {
}

View file

@ -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<IdempotencyRecord> 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<IdempotencyRecord> 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<Void> body = new ApiResponse<>(409, "error.request.duplicate", null,
Instant.now(), null);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(body));
}
}

View file

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

View file

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

View file

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

View file

@ -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=设备验证码已被使用

View file

@ -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<String, Object> redisTemplate() {
return Mockito.mock(RedisTemplate.class);
}
@Bean
@Primary
public StringRedisTemplate stringRedisTemplate() {
return Mockito.mock(StringRedisTemplate.class);
}
}

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import org.junit.jupiter.api.Test;
@ -7,17 +8,22 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -32,6 +38,9 @@ class CliControllerTest {
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@Test
void whoamiShouldReturnUnauthorizedForAnonymousRequest() throws Exception {
mockMvc.perform(get("/api/v1/cli/whoami"))
@ -68,4 +77,118 @@ class CliControllerTest {
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());
}
@Test
void checkShouldReturnValidForValidPackage() throws Exception {
byte[] zipBytes = createValidSkillZip();
MockMultipartFile file = new MockMultipartFile(
"file",
"skill.zip",
"application/zip",
zipBytes
);
mockMvc.perform(multipart("/api/v1/cli/check").file(file))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.valid").value(true))
.andExpect(jsonPath("$.data.errors").isEmpty())
.andExpect(jsonPath("$.data.fileCount").value(2))
.andExpect(jsonPath("$.data.totalSize").isNumber());
}
@Test
void checkShouldReturnInvalidForMissingSkillMd() throws Exception {
byte[] zipBytes = createInvalidSkillZip();
MockMultipartFile file = new MockMultipartFile(
"file",
"skill.zip",
"application/zip",
zipBytes
);
mockMvc.perform(multipart("/api/v1/cli/check").file(file))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.valid").value(false))
.andExpect(jsonPath("$.data.errors").isNotEmpty())
.andExpect(jsonPath("$.data.errors[0]").value("Missing required file: SKILL.md at root"));
}
@Test
void checkShouldReturnInvalidForDisallowedExtension() throws Exception {
byte[] zipBytes = createZipWithDisallowedFile();
MockMultipartFile file = new MockMultipartFile(
"file",
"skill.zip",
"application/zip",
zipBytes
);
mockMvc.perform(multipart("/api/v1/cli/check").file(file))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.valid").value(false))
.andExpect(jsonPath("$.data.errors").isNotEmpty());
}
private byte[] createValidSkillZip() throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
String skillMdContent = """
---
name: test-skill
description: A test skill
version: 1.0.0
---
# Test Skill
This is a test skill.
""";
ZipEntry skillMdEntry = new ZipEntry("SKILL.md");
zos.putNextEntry(skillMdEntry);
zos.write(skillMdContent.getBytes());
zos.closeEntry();
ZipEntry readmeEntry = new ZipEntry("README.md");
zos.putNextEntry(readmeEntry);
zos.write("# README\nThis is a readme.".getBytes());
zos.closeEntry();
}
return baos.toByteArray();
}
private byte[] createInvalidSkillZip() throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
ZipEntry readmeEntry = new ZipEntry("README.md");
zos.putNextEntry(readmeEntry);
zos.write("# README".getBytes());
zos.closeEntry();
}
return baos.toByteArray();
}
private byte[] createZipWithDisallowedFile() throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
String skillMdContent = """
---
name: test-skill
description: A test skill
version: 1.0.0
---
# Test Skill
""";
ZipEntry skillMdEntry = new ZipEntry("SKILL.md");
zos.putNextEntry(skillMdEntry);
zos.write(skillMdContent.getBytes());
zos.closeEntry();
ZipEntry exeEntry = new ZipEntry("malware.exe");
zos.putNextEntry(exeEntry);
zos.write("bad content".getBytes());
zos.closeEntry();
}
return baos.toByteArray();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper) {
RedisTemplate<String, Object> 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;
}
}

View file

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

View file

@ -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<String, Object> redisTemplate, String verificationUri) {
public DeviceAuthService(RedisTemplate<String, Object> 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();

View file

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

View file

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

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.domain.idempotency;
import java.time.Instant;
import java.util.Optional;
public interface IdempotencyRecordRepository {
Optional<IdempotencyRecord> findByRequestId(String requestId);
IdempotencyRecord save(IdempotencyRecord record);
int deleteExpired(Instant now);
int markStaleAsFailed(Instant threshold);
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.domain.idempotency;
public enum IdempotencyStatus {
PROCESSING,
COMPLETED,
FAILED
}

View file

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

View file

@ -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<SkillRating> existing = ratingRepository.findBySkillIdAndUserId(skillId, userId);
if (existing.isPresent()) {

View file

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

View file

@ -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<Long, NamespaceRole> 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);

View file

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

View file

@ -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<IdempotencyRecord, String>, 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);
}

View file

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

View file

@ -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<PagedAdminUsers> {
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<PagedAdminUsers>(url)
}
async function updateUserRole(userId: string, role: string): Promise<void> {
await fetchJson<void>(`/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<void> {
await fetchJson<void>(`/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'] })
},
})
}

View file

@ -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<PagedAuditLogs> {
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<PagedAuditLogs>(url)
}
export function useAuditLog(params: AuditLogParams) {
return useQuery({
queryKey: ['admin', 'audit-logs', params],
queryFn: () => getAuditLogs(params),
})
}

View file

@ -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<ReviewTask> {
return fetchJson<ReviewTask>(`/api/v1/reviews/${taskId}`)
}
async function approveReview(taskId: number, comment?: string): Promise<void> {
await fetchJson<void>(`/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<void> {
await fetchJson<void>(`/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'] })
},
})
}

View file

@ -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<ReviewTask[]> {
const url = status ? `/api/v1/reviews?status=${status}` : '/api/v1/reviews'
return fetchJson<ReviewTask[]>(url)
}
export function useReviewList(status?: string) {
return useQuery({
queryKey: ['reviews', status],
queryFn: () => getReviewList(status),
})
}

View file

@ -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<number | null>(null)
const currentRating = userRating?.rating || 0
const handleRate = (rating: number) => {
rateMutation.mutate(rating)
}
if (isLoading) {
return null
}
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((rating) => {
const isFilled = rating <= (hoveredRating || currentRating)
return (
<button
key={rating}
type="button"
className="p-1 hover:scale-110 transition-transform"
onMouseEnter={() => setHoveredRating(rating)}
onMouseLeave={() => setHoveredRating(null)}
onClick={() => handleRate(rating)}
disabled={rateMutation.isPending}
>
<Star
className={`w-5 h-5 ${
isFilled
? 'fill-yellow-400 text-yellow-400'
: 'text-gray-300'
}`}
/>
</button>
)
})}
</div>
{currentRating > 0 && (
<span className="text-sm text-muted-foreground">
你的评分: {currentRating} 星
</span>
)}
</div>
)
}

View file

@ -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 (
<Button
variant={starStatus.starred ? 'default' : 'outline'}
size="sm"
onClick={handleToggle}
disabled={toggleMutation.isPending}
>
<Star className={`w-4 h-4 mr-2 ${starStatus.starred ? 'fill-current' : ''}`} />
{starStatus.starred ? '已收藏' : '收藏'} ({starStatus.starCount})
</Button>
)
}

View file

@ -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<UserRating> {
return fetchJson<UserRating>(`/api/v1/skills/${skillId}/rating`)
}
async function rateSkill(skillId: number, rating: number): Promise<void> {
await fetchJson<void>(`/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'] })
},
})
}

View file

@ -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<StarStatus> {
return fetchJson<StarStatus>(`/api/v1/skills/${skillId}/star`)
}
async function toggleStar(skillId: number, starred: boolean): Promise<void> {
if (starred) {
await fetchJson<void>(`/api/v1/skills/${skillId}/star`, {
method: 'DELETE',
headers: getCsrfHeaders(),
})
} else {
await fetchJson<void>(`/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'] })
},
})
}

View file

@ -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<string>('')
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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">审计日志</h1>
<p className="text-muted-foreground">查看系统操作记录</p>
</div>
<Card className="p-4">
<div className="flex gap-4">
<Select value={actionFilter} onChange={(e) => setActionFilter(e.target.value)} className="w-[200px]">
<option value="">全部</option>
<option value="USER_LOGIN">用户登录</option>
<option value="USER_LOGOUT">用户登出</option>
<option value="USER_ROLE_CHANGE">角色变更</option>
<option value="USER_STATUS_CHANGE">状态变更</option>
<option value="SKILL_PUBLISH">技能发布</option>
<option value="SKILL_REVIEW">技能审核</option>
</Select>
<Input
placeholder="用户 ID..."
value={userIdFilter}
onChange={(e) => setUserIdFilter(e.target.value)}
className="w-[200px]"
/>
</div>
</Card>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">加载中...</div>
) : !data || data.items.length === 0 ? (
<Card className="p-12 text-center">
<p className="text-muted-foreground">暂无审计日志</p>
</Card>
) : (
<>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>时间</TableHead>
<TableHead>操作</TableHead>
<TableHead>用户 ID</TableHead>
<TableHead>用户名</TableHead>
<TableHead>IP 地址</TableHead>
<TableHead>详情</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((log) => (
<TableRow key={log.id}>
<TableCell>{formatDate(log.timestamp)}</TableCell>
<TableCell className="font-medium">{log.action}</TableCell>
<TableCell>{log.userId}</TableCell>
<TableCell>{log.username || '-'}</TableCell>
<TableCell>{log.ipAddress || '-'}</TableCell>
<TableCell className="max-w-md truncate">
{log.details || '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
共 {data.total} 条记录,第 {page + 1} 页
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={(page + 1) * 20 >= data.total}
onClick={() => setPage(page + 1)}
>
下一页
</Button>
</div>
</div>
</>
)}
</div>
)
}

View file

@ -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<string>('')
const [page, setPage] = useState(0)
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">用户管理</h1>
<p className="text-muted-foreground">管理平台用户和权限</p>
</div>
<Card className="p-4">
<div className="flex gap-4">
<Input
placeholder="搜索用户名或邮箱..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1"
/>
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">全部</option>
<option value="ACTIVE">活跃</option>
<option value="DISABLED">已禁用</option>
</Select>
</div>
</Card>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">加载中...</div>
) : !data || data.items.length === 0 ? (
<Card className="p-12 text-center">
<p className="text-muted-foreground">暂无用户数据</p>
</Card>
) : (
<>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>用户名</TableHead>
<TableHead>邮箱</TableHead>
<TableHead>状态</TableHead>
<TableHead>角色</TableHead>
<TableHead>创建时间</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
user.status === 'ACTIVE'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{user.status === 'ACTIVE' ? '活跃' : '已禁用'}
</span>
</TableCell>
<TableCell>{user.platformRoles.join(', ')}</TableCell>
<TableCell>{formatDate(user.createdAt)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleChangeRole(user)}
>
修改角色
</Button>
{user.status === 'ACTIVE' ? (
<Button
variant="outline"
size="sm"
onClick={() => handleToggleStatus(user, 'ban')}
>
禁用
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => handleToggleStatus(user, 'unban')}
>
启用
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
共 {data.total} 条记录,第 {page + 1} 页
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={(page + 1) * 20 >= data.total}
onClick={() => setPage(page + 1)}
>
下一页
</Button>
</div>
</div>
</>
)}
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>修改用户角色</DialogTitle>
<DialogDescription>
为用户 {selectedUser?.username} 分配新角色
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="role">角色</Label>
<Select id="role" value={newRole} onChange={(e) => setNewRole(e.target.value)}>
<option value="">选择角色</option>
<option value="USER">普通用户</option>
<option value="REVIEWER">审核员</option>
<option value="USER_ADMIN">用户管理员</option>
<option value="AUDITOR">审计员</option>
<option value="SUPER_ADMIN">超级管理员</option>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
取消
</Button>
<Button onClick={confirmRoleChange} disabled={updateRoleMutation.isPending}>
确认
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>确认操作</DialogTitle>
<DialogDescription>
确定要{actionType === 'ban' ? '禁用' : '启用'}用户 {selectedUser?.username} 吗?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmDialogOpen(false)}>
取消
</Button>
<Button onClick={confirmStatusChange} disabled={updateStatusMutation.isPending}>
确认
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -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 <div className="text-center py-8 text-muted-foreground">加载中...</div>
}
if (!review) {
return <div className="text-center py-8 text-muted-foreground">审核任务不存在</div>
}
return (
<div className="space-y-6 max-w-3xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold mb-2">审核详情</h1>
<p className="text-muted-foreground">审核 ID: {review.id}</p>
</div>
<Button variant="outline" onClick={() => navigate({ to: '/dashboard/reviews' })}>
返回列表
</Button>
</div>
<Card className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground">技能名称</Label>
<p className="font-medium">{review.skillName}</p>
</div>
<div>
<Label className="text-muted-foreground">命名空间/标识</Label>
<p className="font-medium">{review.namespace}/{review.skillSlug}</p>
</div>
<div>
<Label className="text-muted-foreground">版本</Label>
<p className="font-medium">{review.version}</p>
</div>
<div>
<Label className="text-muted-foreground">状态</Label>
<p className="font-medium">
{review.status === 'PENDING' && '待审核'}
{review.status === 'APPROVED' && '已通过'}
{review.status === 'REJECTED' && '已拒绝'}
</p>
</div>
<div>
<Label className="text-muted-foreground">提交者</Label>
<p className="font-medium">{review.submittedBy}</p>
</div>
<div>
<Label className="text-muted-foreground">提交时间</Label>
<p className="font-medium">{formatDate(review.submittedAt)}</p>
</div>
{review.reviewedBy && (
<>
<div>
<Label className="text-muted-foreground">审核者</Label>
<p className="font-medium">{review.reviewedBy}</p>
</div>
<div>
<Label className="text-muted-foreground">审核时间</Label>
<p className="font-medium">
{review.reviewedAt ? formatDate(review.reviewedAt) : '-'}
</p>
</div>
</>
)}
</div>
{review.comment && (
<div>
<Label className="text-muted-foreground">审核意见</Label>
<p className="mt-2 p-3 bg-muted rounded-md">{review.comment}</p>
</div>
)}
</Card>
{review.status === 'PENDING' && (
<Card className="p-6 space-y-4">
<h2 className="text-xl font-semibold">审核操作</h2>
<div className="space-y-2">
<Label htmlFor="comment">审核意见(可选)</Label>
<Textarea
id="comment"
placeholder="填写审核意见..."
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
/>
</div>
<div className="flex gap-3">
<Button
onClick={handleApprove}
disabled={approveMutation.isPending || rejectMutation.isPending}
>
通过审核
</Button>
{!showRejectForm ? (
<Button
variant="destructive"
onClick={() => setShowRejectForm(true)}
disabled={approveMutation.isPending || rejectMutation.isPending}
>
拒绝审核
</Button>
) : (
<>
<Button
variant="destructive"
onClick={handleReject}
disabled={approveMutation.isPending || rejectMutation.isPending || !comment.trim()}
>
确认拒绝
</Button>
<Button
variant="outline"
onClick={() => setShowRejectForm(false)}
disabled={approveMutation.isPending || rejectMutation.isPending}
>
取消
</Button>
</>
)}
</div>
{showRejectForm && !comment.trim() && (
<p className="text-sm text-destructive">拒绝审核时必须填写原因</p>
)}
</Card>
)}
</div>
)
}

View file

@ -0,0 +1,111 @@
import { useNavigate } from '@tanstack/react-router'
import { Card } from '@/shared/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/shared/ui/table'
import { useReviewList } from '@/features/review/use-review-list'
export function ReviewsPage() {
const navigate = useNavigate()
const { data: pendingReviews, isLoading: isPendingLoading } = useReviewList('PENDING')
const { data: approvedReviews, isLoading: isApprovedLoading } = useReviewList('APPROVED')
const { data: rejectedReviews, isLoading: isRejectedLoading } = useReviewList('REJECTED')
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('zh-CN')
}
const handleRowClick = (reviewId: number) => {
navigate({ to: '/dashboard/reviews/$id', params: { id: String(reviewId) } })
}
const renderReviewTable = (reviews: typeof pendingReviews, isLoading: boolean, status: string) => {
if (isLoading) {
return <div className="text-center py-8 text-muted-foreground">加载中...</div>
}
if (!reviews || reviews.length === 0) {
return (
<Card className="p-12 text-center">
<p className="text-muted-foreground">暂无审核任务</p>
</Card>
)
}
return (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>技能</TableHead>
<TableHead>版本</TableHead>
<TableHead>提交者</TableHead>
<TableHead>提交时间</TableHead>
{status !== 'PENDING' && <TableHead>审核者</TableHead>}
{status !== 'PENDING' && <TableHead>审核时间</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{reviews.map((review) => (
<TableRow
key={review.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(review.id)}
>
<TableCell className="font-medium">
{review.namespace}/{review.skillSlug}
</TableCell>
<TableCell>{review.version}</TableCell>
<TableCell>{review.submittedBy}</TableCell>
<TableCell>{formatDate(review.submittedAt)}</TableCell>
{status !== 'PENDING' && (
<TableCell>{review.reviewedBy || '-'}</TableCell>
)}
{status !== 'PENDING' && (
<TableCell>
{review.reviewedAt ? formatDate(review.reviewedAt) : '-'}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</Card>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">审核中心</h1>
<p className="text-muted-foreground">管理技能版本审核</p>
</div>
<Tabs defaultValue="PENDING">
<TabsList>
<TabsTrigger value="PENDING">待审核</TabsTrigger>
<TabsTrigger value="APPROVED">已通过</TabsTrigger>
<TabsTrigger value="REJECTED">已拒绝</TabsTrigger>
</TabsList>
<TabsContent value="PENDING" className="mt-4">
{renderReviewTable(pendingReviews, isPendingLoading, 'PENDING')}
</TabsContent>
<TabsContent value="APPROVED" className="mt-4">
{renderReviewTable(approvedReviews, isApprovedLoading, 'APPROVED')}
</TabsContent>
<TabsContent value="REJECTED" className="mt-4">
{renderReviewTable(rejectedReviews, isRejectedLoading, 'REJECTED')}
</TabsContent>
</Tabs>
</div>
)
}

156
web/src/pages/device.tsx Normal file
View file

@ -0,0 +1,156 @@
import { useState, useRef, useEffect } from 'react'
import { Card } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { fetchJson, getCsrfHeaders } from '@/api/client'
async function authorizeDevice(userCode: string): Promise<void> {
await fetchJson<void>('/api/v1/device/authorize', {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ userCode }),
})
}
export function DeviceAuthPage() {
const [part1, setPart1] = useState('')
const [part2, setPart2] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const input1Ref = useRef<HTMLInputElement>(null)
const input2Ref = useRef<HTMLInputElement>(null)
useEffect(() => {
input1Ref.current?.focus()
}, [])
const handlePart1Change = (value: string) => {
const cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 4)
setPart1(cleaned)
if (cleaned.length === 4) {
input2Ref.current?.focus()
}
}
const handlePart2Change = (value: string) => {
const cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 4)
setPart2(cleaned)
}
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault()
const pasted = e.clipboardData.getData('text').toUpperCase().replace(/[^A-Z0-9]/g, '')
if (pasted.length >= 8) {
setPart1(pasted.slice(0, 4))
setPart2(pasted.slice(4, 8))
input2Ref.current?.focus()
} else if (pasted.length > 0) {
setPart1(pasted.slice(0, 4))
if (pasted.length > 4) {
setPart2(pasted.slice(4))
}
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (part1.length !== 4 || part2.length !== 4) {
setMessage({ type: 'error', text: '请输入完整的 8 位用户码' })
return
}
const userCode = `${part1}-${part2}`
setIsSubmitting(true)
setMessage(null)
try {
await authorizeDevice(userCode)
setMessage({ type: 'success', text: '设备授权成功!' })
setPart1('')
setPart2('')
input1Ref.current?.focus()
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : '授权失败,请检查用户码是否正确'
})
} finally {
setIsSubmitting(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md p-8 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold">设备授权</h1>
<p className="text-muted-foreground">
请输入设备上显示的 8 位用户码
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label>用户码</Label>
<div className="flex items-center gap-3">
<Input
ref={input1Ref}
type="text"
value={part1}
onChange={(e) => handlePart1Change(e.target.value)}
onPaste={handlePaste}
placeholder="XXXX"
className="text-center text-2xl font-mono tracking-wider"
maxLength={4}
/>
<span className="text-2xl font-bold text-muted-foreground">-</span>
<Input
ref={input2Ref}
type="text"
value={part2}
onChange={(e) => handlePart2Change(e.target.value)}
onPaste={handlePaste}
placeholder="XXXX"
className="text-center text-2xl font-mono tracking-wider"
maxLength={4}
/>
</div>
<p className="text-sm text-muted-foreground">
格式: XXXX-XXXX (支持粘贴)
</p>
</div>
{message && (
<div
className={`p-4 rounded-md ${
message.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
{message.text}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isSubmitting || part1.length !== 4 || part2.length !== 4}
>
{isSubmitting ? '授权中...' : '授权设备'}
</Button>
</form>
<div className="text-center text-sm text-muted-foreground">
<p>授权后,设备将可以访问你的账户</p>
</div>
</Card>
</div>
)
}

View file

@ -0,0 +1,22 @@
import * as React from 'react'
import { cn } from '@/shared/lib/utils'
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }