diff --git a/.env.release.example b/.env.release.example new file mode 100644 index 00000000..c41ccee5 --- /dev/null +++ b/.env.release.example @@ -0,0 +1,18 @@ +# `edge` tracks the latest build from the default branch. +# For deterministic environments, pin a release tag like `v0.1.0`. +SKILLHUB_VERSION=edge +SKILLHUB_SERVER_IMAGE=ghcr.io/iflytek/skillhub-server +SKILLHUB_WEB_IMAGE=ghcr.io/iflytek/skillhub-web + +POSTGRES_PORT=5432 +POSTGRES_DB=skillhub +POSTGRES_USER=skillhub +POSTGRES_PASSWORD=skillhub_demo + +REDIS_PORT=6379 +API_PORT=8080 +WEB_PORT=80 + +# Optional: configure real GitHub OAuth before exposing the stack to other users. +OAUTH2_GITHUB_CLIENT_ID=local-placeholder +OAUTH2_GITHUB_CLIENT_SECRET=local-placeholder diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml new file mode 100644 index 00000000..74655dc6 --- /dev/null +++ b/.github/workflows/publish-images.yml @@ -0,0 +1,81 @@ +name: Publish Images + +on: + push: + branches: + - main + - feature/project-init + tags: + - "v*.*.*" + workflow_dispatch: + +concurrency: + group: publish-images-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +env: + DOCKER_PLATFORMS: linux/amd64,linux/arm64 + +jobs: + publish: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: server + context: ./server + dockerfile: ./server/Dockerfile + image: ghcr.io/${{ github.repository_owner }}/skillhub-server + - name: web + context: ./web + dockerfile: ./web/Dockerfile + image: ghcr.io/${{ github.repository_owner }}/skillhub-web + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ matrix.image }} + tags: | + type=raw,value=edge,enable={{is_default_branch}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=ref,event=tag + type=sha,format=short,prefix=sha- + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push ${{ matrix.name }} + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + platforms: ${{ env.DOCKER_PLATFORMS }} + push: true + provenance: false + sbom: false + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.name }} + cache-to: type=gha,mode=max,scope=${{ matrix.name }} diff --git a/Makefile b/Makefile index 5417e36f..b33b02a5 100644 --- a/Makefile +++ b/Makefile @@ -14,9 +14,7 @@ help: ## 显示帮助 awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' dev: ## 启动本地开发环境(仅依赖服务) - docker compose up -d - @echo "Waiting for services to be healthy..." - @sleep 5 + docker compose up -d --wait --remove-orphans @echo "Services ready." @echo "Start backend with: make dev-server" @echo "Start frontend with: make dev-web" @@ -32,7 +30,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) 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; \ + $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./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))"; \ @@ -71,15 +69,18 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) @echo "Local environment is ready:" @echo " Web UI: $(DEV_WEB_URL)" @echo " Backend: $(DEV_API_URL)" + @echo "Mock auth users:" + @echo " local-user -> X-Mock-User-Id: local-user" + @echo " local-admin -> X-Mock-User-Id: local-admin" @echo "Logs:" @echo " Backend: $(DEV_SERVER_LOG)" @echo " Frontend: $(DEV_WEB_LOG)" dev-server: ## 启动后端开发服务器 - cd server && ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local + cd server && /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' dev-down: ## 停止本地开发环境 - docker compose down + docker compose down --remove-orphans dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端) @$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID) @@ -89,7 +90,7 @@ dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端) dev-all-reset: ## 重置本地开发环境(清理依赖数据卷后重新启动) @$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID) @$(DEV_PROCESS) stop --pid-file $(DEV_WEB_PID) - docker compose down -v + docker compose down -v --remove-orphans rm -rf $(DEV_DIR) @$(MAKE) dev-all @@ -127,8 +128,6 @@ lint-web: ## 前端代码检查 cd web && pnpm run lint db-reset: ## 重置数据库 - docker compose down -v - docker compose up -d postgres - @echo "Waiting for postgres..." - @sleep 3 + docker compose down -v --remove-orphans + docker compose up -d --wait --remove-orphans postgres cd server && ./mvnw flyway:migrate -pl skillhub-app diff --git a/README.md b/README.md index c0695001..7d094488 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,13 @@ Then open: - Web UI: `http://localhost:3000` - Backend API: `http://localhost:8080` +Local profile seeds two mock-auth users automatically: + +- `local-user` for normal publishing and namespace operations +- `local-admin` with `SUPER_ADMIN` for review and admin flows + +Use them with the `X-Mock-User-Id` header in local development. + Stop everything with: ```bash @@ -62,6 +69,57 @@ make dev-all-reset Run `make help` to see all available commands. +### Container Runtime + +Published runtime images are built by GitHub Actions and pushed to GHCR. +This is the supported path for anyone who wants a ready-to-use local +environment without building the backend or frontend on their machine. +Published images target both `linux/amd64` and `linux/arm64`. + +1. Copy the runtime environment template. +2. Pick an image tag. +3. Start the stack with Docker Compose. + +```bash +cp .env.release.example .env.release +``` + +Recommended image tags: + +- `SKILLHUB_VERSION=edge` for the latest `main` build +- `SKILLHUB_VERSION=vX.Y.Z` for a fixed release + +Start the runtime: + +```bash +docker compose --env-file .env.release -f compose.release.yml up -d +``` + +Then open: + +- Web UI: `http://localhost` +- Backend API: `http://localhost:8080` + +Stop it with: + +```bash +docker compose --env-file .env.release -f compose.release.yml down +``` + +The runtime stack uses its own Compose project name, so it does not +collide with containers from `make dev-all`. + +The runtime uses the existing `local,docker` profile combination so it +is immediately usable with the same mock-auth flow as local development. +Available seeded users: + +- `local-user` +- `local-admin` + +Pass `X-Mock-User-Id` to the backend when you need an authenticated +session without configuring GitHub OAuth. If the GHCR package remains +private, run `docker login ghcr.io` before `docker compose up -d`. + ## Architecture ``` @@ -82,7 +140,7 @@ Run `make help` to see all available commands. ┌────────────┼────────────┐ │ │ │ ┌──────▼───┐ ┌─────▼────┐ ┌───▼────┐ - │PostgreSQL│ │ Redis │ │ MinIO │ + │PostgreSQL│ │ Redis │ │ Storage │ └──────────┘ └──────────┘ └────────┘ ``` diff --git a/compose.release.yml b/compose.release.yml new file mode 100644 index 00000000..2f8f8d0c --- /dev/null +++ b/compose.release.yml @@ -0,0 +1,78 @@ +name: skillhub-release + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + ports: + - "${POSTGRES_PORT:-5432}:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB:-skillhub} + POSTGRES_USER: ${POSTGRES_USER:-skillhub} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skillhub} -d ${POSTGRES_DB:-skillhub}"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + + server: + image: ${SKILLHUB_SERVER_IMAGE:-ghcr.io/iflytek/skillhub-server}:${SKILLHUB_VERSION:-edge} + restart: unless-stopped + ports: + - "${API_PORT:-8080}:8080" + environment: + SPRING_PROFILES_ACTIVE: local,docker + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-skillhub} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-skillhub} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo} + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 + STORAGE_BASE_PATH: /var/lib/skillhub/storage + OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder} + OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder} + volumes: + - skillhub_storage:/var/lib/skillhub/storage + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + + web: + image: ${SKILLHUB_WEB_IMAGE:-ghcr.io/iflytek/skillhub-web}:${SKILLHUB_VERSION:-edge} + restart: unless-stopped + ports: + - "${WEB_PORT:-80}:80" + depends_on: + server: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost/nginx-health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s + +volumes: + postgres_data: + skillhub_storage: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index 3d5b7374..00000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,86 +0,0 @@ -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", "curl", "-f", "http://localhost:9000/minio/health/live"] - interval: 5s - timeout: 5s - retries: 5 - - server: - build: - context: ./server - dockerfile: Dockerfile - ports: - - "8080:8080" - environment: - SPRING_PROFILES_ACTIVE: prod - SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/skillhub - SPRING_DATASOURCE_USERNAME: skillhub - SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-skillhub_prod} - SPRING_DATA_REDIS_HOST: redis - SPRING_DATA_REDIS_PORT: 6379 - 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", "wget", "-qO-", "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: diff --git a/docs/01-system-architecture.md b/docs/01-system-architecture.md index ba932eab..ec754373 100644 --- a/docs/01-system-architecture.md +++ b/docs/01-system-architecture.md @@ -114,6 +114,9 @@ skillhub/ │ ├── Dockerfile # 前端多阶段构建 │ └── nginx.conf # Nginx 配置(SPA 路由 + API 反向代理) ├── docker-compose.yml # 本地开发依赖服务(PostgreSQL/Redis/MinIO) +├── compose.release.yml # 单机运行时编排(发布镜像 + PostgreSQL + Redis) +├── .env.release.example # 单机运行时环境变量模板 +├── .github/workflows/ # GitHub Actions 镜像发布流程 ├── Makefile # 顶层开发编排(dev / dev-all / build) ├── docs/ # 设计文档 └── README.md @@ -123,10 +126,20 @@ skillhub/ ## 8. 部署架构 -同域部署,统一入口: -- `https://skills.example.com/` → 前端静态资源 -- `https://skills.example.com/api/*` → 反向代理到 Spring Boot -- 生产环境通过 Nginx 或网关统一接入 +部署模型收敛为两条路径: + +- 开发路径:`make dev-all`。前后端在宿主机运行,`docker-compose.yml` 只负责 PostgreSQL、Redis、MinIO。 +- 交付路径:GitHub Actions 构建并发布 `server` / `web` 镜像;用户通过 `compose.release.yml` 在本地一键拉起前后端容器和基础服务。 +- 发布镜像为多架构 manifest,至少覆盖 `linux/amd64` 与 `linux/arm64`。 + +单机运行时统一入口: +- `http://localhost/` → Web 容器(Nginx) +- `http://localhost/api/*` → Web 容器反向代理到 Spring Boot +- `http://localhost:8080/actuator/health` → 后端健康检查 + +单机运行时使用 `local,docker` profile 组合: +- `local` 提供 mock 登录和种子账号,保证拉起即用 +- `docker` 负责将数据库、Redis 地址切换到 Compose 网络 ## 9. 分布式环境要求 @@ -148,3 +161,5 @@ skillhub/ - 缓存/Session:Spring Session + Redis - 数据库迁移:Flyway - 认证:Spring Security OAuth2 Client(一期 GitHub) +- 镜像发布:GitHub Actions 推送至 GHCR,默认维护 `edge` 与语义化版本标签 +- 运行时兼容:发布镜像默认输出 `linux/amd64` + `linux/arm64` 多架构 manifest diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 21f010ee..9b5b13ea 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -1,350 +1,179 @@ # skillhub 部署架构与运维 -## 1 K8s 部署拓扑 +## 1 运行模型 + +当前仓库只保留两种运行方式: + +- 开发环境:`make dev-all` + - 前端和后端运行在宿主机 + - `docker-compose.yml` 只负责 PostgreSQL、Redis、MinIO +- 单机交付环境:`docker compose --env-file .env.release -f compose.release.yml up -d` + - 前端和后端都运行在容器内 +- 使用 GitHub Actions 发布到 GHCR 的镜像 +- 默认发布 `linux/amd64` 与 `linux/arm64` 多架构镜像 + - PostgreSQL、Redis 与应用容器一起通过 Compose 启动 + +不再维护本地构建整套 demo 容器的中间模式,也不再保留 `docker-compose.prod.yml`。 + +## 2 单机交付拓扑 ``` - ┌─────────────┐ - │ Ingress │ - │ (Nginx) │ - └──────┬──────┘ - │ - ┌────────────┴────────────┐ - │ /api/* │ /* - ▼ ▼ - ┌──────────────────┐ ┌──────────────────┐ - │ Spring Boot │ │ Nginx / CDN │ - │ replicas: 2+ │ │ 静态资源 │ - └────────┬─────────┘ └──────────────────┘ - │ - ┌────────┴──────────────────────┐ - │ │ │ - ▼ ▼ ▼ -┌────────┐ ┌────────┐ ┌──────────────┐ -│ PostgreSQL│ │ Redis │ │ S3 / MinIO │ -│ (主从) │ │ │ │ │ -└────────┘ └────────┘ └──────────────┘ +┌──────────────┐ +│ Browser / CLI│ +└──────┬───────┘ + │ + ▼ +┌──────────────┐ +│ Web/Nginx │ published image +└──────┬───────┘ + │ /api/* + ▼ +┌──────────────┐ +│ Spring Boot │ published image +└───┬────┬─────┘ + │ │ + ▼ ▼ + PostgreSQL Redis ``` -## 2 服务配置 +说明: +- Web 容器提供静态资源,并将 `/api/*`、`/oauth2/*`、`/.well-known/*` 反代到后端 +- 后端运行 `local,docker` profile 组合 +- 技能包文件默认落在容器卷 `skillhub_storage`,保证单机环境开箱即用 -- 无状态设计,所有状态存储在 PostgreSQL / Redis / S3 -- 健康检查:`/actuator/health`(liveness + readiness 分离) -- 优雅停机:`spring.lifecycle.timeout-per-shutdown-phase=30s` -- JVM:`-XX:MaxRAMPercentage=75.0` +## 3 Profile 约定 -## 3 环境 Profile - -| Profile | 用途 | 特点 | +| Profile | 用途 | 说明 | |---------|------|------| -| `local` | 本地开发 | Docker Compose 一键启动(PostgreSQL/Redis/MinIO),Mock OAuth(见下方说明) | -| `dev` | 开发环境 | 共享基础设施,GitHub OAuth 测试应用 | -| `staging` | 预发布 | 与生产同构 | -| `prod` | 生产 | 多 Pod,完整基础设施 | +| `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 | +| `docker` | 容器网络适配 | 将数据库和 Redis 地址切换到 Compose 内网 | -### 本地开发 Mock 登录 +单机交付环境使用 `SPRING_PROFILES_ACTIVE=local,docker`,原因很明确: -`local` profile 下提供两种开发登录方式: +- 这是当前唯一能保证“镜像拉起后直接可用”的 profile 组合 +- 用户无需先配置 GitHub OAuth,先用 mock 身份即可浏览和联调主要流程 +- 后续如果引入专用 `runtime` / `demo` profile,可以替换这层组合,但当前方案不再新增第三条部署路径 -1. **MockAuthFilter**(默认):通过 `X-Mock-User-Id` Header 模拟登录,自动创建 Session,无需真实 OAuth 流程 -2. **GitHub OAuth 测试应用**:配置 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` 后可走真实 OAuth 流程(GitHub 支持 `http://localhost` 回调) +默认可用账号: -MockAuthFilter 仅在 `local` profile 激活,通过 `@Profile("local")` 注解保证不会泄漏到其他环境。 +- `local-user` +- `local-admin` -### Docker Compose 说明 +鉴权方式: -当前推荐的本地启动入口是 `make dev-all`。Docker Compose 在当前项目里主要承担本地依赖服务启动。 +- 向后端请求携带 `X-Mock-User-Id: local-user` +- 或 `X-Mock-User-Id: local-admin` + +## 4 开发环境 + +开发入口保持不变: + +```bash +make dev-all +``` + +行为: + +- `docker-compose.yml` 启动 PostgreSQL、Redis、MinIO +- `server` 在宿主机通过 Maven Wrapper 启动 +- `web` 在宿主机通过 Vite 启动 常用命令: ```bash +make dev make dev-all +make dev-down make dev-all-down make dev-all-reset ``` -#### docker-compose.yml — 本地开发(仅依赖服务) +## 5 单机交付环境 -本地开发时前后端在宿主机运行,Docker Compose 只拉起依赖服务: - -```yaml -# docker-compose.yml(项目根目录) -services: - postgres: - image: postgres:16-alpine - ports: - - "5432:5432" - environment: - POSTGRES_DB: skillhub - POSTGRES_USER: skillhub - POSTGRES_PASSWORD: skillhub_dev - volumes: - - postgres_data:/var/lib/postgresql/data - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - - minio: - image: minio/minio:latest - ports: - - "9000:9000" - - "9001:9001" # MinIO Console - environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - command: server /data --console-address ":9001" - volumes: - - minio_data:/data - -volumes: - postgres_data: - minio_data: -``` - -生产环境文档不再提供 Compose 一键部署入口。当前仓库只保留本地开发所需的 `docker-compose.yml`,正式部署以镜像构建 + K8s 编排为准。 - -#### 前后端 Dockerfile - -后端 Dockerfile(`server/Dockerfile`): -```dockerfile -FROM maven:3.9-eclipse-temurin-21 AS build -WORKDIR /app -COPY pom.xml . -COPY skillhub-app/pom.xml skillhub-app/ -COPY skillhub-domain/pom.xml skillhub-domain/ -COPY skillhub-auth/pom.xml skillhub-auth/ -COPY skillhub-search/pom.xml skillhub-search/ -COPY skillhub-storage/pom.xml skillhub-storage/ -COPY skillhub-infra/pom.xml skillhub-infra/ -RUN mvn dependency:go-offline -B -COPY . . -RUN mvn package -DskipTests -B - -FROM eclipse-temurin:21-jre-alpine -WORKDIR /app -COPY --from=build /app/skillhub-app/target/*.jar app.jar -EXPOSE 8080 -ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"] -``` - -前端 Dockerfile(`web/Dockerfile`): -```dockerfile -FROM node:20-alpine AS build -WORKDIR /app -RUN corepack enable -COPY package.json pnpm-lock.yaml ./ -RUN pnpm install --frozen-lockfile -COPY . . -RUN pnpm build - -FROM nginx:alpine -COPY --from=build /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 -``` - -前端 Nginx 配置(`web/nginx.conf`): -```nginx -server { - listen 80; - root /usr/share/nginx/html; - index index.html; - - # SPA 路由回退 - location / { - try_files $uri $uri/ /index.html; - } - - # API 反向代理到后端 - location /api/ { - proxy_pass http://server:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # OAuth2 回调反向代理 - location /oauth2/ { - proxy_pass http://server:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - - location /login/oauth2/ { - proxy_pass http://server:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - - # Well-known 发现端点 - location /.well-known/ { - proxy_pass http://server:8080; - proxy_set_header Host $host; - } -} -``` - -### Spring Boot 配置文件分层 - -``` -server/skillhub-app/src/main/resources/ -├── application.yml # 公共配置(所有 profile 共享) -├── application-local.yml # 本地开发(Docker Compose 服务地址) -├── application-dev.yml # 开发环境 -├── application-staging.yml # 预发布 -└── application-prod.yml # 生产 -``` - -`application.yml`(公共配置): -```yaml -spring: - application: - name: skillhub - jpa: - open-in-view: false - hibernate: - ddl-auto: validate # 由 Flyway 管理 schema,Hibernate 仅校验 - properties: - hibernate: - dialect: org.hibernate.dialect.PostgreSQLDialect - flyway: - enabled: true - locations: classpath:db/migration - -server: - shutdown: graceful - -spring.lifecycle.timeout-per-shutdown-phase: 30s -``` - -`application-local.yml`(本地开发,对应 Docker Compose): -```yaml -spring: - datasource: - url: jdbc:postgresql://localhost:5432/skillhub - username: skillhub - password: skillhub_dev - data: - redis: - host: localhost - port: 6379 - jpa: - show-sql: true - -skillhub: - storage: - type: s3 - endpoint: http://localhost:9000 - access-key: minioadmin - secret-key: minioadmin - bucket: skillhub - region: us-east-1 - access-policy: - mode: OPEN # 本地开发默认开放准入 -``` - -`application-prod.yml`(生产环境,凭证从环境变量/K8s Secret 注入): -```yaml -spring: - datasource: - url: ${DATABASE_URL} - username: ${DATABASE_USERNAME} - password: ${DATABASE_PASSWORD} - data: - redis: - host: ${REDIS_HOST} - port: ${REDIS_PORT:6379} - jpa: - show-sql: false - -skillhub: - storage: - type: s3 - endpoint: ${S3_ENDPOINT} - access-key: ${S3_ACCESS_KEY} - secret-key: ${S3_SECRET_KEY} - bucket: ${S3_BUCKET:skillhub} - region: ${S3_REGION:us-east-1} -``` - -### 本地开发启动流程 +### 5.1 启动 ```bash -# 一键启动依赖 + 后端 + 前端 -make dev-all +cp .env.release.example .env.release +docker compose --env-file .env.release -f compose.release.yml up -d ``` -启动后可直接访问: +默认访问地址: -- Web UI: `http://localhost:3000` +- Web UI: `http://localhost` - Backend API: `http://localhost:8080` -停止: +### 5.2 关键文件 -```bash -make dev-all-down -``` +- `compose.release.yml` + - 使用发布镜像,不在用户机器上执行本地构建 + - 负责拉起 PostgreSQL、Redis、server、web + - 使用独立 Compose project name,避免与开发环境容器互相污染 +- `.env.release.example` + - 运行时变量模板 + - 包含镜像名、镜像版本、端口和数据库凭证 -如需分步启动: +### 5.3 镜像标签约定 -```bash -make dev # 仅依赖服务 -make dev-server # 仅后端 -make dev-web # 仅前端 -``` +- `edge` + - `main` 分支最新构建 + - 用于内部持续验证 +- `vX.Y.Z` + - 对应 Git tag + - 用于稳定版本交付 +- `latest` + - 仅在语义化版本 tag 发布时更新 -### Makefile 命令 +推荐: -```bash -make dev # 仅启动本地依赖服务 -make dev-all # 一键启动本地依赖 + 后端 + 前端 -make dev-down # 停止本地依赖服务 -make dev-all-down # 停止本地依赖 + 后端 + 前端 -make build # 构建后端 -make generate-api # 生成 OpenAPI 类型 -``` +- 团队内部试用:`SKILLHUB_VERSION=edge` +- 对外演示或文档引用:固定为某个 `vX.Y.Z` -## 4 配置管理 +## 6 GitHub Actions 发布流程 -- 敏感配置:K8s Secret(数据库/Redis/S3 凭证、OAuth2 Client ID/Secret) -- 非敏感配置:K8s ConfigMap(文件大小限制、Session TTL 等) +发布工作流文件:`.github/workflows/publish-images.yml` -## 5 可观测性 +触发条件: + +- push 到 `main` +- push 语义化版本 tag,例如 `v1.2.0` +- 手动 `workflow_dispatch` + +流程: + +1. 检出代码 +2. 登录 GHCR +3. 分别构建 `server/Dockerfile` 与 `web/Dockerfile` +4. 推送镜像: + - `ghcr.io/iflytek/skillhub-server` + - `ghcr.io/iflytek/skillhub-web` +5. 写入 `edge` / `vX.Y.Z` / `latest` / `sha-*` 标签 +6. 同时发布 `linux/amd64` 与 `linux/arm64` manifest,避免 Apple Silicon / ARM 主机依赖模拟层 + +## 7 配置管理 + +开发环境: + +- 本地命令与 `docker-compose.yml` +- 非敏感默认值可直接落库或写入本地配置 + +单机交付环境: + +- 使用 `.env.release` 管理 Compose 变量 +- 如果 GHCR 包保持私有,用户需要先 `docker login ghcr.io` +- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` + +## 8 可观测性 | 维度 | 方案 | |------|------| -| 日志 | JSON 格式 stdout,包含 traceId/requestId | -| 指标 | Actuator + Micrometer → Prometheus | -| 链路追踪 | 一期 requestId 透传,后续接 Jaeger/Zipkin | -| 告警 | 基于 Prometheus(5xx 率、延迟 P99、Pod 重启) | +| 健康检查 | `web/nginx-health`、`server/actuator/health` | +| 日志 | 容器 stdout / stderr | +| 指标 | Spring Boot Actuator,后续可接 Prometheus | -requestId 透传:Ingress 注入 → Spring Filter 读取放入 MDC → 日志自动携带 → 响应 Header 回传。 +## 9 数据迁移 -## 6 构建与发布 +Flyway 仍是唯一 schema 变更入口: -### CI Pipeline 构建 - -``` -代码提交 → CI Pipeline - ├── server: mvn package → JAR - └── web: pnpm build → dist/ - │ - ▼ - Docker 多阶段构建 - ├── server → eclipse-temurin:21-jre-alpine - └── web → nginx:alpine - │ - ▼ - 推送镜像 → K8s 滚动更新 -``` - -Makefile 顶层命令:`make dev`, `make dev-all`, `make dev-down`, `make dev-all-down`, `make build`, `make generate-api` - -## 7 数据库迁移 - -Flyway 管理 schema 变更: -- 脚本路径:`server/skillhub-app/src/main/resources/db/migration/` +- 路径:`server/skillhub-app/src/main/resources/db/migration/` - 命名:`V{version}__{description}.sql` -- 多 Pod 安全:Flyway 自带数据库锁 +- 启动策略:应用容器启动时自动执行迁移 diff --git a/scripts/dev_process.py b/scripts/dev_process.py new file mode 100644 index 00000000..3213c108 --- /dev/null +++ b/scripts/dev_process.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 + +import argparse +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + + +def is_running(pid: int) -> bool: + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def read_pid(pid_file: Path) -> int | None: + if not pid_file.exists(): + return None + content = pid_file.read_text(encoding="utf-8").strip() + if not content: + return None + try: + return int(content) + except ValueError: + return None + + +def write_pid(pid_file: Path, pid: int) -> None: + pid_file.parent.mkdir(parents=True, exist_ok=True) + pid_file.write_text(f"{pid}\n", encoding="utf-8") + + +def start_process(args: argparse.Namespace) -> int: + pid_file = Path(args.pid_file) + log_file = Path(args.log_file) + cwd = Path(args.cwd) + existing_pid = read_pid(pid_file) + if existing_pid and is_running(existing_pid): + print(existing_pid) + return 0 + + pid_file.unlink(missing_ok=True) + log_file.parent.mkdir(parents=True, exist_ok=True) + + command = list(args.command) + if command and command[0] == "--": + command = command[1:] + + with log_file.open("ab") as log_handle, open(os.devnull, "rb") as devnull: + process = subprocess.Popen( + command, + cwd=cwd, + stdin=devnull, + stdout=log_handle, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + + write_pid(pid_file, process.pid) + print(process.pid) + return 0 + + +def stop_process(args: argparse.Namespace) -> int: + pid_file = Path(args.pid_file) + pid = read_pid(pid_file) + if not pid: + return 0 + + if not is_running(pid): + pid_file.unlink(missing_ok=True) + return 0 + + os.kill(pid, signal.SIGTERM) + deadline = time.time() + args.timeout + while time.time() < deadline: + if not is_running(pid): + pid_file.unlink(missing_ok=True) + return 0 + time.sleep(0.2) + + os.kill(pid, signal.SIGKILL) + pid_file.unlink(missing_ok=True) + return 0 + + +def status_process(args: argparse.Namespace) -> int: + pid_file = Path(args.pid_file) + pid = read_pid(pid_file) + if not pid: + return 1 + if not is_running(pid): + pid_file.unlink(missing_ok=True) + return 1 + print(pid) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Manage detached dev processes.") + subparsers = parser.add_subparsers(dest="action", required=True) + + start_parser = subparsers.add_parser("start") + start_parser.add_argument("--pid-file", required=True) + start_parser.add_argument("--log-file", required=True) + start_parser.add_argument("--cwd", required=True) + start_parser.add_argument("command", nargs=argparse.REMAINDER) + + stop_parser = subparsers.add_parser("stop") + stop_parser.add_argument("--pid-file", required=True) + stop_parser.add_argument("--timeout", type=float, default=10.0) + + status_parser = subparsers.add_parser("status") + status_parser.add_argument("--pid-file", required=True) + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + if args.action == "start": + if not args.command: + parser.error("start requires a command") + return start_process(args) + if args.action == "stop": + return stop_process(args) + if args.action == "status": + return status_process(args) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java new file mode 100644 index 00000000..b78a871c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Profile("local") +public class LocalDevDataInitializer implements ApplicationRunner { + + public static final String LOCAL_USER_ID = "local-user"; + public static final String LOCAL_ADMIN_ID = "local-admin"; + + private static final Logger log = LoggerFactory.getLogger(LocalDevDataInitializer.class); + + private final UserAccountRepository userAccountRepository; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final RoleRepository roleRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + + public LocalDevDataInitializer(UserAccountRepository userAccountRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + RoleRepository roleRepository, + UserRoleBindingRepository userRoleBindingRepository) { + this.userAccountRepository = userAccountRepository; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.roleRepository = roleRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + UserAccount localUser = ensureUser( + LOCAL_USER_ID, + "Local Developer", + "local-user@example.test" + ); + UserAccount localAdmin = ensureUser( + LOCAL_ADMIN_ID, + "Local Admin", + "local-admin@example.test" + ); + + Namespace globalNamespace = namespaceRepository.findBySlug("global") + .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + + ensureMembership(globalNamespace.getId(), localUser.getId(), NamespaceRole.OWNER); + ensureMembership(globalNamespace.getId(), localAdmin.getId(), NamespaceRole.OWNER); + ensureRole(localAdmin.getId(), "SUPER_ADMIN"); + + log.info("Local dev accounts ready: {} / {}", LOCAL_USER_ID, LOCAL_ADMIN_ID); + } + + private UserAccount ensureUser(String userId, String displayName, String email) { + return userAccountRepository.findById(userId) + .map(existing -> { + existing.setDisplayName(displayName); + existing.setEmail(email); + existing.setStatus(UserStatus.ACTIVE); + return userAccountRepository.save(existing); + }) + .orElseGet(() -> userAccountRepository.save( + new UserAccount(userId, displayName, email, null) + )); + } + + private void ensureMembership(Long namespaceId, String userId, NamespaceRole role) { + NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .orElseGet(() -> new NamespaceMember(namespaceId, userId, role)); + if (member.getRole() != role) { + member.setRole(role); + } + namespaceMemberRepository.save(member); + } + + private void ensureRole(String userId, String roleCode) { + boolean exists = userRoleBindingRepository.findByUserId(userId).stream() + .map(binding -> binding.getRole().getCode()) + .anyMatch(roleCode::equals); + if (exists) { + return; + } + + Role role = roleRepository.findByCode(roleCode) + .orElseThrow(() -> new IllegalStateException("Missing built-in role: " + roleCode)); + userRoleBindingRepository.save(new UserRoleBinding(userId, role)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index 270372be..d65bf9f9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java @@ -58,7 +58,7 @@ public class PromotionController extends BaseApiController { PromotionRequest promotion = promotionService.submitPromotion( request.sourceSkillId(), request.sourceVersionId(), request.targetNamespaceId(), userId); - return ok("response.success.create", toResponse(promotion)); + return ok("response.success.created", toResponse(promotion)); } @PostMapping("/{id}/approve") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index 742c263a..781962a0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -61,7 +61,7 @@ public class ReviewController extends BaseApiController { .orElseThrow(); Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow(); ReviewTask task = reviewService.submitReview(request.skillVersionId(), skill.getNamespaceId(), userId); - return ok("response.success.create", toResponse(task)); + return ok("response.success.created", toResponse(task)); } @PostMapping("/{id}/approve") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index c1e91628..49625f63 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; @@ -43,6 +44,12 @@ public class GlobalExceptionHandler { apiResponseFactory.error(403, ex.messageCode(), ex.messageArgs())); } + @ExceptionHandler(DomainNotFoundException.class) + public ResponseEntity> handleDomainNotFound(DomainNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body( + apiResponseFactory.error(404, ex.messageCode(), ex.messageArgs())); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { String msg = ex.getBindingResult().getFieldErrors().stream() diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 7cc2c460..58abc9e4 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -51,6 +51,12 @@ error.skill.publish.package.invalid=Package validation failed: {0} error.skill.publish.skillMd.notFound=SKILL.md not found error.skill.publish.precheck.failed=Pre-publish validation failed: {0} error.skill.notFound=Skill not found: {0} +skill.not_found=Skill not found: {0} +skill_version.not_found=Skill version not found: {0} +namespace.not_found=Namespace not found: {0} +promotion.not_found=Promotion request not found: {0} +review_task.not_found=Review task not found: {0} +review_task.not_found_for_version=Review task not found for skill version: {0} error.skill.access.denied=Access denied to skill: {0} error.skill.status.notActive=Skill is not active error.skill.version.exists=Version already exists: {0} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 5192afd1..2775dc5e 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -51,6 +51,12 @@ error.skill.publish.package.invalid=技能包校验失败:{0} error.skill.publish.skillMd.notFound=未找到 SKILL.md error.skill.publish.precheck.failed=预发布校验失败:{0} error.skill.notFound=未找到技能:{0} +skill.not_found=未找到技能:{0} +skill_version.not_found=未找到技能版本:{0} +namespace.not_found=未找到命名空间:{0} +promotion.not_found=未找到推广申请:{0} +review_task.not_found=未找到审核任务:{0} +review_task.not_found_for_version=未找到该技能版本对应的审核任务:{0} error.skill.access.denied=没有权限访问技能:{0} error.skill.status.notActive=技能未处于 ACTIVE 状态 error.skill.version.exists=版本已存在:{0} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializerTest.java new file mode 100644 index 00000000..945530c6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializerTest.java @@ -0,0 +1,98 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.DefaultApplicationArguments; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LocalDevDataInitializerTest { + + @Mock private UserAccountRepository userAccountRepository; + @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock private RoleRepository roleRepository; + @Mock private UserRoleBindingRepository userRoleBindingRepository; + + private LocalDevDataInitializer initializer; + + @BeforeEach + void setUp() { + initializer = new LocalDevDataInitializer( + userAccountRepository, + namespaceRepository, + namespaceMemberRepository, + roleRepository, + userRoleBindingRepository + ); + } + + @Test + void shouldSeedLocalUsersGlobalMembershipAndSuperAdminRole() throws Exception { + Namespace global = new Namespace("global", "Global", "system"); + setField(global, "id", 1L); + + Role superAdminRole = new Role(); + setField(superAdminRole, "id", 1L); + setField(superAdminRole, "code", "SUPER_ADMIN"); + + when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_USER_ID)).thenReturn(Optional.empty()); + when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(Optional.empty()); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(anyLong(), any())).thenReturn(Optional.empty()); + when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleRepository.findByCode("SUPER_ADMIN")).thenReturn(Optional.of(superAdminRole)); + when(userRoleBindingRepository.findByUserId(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(List.of()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userAccountRepository, times(2)).save(userCaptor.capture()); + List savedUsers = userCaptor.getAllValues(); + assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_USER_ID.equals(user.getId()))); + assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_ADMIN_ID.equals(user.getId()))); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository, times(2)).save(memberCaptor.capture()); + assertEquals( + List.of(LocalDevDataInitializer.LOCAL_USER_ID, LocalDevDataInitializer.LOCAL_ADMIN_ID), + memberCaptor.getAllValues().stream().map(NamespaceMember::getUserId).toList() + ); + assertTrue(memberCaptor.getAllValues().stream().allMatch(member -> member.getRole() == NamespaceRole.OWNER)); + + ArgumentCaptor roleBindingCaptor = ArgumentCaptor.forClass(UserRoleBinding.class); + verify(userRoleBindingRepository).save(roleBindingCaptor.capture()); + assertEquals(LocalDevDataInitializer.LOCAL_ADMIN_ID, roleBindingCaptor.getValue().getUserId()); + assertEquals("SUPER_ADMIN", roleBindingCaptor.getValue().getRole().getCode()); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java index 8bc0dceb..7e6e1cdf 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.social.SkillRatingService; import org.junit.jupiter.api.Test; @@ -107,4 +108,33 @@ class SkillRatingControllerTest { .content("{\"score\": 4}")) .andExpect(status().isUnauthorized()); } + + @Test + void get_user_rating_missing_skill_returns_404_envelope() 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")) + ); + + when(skillRatingService.getUserRating(eq(999L), eq("user-42"))) + .thenThrow(new DomainNotFoundException("skill.not_found", 999L)); + + mockMvc.perform(get("/api/v1/skills/999/rating") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(404)) + .andExpect(jsonPath("$.msg").value("Skill not found: 999")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java index de0b5f92..7397ec1c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.social.SkillStarService; import org.junit.jupiter.api.Test; @@ -126,4 +127,33 @@ class SkillStarControllerTest { .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } + + @Test + void check_starred_missing_skill_returns_404_envelope() 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")) + ); + + when(skillStarService.isStarred(eq(999L), eq("user-42"))) + .thenThrow(new DomainNotFoundException("skill.not_found", 999L)); + + mockMvc.perform(get("/api/v1/skills/999/star") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(404)) + .andExpect(jsonPath("$.msg").value("Skill not found: 999")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java index 56ffa0ef..8007f29b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java @@ -1,6 +1,9 @@ package com.iflytek.skillhub.auth.device; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @@ -20,12 +23,25 @@ public class DeviceAuthService { private final RedisTemplate redisTemplate; private final String verificationUri; + private final ObjectMapper objectMapper; + private final ApiTokenService apiTokenService; private final SecureRandom random = new SecureRandom(); + @Autowired public DeviceAuthService(RedisTemplate redisTemplate, + ApiTokenService apiTokenService, @Value("${skillhub.device-auth.verification-uri:/device}") String verificationUri) { + this(redisTemplate, apiTokenService, verificationUri, new ObjectMapper()); + } + + public DeviceAuthService(RedisTemplate redisTemplate, + ApiTokenService apiTokenService, + String verificationUri, + ObjectMapper objectMapper) { this.redisTemplate = redisTemplate; this.verificationUri = verificationUri; + this.objectMapper = objectMapper; + this.apiTokenService = apiTokenService; } public DeviceCodeResponse generateDeviceCode() { @@ -48,7 +64,7 @@ public class DeviceAuthService { throw new DomainBadRequestException("error.deviceAuth.userCode.invalid"); } - DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); + DeviceCodeData data = readDeviceCodeData(deviceCode); if (data == null) { throw new DomainBadRequestException("error.deviceAuth.deviceCode.expired"); } @@ -60,7 +76,7 @@ public class DeviceAuthService { } public DeviceTokenResponse pollToken(String deviceCode) { - DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); + DeviceCodeData data = readDeviceCodeData(deviceCode); if (data == null) { throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid"); @@ -72,7 +88,9 @@ public class DeviceAuthService { data.setStatus(DeviceCodeStatus.USED); redisTemplate.opsForValue().set( DEVICE_CODE_PREFIX + deviceCode, data, 1, TimeUnit.MINUTES); - yield DeviceTokenResponse.success(null); + String token = apiTokenService.createToken( + data.getUserId(), "device-auth", "[]").rawToken(); + yield DeviceTokenResponse.success(token); } case USED -> throw new DomainBadRequestException("error.deviceAuth.deviceCode.used"); }; @@ -92,4 +110,15 @@ public class DeviceAuthService { } return code.toString(); } + + private DeviceCodeData readDeviceCodeData(String deviceCode) { + Object raw = redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); + if (raw == null) { + return null; + } + if (raw instanceof DeviceCodeData data) { + return data; + } + return objectMapper.convertValue(raw, DeviceCodeData.class); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java index 993ae314..a8ada47d 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.device; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -10,6 +12,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -26,12 +30,15 @@ class DeviceAuthServiceTest { @Mock private ValueOperations valueOperations; + @Mock + private ApiTokenService apiTokenService; + private DeviceAuthService service; @BeforeEach void setUp() { when(redisTemplate.opsForValue()).thenReturn(valueOperations); - service = new DeviceAuthService(redisTemplate, "https://skillhub.example.com/device"); + service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device", new ObjectMapper()); } @Test @@ -103,4 +110,56 @@ class DeviceAuthServiceTest { assertThat(data.getUserId()).isEqualTo("42"); verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(15L), eq(TimeUnit.MINUTES)); } + + @Test + void authorizeDeviceCode_accepts_linked_hash_map_from_redis_serializer() { + Map redisValue = new HashMap<>(); + redisValue.put("deviceCode", "device123"); + redisValue.put("userCode", "ABCD-1234"); + redisValue.put("status", "PENDING"); + redisValue.put("userId", null); + when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123"); + when(valueOperations.get("device:code:device123")).thenReturn(redisValue); + + service.authorizeDeviceCode("ABCD-1234", "42"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DeviceCodeData.class); + verify(valueOperations).set(eq("device:code:device123"), captor.capture(), eq(15L), eq(TimeUnit.MINUTES)); + assertThat(captor.getValue().getStatus()).isEqualTo(DeviceCodeStatus.AUTHORIZED); + assertThat(captor.getValue().getUserId()).isEqualTo("42"); + } + + @Test + void pollToken_accepts_linked_hash_map_from_redis_serializer() { + Map redisValue = new HashMap<>(); + redisValue.put("deviceCode", "device123"); + redisValue.put("userCode", "ABCD-1234"); + redisValue.put("status", "AUTHORIZED"); + redisValue.put("userId", "42"); + when(valueOperations.get("device:code:device123")).thenReturn(redisValue); + when(apiTokenService.createToken("42", "device-auth", "[]")) + .thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null)); + + DeviceTokenResponse response = service.pollToken("device123"); + + assertThat(response.error()).isNull(); + assertThat(response.accessToken()).isEqualTo("sk_device_token"); + assertThat(response.tokenType()).isEqualTo("Bearer"); + verify(valueOperations).set(eq("device:code:device123"), any(DeviceCodeData.class), eq(1L), eq(TimeUnit.MINUTES)); + } + + @Test + void pollToken_returns_access_token_when_authorized() { + DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42"); + when(valueOperations.get("device:code:device123")).thenReturn(data); + when(apiTokenService.createToken("42", "device-auth", "[]")) + .thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null)); + + DeviceTokenResponse response = service.pollToken("device123"); + + assertThat(response.error()).isNull(); + assertThat(response.accessToken()).isEqualTo("sk_device_token"); + assertThat(response.tokenType()).isEqualTo("Bearer"); + verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(1L), eq(TimeUnit.MINUTES)); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 01d4d581..006ba1bf 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -8,10 +8,12 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.*; +import jakarta.persistence.EntityManager; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.time.LocalDateTime; import java.util.ConcurrentModificationException; import java.util.List; @@ -27,6 +29,7 @@ public class PromotionService { private final NamespaceRepository namespaceRepository; private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; + private final EntityManager entityManager; public PromotionService(PromotionRequestRepository promotionRequestRepository, SkillRepository skillRepository, @@ -34,7 +37,8 @@ public class PromotionService { SkillFileRepository skillFileRepository, NamespaceRepository namespaceRepository, ReviewPermissionChecker permissionChecker, - ApplicationEventPublisher eventPublisher) { + ApplicationEventPublisher eventPublisher, + EntityManager entityManager) { this.promotionRequestRepository = promotionRequestRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -42,6 +46,7 @@ public class PromotionService { this.namespaceRepository = namespaceRepository; this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; + this.entityManager = entityManager; } @Transactional @@ -96,6 +101,11 @@ public class PromotionService { if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } + entityManager.detach(request); + request.setStatus(ReviewTaskStatus.APPROVED); + request.setReviewedBy(reviewerId); + request.setReviewComment(comment); + request.setReviewedAt(Instant.now()); Skill sourceSkill = skillRepository.findById(request.getSourceSkillId()) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId())); @@ -138,9 +148,12 @@ public class PromotionService { .toList(); skillFileRepository.saveAll(copiedFiles); - // Update promotion request with target skill id + int targetUpdated = promotionRequestRepository.updateStatusWithVersion( + promotionId, ReviewTaskStatus.APPROVED, reviewerId, comment, newSkill.getId(), request.getVersion() + 1); + if (targetUpdated == 0) { + throw new ConcurrentModificationException("Promotion request target skill was modified concurrently"); + } request.setTargetSkillId(newSkill.getId()); - promotionRequestRepository.save(request); eventPublisher.publishEvent(new SkillPublishedEvent( newSkill.getId(), newVersion.getId(), reviewerId)); @@ -167,7 +180,11 @@ public class PromotionService { if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } - - return promotionRequestRepository.findById(promotionId).orElse(request); + entityManager.detach(request); + request.setStatus(ReviewTaskStatus.REJECTED); + request.setReviewedBy(reviewerId); + request.setReviewComment(comment); + request.setReviewedAt(Instant.now()); + return request; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index 0cc89df1..259372c9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -30,10 +30,14 @@ public class ReviewPermissionChecker { return false; } + if (platformRoles.contains("SKILL_ADMIN") + || platformRoles.contains("SUPER_ADMIN")) { + return true; + } + // Global namespace: only SKILL_ADMIN or SUPER_ADMIN if (namespaceType == NamespaceType.GLOBAL) { - return platformRoles.contains("SKILL_ADMIN") - || platformRoles.contains("SUPER_ADMIN"); + return false; } // Team namespace: namespace ADMIN or OWNER diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index fb2482af..a9392024 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -12,6 +12,7 @@ import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import jakarta.persistence.EntityManager; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -32,19 +33,22 @@ public class ReviewService { private final NamespaceRepository namespaceRepository; private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; + private final EntityManager entityManager; public ReviewService(ReviewTaskRepository reviewTaskRepository, SkillVersionRepository skillVersionRepository, SkillRepository skillRepository, NamespaceRepository namespaceRepository, ReviewPermissionChecker permissionChecker, - ApplicationEventPublisher eventPublisher) { + ApplicationEventPublisher eventPublisher, + EntityManager entityManager) { this.reviewTaskRepository = reviewTaskRepository; this.skillVersionRepository = skillVersionRepository; this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; + this.entityManager = entityManager; } @Transactional @@ -91,6 +95,11 @@ public class ReviewService { if (updated == 0) { throw new ConcurrentModificationException("Review task was modified concurrently"); } + entityManager.detach(task); + task.setStatus(ReviewTaskStatus.APPROVED); + task.setReviewedBy(reviewerId); + task.setReviewComment(comment); + task.setReviewedAt(Instant.now()); SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); @@ -106,8 +115,7 @@ public class ReviewService { eventPublisher.publishEvent(new SkillPublishedEvent( skill.getId(), skillVersion.getId(), reviewerId)); - // Reload to return updated state - return reviewTaskRepository.findById(reviewTaskId).orElse(task); + return task; } @Transactional @@ -134,13 +142,18 @@ public class ReviewService { if (updated == 0) { throw new ConcurrentModificationException("Review task was modified concurrently"); } + entityManager.detach(task); + task.setStatus(ReviewTaskStatus.REJECTED); + task.setReviewedBy(reviewerId); + task.setReviewComment(comment); + task.setReviewedAt(Instant.now()); SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); skillVersion.setStatus(SkillVersionStatus.REJECTED); skillVersionRepository.save(skillVersion); - return reviewTaskRepository.findById(reviewTaskId).orElse(task); + return task; } @Transactional diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 2d6fa0bb..009e8103 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -214,6 +214,9 @@ public class SkillPublishService { version.setTotalSize(totalSize); skillVersionRepository.save(version); + ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId); + reviewTaskRepository.save(reviewTask); + // 12. Update skill skill.setLatestVersionId(version.getId()); skill.setDisplayName(metadata.name()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java index 814c1ff7..7d8a6235 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -11,16 +13,20 @@ import java.util.Optional; @Service public class SkillRatingService { private final SkillRatingRepository ratingRepository; + private final SkillRepository skillRepository; private final ApplicationEventPublisher eventPublisher; public SkillRatingService(SkillRatingRepository ratingRepository, + SkillRepository skillRepository, ApplicationEventPublisher eventPublisher) { this.ratingRepository = ratingRepository; + this.skillRepository = skillRepository; this.eventPublisher = eventPublisher; } @Transactional public void rate(Long skillId, String userId, short score) { + ensureSkillExists(skillId); if (score < 1 || score > 5) { throw new DomainBadRequestException("error.rating.score.invalid"); } @@ -35,7 +41,14 @@ public class SkillRatingService { } public Optional getUserRating(Long skillId, String userId) { + ensureSkillExists(skillId); return ratingRepository.findBySkillIdAndUserId(skillId, userId) .map(SkillRating::getScore); } + + private void ensureSkillExists(Long skillId) { + if (skillRepository.findById(skillId).isEmpty()) { + throw new DomainNotFoundException("skill.not_found", skillId); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java index 2576d457..b1dd49bc 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.domain.social; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; import org.springframework.context.ApplicationEventPublisher; @@ -9,16 +11,20 @@ import org.springframework.transaction.annotation.Transactional; @Service public class SkillStarService { private final SkillStarRepository starRepository; + private final SkillRepository skillRepository; private final ApplicationEventPublisher eventPublisher; public SkillStarService(SkillStarRepository starRepository, + SkillRepository skillRepository, ApplicationEventPublisher eventPublisher) { this.starRepository = starRepository; + this.skillRepository = skillRepository; this.eventPublisher = eventPublisher; } @Transactional public void star(Long skillId, String userId) { + ensureSkillExists(skillId); if (starRepository.findBySkillIdAndUserId(skillId, userId).isPresent()) { return; // idempotent } @@ -28,6 +34,7 @@ public class SkillStarService { @Transactional public void unstar(Long skillId, String userId) { + ensureSkillExists(skillId); starRepository.findBySkillIdAndUserId(skillId, userId).ifPresent(star -> { starRepository.delete(star); eventPublisher.publishEvent(new SkillUnstarredEvent(skillId, userId)); @@ -35,6 +42,13 @@ public class SkillStarService { } public boolean isStarred(Long skillId, String userId) { + ensureSkillExists(skillId); return starRepository.findBySkillIdAndUserId(skillId, userId).isPresent(); } + + private void ensureSkillExists(Long skillId) { + if (skillRepository.findById(skillId).isEmpty()) { + throw new DomainNotFoundException("skill.not_found", skillId); + } + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index 7670a76d..b73e9e52 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -16,6 +16,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import jakarta.persistence.EntityManager; import java.util.*; @@ -34,6 +35,7 @@ class PromotionServiceTest { @Mock private NamespaceRepository namespaceRepository; @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private EntityManager entityManager; private PromotionService promotionService; @@ -50,7 +52,7 @@ class PromotionServiceTest { void setUp() { promotionService = new PromotionService( promotionRequestRepository, skillRepository, skillVersionRepository, - skillFileRepository, namespaceRepository, permissionChecker, eventPublisher); + skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, entityManager); } private static void setField(Object target, String fieldName, Object value) { @@ -233,6 +235,9 @@ class PromotionServiceTest { when(promotionRequestRepository.updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", null, pr.getVersion())) .thenReturn(1); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", NEW_SKILL_ID, pr.getVersion() + 1)) + .thenReturn(1); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> { @@ -292,8 +297,9 @@ class PromotionServiceTest { assertEquals(NEW_VERSION_ID, event.versionId()); assertEquals(REVIEWER_ID, event.publisherId()); - // Verify targetSkillId updated on promotion request - verify(promotionRequestRepository).save(pr); + verify(entityManager).detach(pr); + verify(promotionRequestRepository).updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", NEW_SKILL_ID, pr.getVersion() + 1); assertEquals(NEW_SKILL_ID, pr.getTargetSkillId()); } @@ -346,6 +352,9 @@ class PromotionServiceTest { when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); when(promotionRequestRepository.updateStatusWithVersion(any(), any(), any(), any(), any(), any())).thenReturn(1); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "ok", NEW_SKILL_ID, pr.getVersion() + 1)) + .thenReturn(1); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> { @@ -382,14 +391,15 @@ class PromotionServiceTest { when(promotionRequestRepository.updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion())) .thenReturn(1); - when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); - PromotionRequest result = promotionService.rejectPromotion( PROMOTION_ID, REVIEWER_ID, "Not ready", Set.of("SKILL_ADMIN")); assertNotNull(result); + assertEquals(ReviewTaskStatus.REJECTED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); verify(promotionRequestRepository).updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion()); + verify(entityManager).detach(pr); verify(eventPublisher, never()).publishEvent(any()); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index f3d43cd9..45a5a924 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -66,11 +66,19 @@ class ReviewPermissionCheckerTest { @Test void skillAdminCannotReviewTeamSkill() { ReviewTask task = new ReviewTask(1L, 10L, "user-2"); - assertFalse(checker.canReview(task, "user-1", + assertTrue(checker.canReview(task, "user-1", NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN"))); } + @Test + void superAdminCanReviewTeamSkill() { + ReviewTask task = new ReviewTask(1L, 10L, "user-2"); + assertTrue(checker.canReview(task, "user-1", + NamespaceType.TEAM, + Map.of(), Set.of("SUPER_ADMIN"))); + } + @Test void nonAdminCannotReviewGlobalSkill() { ReviewTask task = new ReviewTask(1L, 1L, "user-2"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index 297a7701..42bbad07 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -41,6 +42,7 @@ class ReviewServiceTest { @Mock private NamespaceRepository namespaceRepository; @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private EntityManager entityManager; private ReviewService reviewService; @@ -55,7 +57,7 @@ class ReviewServiceTest { void setUp() { reviewService = new ReviewService( reviewTaskRepository, skillVersionRepository, skillRepository, - namespaceRepository, permissionChecker, eventPublisher); + namespaceRepository, permissionChecker, eventPublisher, entityManager); } private SkillVersion createDraftSkillVersion() { @@ -164,13 +166,16 @@ class ReviewServiceTest { .thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); - when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); ReviewTask result = reviewService.approveReview( REVIEW_TASK_ID, REVIEWER_ID, "LGTM", Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); assertNotNull(result); + assertEquals(ReviewTaskStatus.APPROVED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("LGTM", result.getReviewComment()); + assertNotNull(result.getReviewedAt()); assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus()); assertNotNull(sv.getPublishedAt()); assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId()); @@ -190,7 +195,6 @@ class ReviewServiceTest { when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); - when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); @@ -262,13 +266,16 @@ class ReviewServiceTest { when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true); when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); - when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); ReviewTask result = reviewService.rejectReview( REVIEW_TASK_ID, REVIEWER_ID, "needs work", Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); assertNotNull(result); + assertEquals(ReviewTaskStatus.REJECTED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("needs work", result.getReviewComment()); + assertNotNull(result.getReviewedAt()); assertEquals(SkillVersionStatus.REJECTED, sv.getStatus()); verify(skillVersionRepository).save(sv); verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 416d441e..7f87af7b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.*; @@ -128,6 +129,7 @@ class SkillPublishServiceTest { verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); verify(skillFileRepository).saveAll(anyList()); verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString()); + verify(reviewTaskRepository).save(any(ReviewTask.class)); } @Test @@ -168,6 +170,7 @@ class SkillPublishServiceTest { assertEquals("smoke-skill-two", result.slug()); verify(skillRepository).findByNamespaceIdAndSlug(1L, "smoke-skill-two"); + verify(reviewTaskRepository).save(any(ReviewTask.class)); } @Test diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java index 29ebd961..00bf2133 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java @@ -1,6 +1,10 @@ package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,11 +20,17 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SkillRatingServiceTest { @Mock SkillRatingRepository ratingRepository; + @Mock SkillRepository skillRepository; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks SkillRatingService service; + private Skill skill() { + return new Skill(1L, "skill-1", "owner-1", SkillVisibility.PUBLIC); + } + @Test void rate_creates_new_rating() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty()); when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -32,6 +42,7 @@ class SkillRatingServiceTest { @Test void rate_updates_existing_rating() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); SkillRating existing = new SkillRating(1L, "10", (short) 3); when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing)); when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -45,6 +56,7 @@ class SkillRatingServiceTest { @Test void rate_invalid_score_throws() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); assertThatThrownBy(() -> service.rate(1L, "10", (short) 0)) .isInstanceOf(DomainBadRequestException.class); assertThatThrownBy(() -> service.rate(1L, "10", (short) 6)) @@ -53,8 +65,16 @@ class SkillRatingServiceTest { @Test void getUserRating_returns_score() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); SkillRating existing = new SkillRating(1L, "10", (short) 4); when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing)); assertThat(service.getUserRating(1L, "10")).hasValue((short) 4); } + + @Test + void getUserRating_throws_when_skill_missing() { + when(skillRepository.findById(99L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.getUserRating(99L, "10")) + .isInstanceOf(DomainNotFoundException.class); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java index a9e30d1b..2c4a2197 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java @@ -1,5 +1,9 @@ package com.iflytek.skillhub.domain.social; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; import org.junit.jupiter.api.Test; @@ -16,11 +20,17 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SkillStarServiceTest { @Mock SkillStarRepository starRepository; + @Mock SkillRepository skillRepository; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks SkillStarService service; + private Skill skill() { + return new Skill(1L, "skill-1", "owner-1", SkillVisibility.PUBLIC); + } + @Test void star_skill_creates_record_and_publishes_event() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty()); when(starRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -32,6 +42,7 @@ class SkillStarServiceTest { @Test void star_skill_already_starred_is_idempotent() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")) .thenReturn(Optional.of(new SkillStar(1L, "10"))); @@ -43,6 +54,7 @@ class SkillStarServiceTest { @Test void unstar_skill_deletes_record_and_publishes_event() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); SkillStar existing = new SkillStar(1L, "10"); when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing)); @@ -54,6 +66,7 @@ class SkillStarServiceTest { @Test void unstar_skill_not_starred_is_noop() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty()); service.unstar(1L, "10"); @@ -64,8 +77,16 @@ class SkillStarServiceTest { @Test void isStarred_returns_true_when_exists() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")) .thenReturn(Optional.of(new SkillStar(1L, "10"))); assertThat(service.isStarred(1L, "10")).isTrue(); } + + @Test + void star_skill_throws_when_skill_missing() { + when(skillRepository.findById(99L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.star(99L, "10")) + .isInstanceOf(DomainNotFoundException.class); + } } diff --git a/web/index.html b/web/index.html index 3b775782..d4911822 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,9 @@ SkillHub + + +
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 5685be3f..ac516d8a 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -5,31 +5,51 @@ export function Layout() { const { user, isLoading } = useAuth() return ( -
-
-
- - SkillHub +
+ {/* Glow orbs */} +
+
+ + {/* Glass header */} +
+
+ +
+ S +
+ + SkillHub + -
-
+ +
+ + {/* Footer */} +
+
+
+
+
+
+ S +
+ SkillHub +
+

+ 现代化的技能注册中心,为开发者提供高效的技能管理和分发平台。 +

+
+ +
+

快速链接

+
    +
  • + + 首页 + +
  • +
  • + + 搜索技能 + +
  • +
  • + + Dashboard + +
  • +
+
+ +
+

资源

+ +
+
+ +
+

+ © 2024 SkillHub. All rights reserved. +

+ +
+
+
) } diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index 57db8e2f..1ddb4812 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -14,7 +14,8 @@ export function LoginButton() { if (isLoading) { return (
-
@@ -26,11 +27,15 @@ export function LoginButton() { {providers.map((provider) => ( ))} diff --git a/web/src/features/publish/upload-zone.tsx b/web/src/features/publish/upload-zone.tsx index 6ffa4ac3..f9ba9093 100644 --- a/web/src/features/publish/upload-zone.tsx +++ b/web/src/features/publish/upload-zone.tsx @@ -30,32 +30,37 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
-
- - - +
+
+ + + +
{isDragActive ? ( -

放开以上传文件...

+

放开以上传文件...

) : ( <> -

拖拽 ZIP 文件到此处,或点击选择

+

拖拽 ZIP 文件到此处,或点击选择

仅支持 .zip 格式

)} diff --git a/web/src/features/search/search-bar.tsx b/web/src/features/search/search-bar.tsx index ba2f6643..a5c07ee3 100644 --- a/web/src/features/search/search-bar.tsx +++ b/web/src/features/search/search-bar.tsx @@ -1,4 +1,4 @@ -import { useState, type FormEvent } from 'react' +import { useState } from 'react' import { Input } from '@/shared/ui/input' import { Button } from '@/shared/ui/button' @@ -11,7 +11,7 @@ interface SearchBarProps { export function SearchBar({ defaultValue = '', placeholder = '搜索技能...', onSearch }: SearchBarProps) { const [query, setQuery] = useState(defaultValue) - const handleSubmit = (e: FormEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (onSearch) { onSearch(query) @@ -19,15 +19,32 @@ export function SearchBar({ defaultValue = '', placeholder = '搜索技能...', } return ( -
- setQuery(e.target.value)} - placeholder={placeholder} - className="flex-1" - /> - + +
+ + + + setQuery(e.target.value)} + placeholder={placeholder} + className="pl-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12" + /> +
+
) } diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index ab52ac80..e688abc5 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -10,28 +10,47 @@ interface SkillCardProps { export function SkillCard({ skill, onClick }: SkillCardProps) { return ( -
-

{skill.displayName}

- -
+ {/* Hover gradient border effect */} +
- {skill.summary && ( -

- {skill.summary} -

- )} +
+
+

+ {skill.displayName} +

+ +
-
- {skill.latestVersion && ( - v{skill.latestVersion} - )} - {skill.downloadCount} 下载 - {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( - ⭐ {skill.ratingAvg.toFixed(1)} ({skill.ratingCount}) + {skill.summary && ( +

+ {skill.summary} +

)} + +
+ {skill.latestVersion && ( + + v{skill.latestVersion} + + )} + + + + + {skill.downloadCount} + + {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( + + + + + {skill.ratingAvg.toFixed(1)} ({skill.ratingCount}) + + )} +
) diff --git a/web/src/index.css b/web/src/index.css index 10c2d37f..02742352 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -4,48 +4,63 @@ @layer base { :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + /* Aurora Tech — light theme (default) */ + --background: 210 40% 97%; + --foreground: 222 47% 11%; --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + --card-foreground: 222 47% 11%; --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; - --radius: 0.5rem; + --popover-foreground: 222 47% 11%; + /* Cyan primary */ + --primary: 192 80% 42%; + --primary-foreground: 0 0% 100%; + /* Surface tones */ + --secondary: 210 30% 93%; + --secondary-foreground: 222 47% 11%; + --muted: 210 25% 92%; + --muted-foreground: 215 16% 42%; + /* Violet accent */ + --accent: 263 60% 58%; + --accent-foreground: 0 0% 100%; + --destructive: 0 72% 55%; + --destructive-foreground: 0 0% 100%; + --border: 214 20% 88%; + --input: 214 20% 88%; + --ring: 192 80% 42%; + --radius: 0.75rem; + + /* Extended palette */ + --surface-glass: 210 30% 95%; + --glow-primary: 192 80% 42%; + --glow-accent: 263 60% 58%; + --success: 160 60% 45%; + --warning: 38 92% 58%; } .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; + /* Aurora Tech — deep navy dark theme */ + --background: 222 47% 6%; + --foreground: 210 40% 96%; + --card: 222 40% 9%; + --card-foreground: 210 40% 96%; + --popover: 222 40% 9%; + --popover-foreground: 210 40% 96%; + --primary: 192 91% 56%; + --primary-foreground: 222 47% 6%; + --secondary: 222 30% 13%; + --secondary-foreground: 210 30% 85%; + --muted: 222 25% 15%; + --muted-foreground: 215 20% 55%; + --accent: 263 70% 70%; + --accent-foreground: 0 0% 100%; + --destructive: 0 72% 55%; + --destructive-foreground: 0 0% 100%; + --border: 222 20% 18%; + --input: 222 20% 18%; + --ring: 192 91% 56%; + --surface-glass: 222 35% 11%; + --glow-primary: 192 91% 56%; + --glow-accent: 263 70% 70%; } } @@ -53,7 +68,207 @@ * { @apply border-border; } + + html { + scroll-behavior: smooth; + } + body { - @apply bg-background text-foreground; + @apply bg-background text-foreground antialiased; + font-family: 'DM Sans', system-ui, sans-serif; + } + + h1, h2, h3, h4, h5, h6 { + font-family: 'Outfit', 'DM Sans', system-ui, sans-serif; + } + + code, pre, kbd { + font-family: 'JetBrains Mono', ui-monospace, monospace; } } + +/* ─── Dot-grid background texture ─── */ +.bg-dots { + background-image: radial-gradient(circle, hsl(var(--muted-foreground) / 0.12) 1px, transparent 1px); + background-size: 24px 24px; +} + +/* ─── Gradient glow orbs ─── */ +.glow-orb-primary { + position: fixed; + width: 600px; + height: 600px; + border-radius: 50%; + background: radial-gradient(circle, hsl(var(--glow-primary) / 0.08) 0%, transparent 70%); + pointer-events: none; + z-index: 0; +} + +.glow-orb-accent { + position: fixed; + width: 500px; + height: 500px; + border-radius: 50%; + background: radial-gradient(circle, hsl(var(--glow-accent) / 0.06) 0%, transparent 70%); + pointer-events: none; + z-index: 0; +} + +/* ─── Glass morphism ─── */ +.glass { + background: hsl(var(--surface-glass) / 0.7); + backdrop-filter: blur(16px) saturate(1.4); + -webkit-backdrop-filter: blur(16px) saturate(1.4); + border: 1px solid hsl(var(--border) / 0.5); +} + +.glass-strong { + background: hsl(var(--surface-glass) / 0.85); + backdrop-filter: blur(24px) saturate(1.6); + -webkit-backdrop-filter: blur(24px) saturate(1.6); + border: 1px solid hsl(var(--border) / 0.6); +} + +/* ─── Animations ─── */ +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slide-in-right { + from { + opacity: 0; + transform: translateX(16px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } +} + +@keyframes pulse-glow { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } +} + +.animate-fade-up { + animation: fade-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.animate-fade-in { + animation: fade-in 0.5s ease both; +} + +.animate-slide-in-right { + animation: slide-in-right 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.animate-shimmer { + background: linear-gradient( + 90deg, + hsl(var(--muted)) 25%, + hsl(var(--muted-foreground) / 0.08) 50%, + hsl(var(--muted)) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.8s ease-in-out infinite; +} + +.animate-float { + animation: float 6s ease-in-out infinite; +} + +/* Stagger delays */ +.delay-1 { animation-delay: 0.1s; } +.delay-2 { animation-delay: 0.2s; } +.delay-3 { animation-delay: 0.3s; } +.delay-4 { animation-delay: 0.4s; } +.delay-5 { animation-delay: 0.5s; } +.delay-6 { animation-delay: 0.6s; } + +/* ─── Gradient text ─── */ +.text-gradient-primary { + background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--primary) / 0.7)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.text-gradient-hero { + background: linear-gradient(135deg, hsl(192 80% 38%), hsl(192 80% 42%), hsl(263 60% 52%)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.dark .text-gradient-hero { + background: linear-gradient(135deg, hsl(192 95% 65%), hsl(192 91% 56%), hsl(263 70% 70%)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ─── Card hover lift ─── */ +.card-hover { + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), + box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1), + border-color 0.3s ease; +} + +.card-hover:hover { + transform: translateY(-4px); + box-shadow: 0 20px 40px -12px hsl(var(--primary) / 0.1), + 0 8px 16px -8px hsl(0 0% 0% / 0.2); + border-color: hsl(var(--primary) / 0.3); +} + +/* ─── Scrollbar ─── */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: hsl(var(--background)); +} + +::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.3); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.5); +} + +/* ─── Selection ─── */ +::selection { + background: hsl(var(--primary) / 0.3); + color: hsl(var(--foreground)); +} + +/* ─── Focus ring ─── */ +.focus-ring { + @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background; +} diff --git a/web/src/pages/admin/audit-log.tsx b/web/src/pages/admin/audit-log.tsx index 67399088..cca6a177 100644 --- a/web/src/pages/admin/audit-log.tsx +++ b/web/src/pages/admin/audit-log.tsx @@ -30,13 +30,13 @@ export function AuditLogPage() { } return ( -
+
-

审计日志

-

查看系统操作记录

+

审计日志

+

查看系统操作记录

- +
{isLoading ? ( -
加载中...
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
) : !data || data.items.length === 0 ? (

暂无用户数据

@@ -132,10 +136,10 @@ export function AdminUsersPage() { {user.email} {user.status === 'ACTIVE' ? '活跃' : '已禁用'} diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index 5116fca0..b4358bfb 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -6,10 +6,10 @@ export function DashboardPage() { const { user } = useAuth() return ( -
+
-

Dashboard

-

+

Dashboard

+

管理你的账户和 API Tokens

@@ -19,31 +19,32 @@ export function DashboardPage() { 用户信息 你的账户详情 - -
+ +
{user?.avatarUrl && ( {user.displayName} )} -
-
{user?.displayName}
+
+
{user?.displayName}
{user?.email}
-
+
+ 通过 {user?.oauthProvider} 登录
{user?.platformRoles && user.platformRoles.length > 0 && ( -
-
平台角色
+
+
平台角色
{user.platformRoles.map((role) => ( {role} diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index cb58340f..d95d7e7b 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -19,43 +19,51 @@ export function MyNamespacesPage() { } if (isLoading) { - return
加载中...
+ return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) } return ( -
+
-

我的命名空间

-

管理你的命名空间和团队

+

我的命名空间

+

管理你的命名空间和团队

{namespaces && namespaces.length > 0 ? ( -
- {namespaces.map((namespace) => ( +
+ {namespaces.map((namespace, idx) => ( handleNamespaceClick(namespace.slug)} > -
+
-
-

{namespace.displayName}

+
+

+ {namespace.displayName} +

{namespace.description && ( -

+

{namespace.description}

)} -
@{namespace.slug}
+
@{namespace.slug}
{namespace.type === 'TEAM' && ( diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index abe5f932..39e3ac4b 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -13,41 +13,59 @@ export function MySkillsPage() { } if (isLoading) { - return
加载中...
+ return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) } return ( -
+
-

我的技能

-

管理你发布的技能

+

我的技能

+

管理你发布的技能

-
{skills && skills.length > 0 ? (
- {skills.map((skill) => ( + {skills.map((skill, idx) => ( handleSkillClick(skill.namespace, skill.slug)} >
-

{skill.displayName}

+

+ {skill.displayName} +

{skill.summary && ( -

{skill.summary}

+

{skill.summary}

)}
- @{skill.namespace} - {skill.latestVersion && v{skill.latestVersion}} - {skill.downloadCount} 下载 + @{skill.namespace} + {skill.latestVersion && ( + v{skill.latestVersion} + )} + + + + + {skill.downloadCount} +
+ + +
))} @@ -57,7 +75,7 @@ export function MySkillsPage() { title="还没有技能" description="开始发布你的第一个技能吧" action={ - } diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index c1326cc2..b764761b 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -11,43 +11,56 @@ export function NamespaceMembersPage() { const { data: members, isLoading: isLoadingMembers } = useNamespaceMembers(slug) if (isLoadingNamespace) { - return
加载中...
+ return ( +
+
+
+
+ ) } if (!namespace) { - return
命名空间不存在
+ return ( +
+

命名空间不存在

+
+ ) } return ( -
+
-
+
-

成员管理

+

成员管理

{isLoadingMembers ? ( -
加载中...
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
) : members && members.length > 0 ? ( - +
- - - - - + + + + + {members.map((member) => ( - - + + diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 1303d4b3..2481861a 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -36,18 +36,18 @@ export function PublishPage() { } return ( -
+
-

发布技能

-

上传技能包到 SkillHub

+

发布技能

+

上传技能包到 SkillHub

- + {/* Namespace Selector */} -
- +
+ {isLoadingNamespaces ? ( -
加载中...
+
) : ( {/* Upload Zone */} -
- +
+ {selectedFile && ( -
- 已选择: {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB) +
+ + + + {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
)}
@@ -95,11 +98,11 @@ export function PublishPage() { {/* Publish Button */} + {publishMutation.isPending ? '发布中...' : '确认发布'}
) diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index b3746dbb..4631b846 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -53,18 +53,27 @@ export function ReviewDetailPage() { } if (isLoading) { - return
加载中...
+ return ( +
+
+
+
+ ) } if (!review) { - return
审核任务不存在
+ return ( +
+

审核任务不存在

+
+ ) } return ( -
+
-

审核详情

+

审核详情

审核 ID: {review.id}

- -
-
- -

{review.skillName}

+ +
+
+ +

{review.skillName}

-
- -

{review.namespace}/{review.skillSlug}

+
+ +

{review.namespace}/{review.skillSlug}

-
- -

{review.version}

-
-
- -

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

+ +

+ + {review.version} +

-
- -

{review.submittedBy}

+
+ +

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

-
- -

{formatDate(review.submittedAt)}

+
+ +

{review.submittedBy}

+
+
+ +

{formatDate(review.submittedAt)}

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

{review.reviewedBy}

+
+ +

{review.reviewedBy}

-
- -

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

+ +

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

@@ -119,19 +138,19 @@ export function ReviewDetailPage() {
{review.comment && ( -
- -

{review.comment}

+
+ +

{review.comment}

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

审核操作

+ +

审核操作

-
- +
+
用户 ID角色加入时间操作
用户 ID角色加入时间操作
{member.userId}
{member.userId} - + {member.role}