mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
Merge branch 'main' into feature/project-review
This commit is contained in:
commit
4989aa2ecc
78 changed files with 3211 additions and 328 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -57,3 +57,9 @@ tmp/
|
|||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Local dev runtime
|
||||
.dev/
|
||||
|
||||
# Superpowers (AI planning artifacts)
|
||||
docs/superpowers/
|
||||
|
|
|
|||
92
Makefile
92
Makefile
|
|
@ -1,4 +1,13 @@
|
|||
.PHONY: help dev dev-down build test clean web-install dev-server dev-web build-web test-web typecheck-web lint-web generate-api prod-up prod-down logs db-reset
|
||||
.PHONY: help dev dev-all dev-down dev-all-down dev-all-reset build test clean web-install dev-server dev-web build-web test-web typecheck-web lint-web generate-api db-reset
|
||||
|
||||
DEV_DIR := .dev
|
||||
DEV_SERVER_PID := $(DEV_DIR)/server.pid
|
||||
DEV_WEB_PID := $(DEV_DIR)/web.pid
|
||||
DEV_SERVER_LOG := $(DEV_DIR)/server.log
|
||||
DEV_WEB_LOG := $(DEV_DIR)/web.log
|
||||
DEV_WEB_URL := http://localhost:3000
|
||||
DEV_API_URL := http://localhost:8080
|
||||
DEV_PROCESS := python3 scripts/dev_process.py
|
||||
|
||||
help: ## 显示帮助
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
|
||||
|
|
@ -8,15 +17,82 @@ dev: ## 启动本地开发环境(仅依赖服务)
|
|||
docker compose up -d
|
||||
@echo "Waiting for services to be healthy..."
|
||||
@sleep 5
|
||||
@echo "Services ready. Start backend with: make dev-server"
|
||||
@echo "Services ready."
|
||||
@echo "Start backend with: make dev-server"
|
||||
@echo "Start frontend with: make dev-web"
|
||||
|
||||
dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端)
|
||||
@mkdir -p $(DEV_DIR)
|
||||
@$(MAKE) dev
|
||||
@if [ ! -d web/node_modules ]; then \
|
||||
echo "Installing frontend dependencies..."; \
|
||||
$(MAKE) web-install; \
|
||||
fi
|
||||
@if [ -f $(DEV_SERVER_PID) ] && kill -0 "$$(cat $(DEV_SERVER_PID))" 2>/dev/null; then \
|
||||
echo "Backend already running with PID $$(cat $(DEV_SERVER_PID))"; \
|
||||
else \
|
||||
echo "Starting backend..."; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local >/dev/null; \
|
||||
fi
|
||||
@if [ -f $(DEV_WEB_PID) ] && kill -0 "$$(cat $(DEV_WEB_PID))" 2>/dev/null; then \
|
||||
echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \
|
||||
else \
|
||||
echo "Starting frontend..."; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host 0.0.0.0 --strictPort >/dev/null; \
|
||||
fi
|
||||
@echo "Waiting for backend on $(DEV_API_URL) ..."
|
||||
@backend_ready=0; \
|
||||
for i in $$(seq 1 60); do \
|
||||
if curl -sf $(DEV_API_URL)/actuator/health >/dev/null; then \
|
||||
echo "Backend ready."; \
|
||||
backend_ready=1; \
|
||||
break; \
|
||||
fi; \
|
||||
sleep 2; \
|
||||
done; \
|
||||
if [ "$$backend_ready" -ne 1 ]; then \
|
||||
echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Waiting for frontend on $(DEV_WEB_URL) ..."
|
||||
@frontend_ready=0; \
|
||||
for i in $$(seq 1 60); do \
|
||||
if curl -sf $(DEV_WEB_URL) >/dev/null; then \
|
||||
echo "Frontend ready."; \
|
||||
frontend_ready=1; \
|
||||
break; \
|
||||
fi; \
|
||||
sleep 2; \
|
||||
done; \
|
||||
if [ "$$frontend_ready" -ne 1 ]; then \
|
||||
echo "Frontend failed to become ready. Check $(DEV_WEB_LOG)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Local environment is ready:"
|
||||
@echo " Web UI: $(DEV_WEB_URL)"
|
||||
@echo " Backend: $(DEV_API_URL)"
|
||||
@echo "Logs:"
|
||||
@echo " Backend: $(DEV_SERVER_LOG)"
|
||||
@echo " Frontend: $(DEV_WEB_LOG)"
|
||||
|
||||
dev-server: ## 启动后端开发服务器
|
||||
cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local
|
||||
cd server && ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local
|
||||
|
||||
dev-down: ## 停止本地开发环境
|
||||
docker compose down
|
||||
|
||||
dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端)
|
||||
@$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID)
|
||||
@$(DEV_PROCESS) stop --pid-file $(DEV_WEB_PID)
|
||||
@$(MAKE) dev-down
|
||||
|
||||
dev-all-reset: ## 重置本地开发环境(清理依赖数据卷后重新启动)
|
||||
@$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID)
|
||||
@$(DEV_PROCESS) stop --pid-file $(DEV_WEB_PID)
|
||||
docker compose down -v
|
||||
rm -rf $(DEV_DIR)
|
||||
@$(MAKE) dev-all
|
||||
|
||||
build: ## 构建后端
|
||||
cd server && ./mvnw clean package -DskipTests
|
||||
|
||||
|
|
@ -26,6 +102,7 @@ test: ## 运行后端测试
|
|||
clean: ## 清理构建产物
|
||||
cd server && ./mvnw clean
|
||||
docker compose down -v
|
||||
rm -rf $(DEV_DIR)
|
||||
|
||||
generate-api: ## 生成 OpenAPI 类型(前端用)
|
||||
@echo "Generating OpenAPI types..."
|
||||
|
|
@ -49,15 +126,6 @@ typecheck-web: ## 前端类型检查
|
|||
lint-web: ## 前端代码检查
|
||||
cd web && pnpm run lint
|
||||
|
||||
prod-up: ## 一键启动全部服务(生产模式)
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
|
||||
prod-down: ## 停止全部服务(生产模式)
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
|
||||
logs: ## 查看后端服务日志
|
||||
docker compose -f docker-compose.prod.yml logs -f server
|
||||
|
||||
db-reset: ## 重置数据库
|
||||
docker compose down -v
|
||||
docker compose up -d postgres
|
||||
|
|
|
|||
127
README.md
127
README.md
|
|
@ -1,80 +1,95 @@
|
|||
# SkillHub
|
||||
|
||||
AI Skill 共享平台 — 发布、发现、管理 AI 技能包。
|
||||
An enterprise-grade agent skill registry — publish, discover, and
|
||||
manage reusable skill packages across your organization.
|
||||
|
||||
## 快速开始
|
||||
SkillHub is a self-hosted platform that gives teams a private,
|
||||
governed place to share agent skills. Publish a skill package, push
|
||||
it to a namespace, and let others find it through search or
|
||||
install it via CLI. Built for on-premise deployment behind your
|
||||
firewall, with the same polish you'd expect from a public registry.
|
||||
|
||||
### 前置条件
|
||||
## Highlights
|
||||
|
||||
- **Self-Hosted & Private** — Deploy on your own infrastructure.
|
||||
Keep proprietary skills behind your firewall with full data
|
||||
sovereignty. One `make dev-all` command to get running locally.
|
||||
- **Publish & Version** — Upload agent skill packages with semantic
|
||||
versioning, custom tags (`beta`, `stable`), and automatic
|
||||
`latest` tracking.
|
||||
- **Discover** — Full-text search with filters by namespace,
|
||||
downloads, ratings, and recency. Visibility rules ensure
|
||||
users only see what they're authorized to.
|
||||
- **Team Namespaces** — Organize skills under team or global scopes.
|
||||
Each namespace has its own members, roles (Owner / Admin /
|
||||
Member), and publishing policies.
|
||||
- **Review & Governance** — Team admins review within their namespace;
|
||||
platform admins gate promotions to the global scope. Every
|
||||
action is audit-logged for compliance.
|
||||
- **CLI-First** — Native REST API plus a compatibility layer for
|
||||
existing ClawHub CLI tools — no client changes needed.
|
||||
- **Pluggable Storage** — Local filesystem for development, S3 /
|
||||
MinIO for production. Swap via config.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker & Docker Compose
|
||||
- JDK 21+(开发模式)
|
||||
- Node.js 20+ / pnpm(前端开发)
|
||||
|
||||
### 一键启动(生产模式)
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
make prod-up
|
||||
make dev-all
|
||||
```
|
||||
|
||||
访问:
|
||||
- 前端: http://localhost
|
||||
- API: http://localhost:8080
|
||||
- 健康检查: http://localhost:8080/actuator/health
|
||||
Then open:
|
||||
|
||||
### 开发模式
|
||||
- Web UI: `http://localhost:3000`
|
||||
- Backend API: `http://localhost:8080`
|
||||
|
||||
Stop everything with:
|
||||
|
||||
```bash
|
||||
# 启动基础设施(PostgreSQL + Redis + MinIO)
|
||||
make dev
|
||||
|
||||
# 启动后端
|
||||
make dev-server
|
||||
|
||||
# 启动前端(另一个终端)
|
||||
make dev-web
|
||||
make dev-all-down
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
skillhub/
|
||||
├── server/ # Spring Boot 后端
|
||||
│ ├── skillhub-app/ # 应用层 (Controller, DTO, Config)
|
||||
│ ├── skillhub-domain/ # 领域层 (Entity, Service, Repository)
|
||||
│ ├── skillhub-auth/ # 认证授权模块
|
||||
│ ├── skillhub-search/ # 搜索模块 (PostgreSQL 全文检索)
|
||||
│ ├── skillhub-storage/ # 存储模块 (本地/S3)
|
||||
│ └── skillhub-infra/ # 基础设施层 (JPA 实现)
|
||||
├── web/ # React 前端
|
||||
├── docker-compose.yml # 开发环境(仅基础设施)
|
||||
├── docker-compose.prod.yml # 生产环境(全栈)
|
||||
└── Makefile # 常用命令
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
Reset local dependencies and start from a clean slate with:
|
||||
|
||||
```bash
|
||||
make help # 查看所有命令
|
||||
make dev # 启动开发基础设施
|
||||
make dev-server # 启动后端开发服务器
|
||||
make dev-web # 启动前端开发服务器
|
||||
make test # 运行后端测试
|
||||
make test-web # 运行前端测试
|
||||
make build # 构建后端
|
||||
make build-web # 构建前端
|
||||
make prod-up # 一键启动全部服务
|
||||
make prod-down # 停止全部服务
|
||||
make logs # 查看后端日志
|
||||
make db-reset # 重置数据库
|
||||
make clean # 清理构建产物
|
||||
make dev-all-reset
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
Run `make help` to see all available commands.
|
||||
|
||||
- **后端:** Spring Boot 3, JDK 21, PostgreSQL 16, Redis 7, Flyway, Spring Security (OAuth2 + 本地认证)
|
||||
- **前端:** React 19, TypeScript, Vite, TanStack Router/Query, shadcn/ui, Tailwind CSS
|
||||
- **存储:** MinIO (S3 兼容) / 本地文件系统
|
||||
- **运维:** Docker, Nginx
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
|
||||
│ Web UI │ │ CLI Tools │ │ REST API │
|
||||
└──────┬──────┘ └──────┬──────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
└───────────────────┼───────────────────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Nginx │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Spring Boot │ Auth · RBAC · Core Services
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
│ │ │
|
||||
┌──────▼───┐ ┌─────▼────┐ ┌───▼────┐
|
||||
│PostgreSQL│ │ Redis │ │ MinIO │
|
||||
└──────────┘ └──────────┘ └────────┘
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Please open an issue first to discuss
|
||||
what you'd like to change.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 数据库迁移
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.iflytek.skillhub.compat;
|
||||
|
||||
public record SkillCoordinate(String namespace, String slug) {}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
public record ClawHubPublishResponse(
|
||||
String canonicalSlug,
|
||||
String version,
|
||||
String status
|
||||
) {}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
public record ClawHubResolveResponse(
|
||||
String canonicalSlug,
|
||||
String version,
|
||||
String downloadUrl
|
||||
) {}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ClawHubSearchResponse(List<ClawHubSkillItem> items) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
public record ClawHubSkillItem(
|
||||
String canonicalSlug,
|
||||
String description,
|
||||
String latestVersion,
|
||||
int starCount
|
||||
) {}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
public record ClawHubWhoamiResponse(
|
||||
String userId,
|
||||
String displayName,
|
||||
String email
|
||||
) {}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminUserMutationResponse(
|
||||
String userId,
|
||||
String role,
|
||||
String status
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record AdminUserRoleUpdateRequest(
|
||||
@NotBlank(message = "{error.badRequest}")
|
||||
String role
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record AdminUserStatusUpdateRequest(
|
||||
@NotBlank(message = "{error.badRequest}")
|
||||
String status
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminUserSummaryResponse(
|
||||
String userId,
|
||||
String username,
|
||||
String role,
|
||||
String status
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record SkillRatingRequest(
|
||||
@NotNull(message = "{error.badRequest}")
|
||||
Short score
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record SkillRatingStatusResponse(
|
||||
short score,
|
||||
boolean rated
|
||||
) {
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=设备验证码已被使用
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.idempotency;
|
||||
|
||||
public enum IdempotencyStatus {
|
||||
PROCESSING,
|
||||
COMPLETED,
|
||||
FAILED
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
81
web/src/features/admin/use-admin-users.ts
Normal file
81
web/src/features/admin/use-admin-users.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
44
web/src/features/admin/use-audit-log.ts
Normal file
44
web/src/features/admin/use-audit-log.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
59
web/src/features/review/use-review-detail.ts
Normal file
59
web/src/features/review/use-review-detail.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
29
web/src/features/review/use-review-list.ts
Normal file
29
web/src/features/review/use-review-list.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
57
web/src/features/social/rating-input.tsx
Normal file
57
web/src/features/social/rating-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
web/src/features/social/star-button.tsx
Normal file
34
web/src/features/social/star-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
web/src/features/social/use-rating.ts
Normal file
41
web/src/features/social/use-rating.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
45
web/src/features/social/use-star.ts
Normal file
45
web/src/features/social/use-star.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
123
web/src/pages/admin/audit-log.tsx
Normal file
123
web/src/pages/admin/audit-log.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
258
web/src/pages/admin/users.tsx
Normal file
258
web/src/pages/admin/users.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
186
web/src/pages/dashboard/review-detail.tsx
Normal file
186
web/src/pages/dashboard/review-detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
web/src/pages/dashboard/reviews.tsx
Normal file
111
web/src/pages/dashboard/reviews.tsx
Normal 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
156
web/src/pages/device.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
web/src/shared/ui/textarea.tsx
Normal file
22
web/src/shared/ui/textarea.tsx
Normal 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 }
|
||||
Loading…
Add table
Reference in a new issue