From f91a4b8a86fb0e9a6f5e8787594ebf7f7c8da43c Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 10:06:14 +0800 Subject: [PATCH 001/313] Revert "merge: bring review fixes into feature/project-init" This reverts commit f2e8079a247082e447b25b409b31fc4699119693, reversing changes made to 10665b6e0e7594e6443b176bb8282bfd9fa58ffb. --- .github/workflows/publish-images.yml | 3 +- README.md | 103 +++++------ scripts/runtime.sh | 174 ------------------ .../skillhub/config/DomainBeanConfig.java | 11 +- .../config/SkillPublishProperties.java | 53 ------ .../controller/cli/CliPublishController.java | 40 +++- .../portal/PromotionController.java | 34 +--- .../controller/portal/ReviewController.java | 47 ++--- .../controller/portal/SkillController.java | 10 +- .../portal/SkillPublishController.java | 40 +++- .../controller/portal/SkillTagController.java | 13 +- .../support/ZipPackageExtractor.java | 127 ------------- .../src/main/resources/application.yml | 4 +- .../skillhub/auth/config/SecurityConfig.java | 16 +- .../auth/device/DeviceAuthService.java | 63 ++----- .../auth/device/DeviceAuthServiceTest.java | 17 +- .../domain/review/PromotionService.java | 13 +- .../review/ReviewPermissionChecker.java | 60 +----- .../skillhub/domain/review/ReviewService.java | 29 +-- .../skill/service/SkillPublishService.java | 13 +- .../skill/service/SkillQueryService.java | 7 +- .../domain/skill/service/SkillTagService.java | 13 +- .../validation/SkillPackageValidator.java | 100 +++------- .../domain/review/PromotionServiceTest.java | 23 +-- .../domain/review/ReviewServiceTest.java | 17 +- .../service/SkillPublishServiceTest.java | 6 + .../skill/service/SkillTagServiceTest.java | 8 +- .../storage/LocalFileStorageService.java | 10 +- 28 files changed, 231 insertions(+), 823 deletions(-) delete mode 100644 scripts/runtime.sh delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java delete mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index f4575c38e..74655dc6f 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - feature/project-init tags: - "v*.*.*" workflow_dispatch: @@ -59,7 +60,7 @@ jobs: images: ${{ matrix.image }} tags: | type=raw,value=edge,enable={{is_default_branch}} - type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') }} + 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}} diff --git a/README.md b/README.md index 893025a12..a4ed20c5d 100644 --- a/README.md +++ b/README.md @@ -37,24 +37,62 @@ firewall, with the same polish you'd expect from a public registry. - Docker & Docker Compose -### One-Command Runtime +### Local Development + +```bash +make dev-all +``` + +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 +make dev-all-down +``` + +Reset local dependencies and start from a clean slate with: + +```bash +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 recommended path for anyone who wants a ready-to-use local +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`. -Start the latest runtime from `main`: +1. Copy the runtime environment template. +2. Pick an image tag. +3. Start the stack with Docker Compose. ```bash -curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up +cp .env.release.example .env.release ``` -Start the first beta release explicitly: +Recommended image tags: + +- `SKILLHUB_VERSION=edge` for the latest `main` build +- `SKILLHUB_VERSION=vX.Y.Z` for a fixed release + +Start the runtime: ```bash -curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | \ - sh -s -- up --version v0.1.0-beta.1 +docker compose --env-file .env.release -f compose.release.yml up -d ``` Then open: @@ -62,30 +100,15 @@ Then open: - Web UI: `http://localhost` - Backend API: `http://localhost:8080` -The script downloads the runtime files into `${TMPDIR:-/tmp}/skillhub-runtime` -by default and starts Docker Compose from there, so it does not pollute -your current working directory. - -If you want a persistent location instead of the temp directory: +Stop it with: ```bash -curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | \ - SKILLHUB_HOME=$HOME/.skillhub-runtime sh -s -- up --version v0.1.0-beta.1 +docker compose --env-file .env.release -f compose.release.yml down ``` -Other useful commands: - -- `curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- down` -- `curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- clean` -- `curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- ps` -- `curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- logs` - The runtime stack uses its own Compose project name, so it does not collide with containers from `make dev-all`. -Use `clean` if you also want to remove the downloaded runtime files from -`${TMPDIR:-/tmp}/skillhub-runtime`. - 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: @@ -97,38 +120,6 @@ 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`. -### Local Development - -```bash -make dev-all -``` - -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 -make dev-all-down -``` - -Reset local dependencies and start from a clean slate with: - -```bash -make dev-all-reset -``` - -Run `make help` to see all available commands. - ## Architecture ``` diff --git a/scripts/runtime.sh b/scripts/runtime.sh deleted file mode 100644 index 1d04b4d5c..000000000 --- a/scripts/runtime.sh +++ /dev/null @@ -1,174 +0,0 @@ -#!/bin/sh - -set -eu - -COMMAND="up" -if [ "$#" -gt 0 ] && [ "${1#-}" = "$1" ]; then - COMMAND="$1" - shift -fi - -SKILLHUB_REF="${SKILLHUB_REF:-main}" -SKILLHUB_HOME_DEFAULT="${TMPDIR:-/tmp}/skillhub-runtime" -SKILLHUB_HOME="${SKILLHUB_HOME:-$SKILLHUB_HOME_DEFAULT}" -SKILLHUB_VERSION_VALUE="${SKILLHUB_VERSION:-}" -SKILLHUB_SERVER_IMAGE_VALUE="${SKILLHUB_SERVER_IMAGE:-}" -SKILLHUB_WEB_IMAGE_VALUE="${SKILLHUB_WEB_IMAGE:-}" - -while [ "$#" -gt 0 ]; do - case "$1" in - --version) - [ "$#" -ge 2 ] || { echo "Missing value for --version" >&2; exit 1; } - SKILLHUB_VERSION_VALUE="$2" - shift 2 - ;; - --home) - [ "$#" -ge 2 ] || { echo "Missing value for --home" >&2; exit 1; } - SKILLHUB_HOME="$2" - shift 2 - ;; - --ref) - [ "$#" -ge 2 ] || { echo "Missing value for --ref" >&2; exit 1; } - SKILLHUB_REF="$2" - shift 2 - ;; - --server-image) - [ "$#" -ge 2 ] || { echo "Missing value for --server-image" >&2; exit 1; } - SKILLHUB_SERVER_IMAGE_VALUE="$2" - shift 2 - ;; - --web-image) - [ "$#" -ge 2 ] || { echo "Missing value for --web-image" >&2; exit 1; } - SKILLHUB_WEB_IMAGE_VALUE="$2" - shift 2 - ;; - --help|-h) - cat < Use a specific image tag, for example v0.1.0 - --home Store runtime files in a specific directory - --ref Download runtime files from a specific Git ref - --server-image Override backend image repository - --web-image Override frontend image repository -EOF - exit 0 - ;; - *) - echo "Unsupported argument: $1" >&2 - exit 1 - ;; - esac -done - -SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}" -COMPOSE_FILE="$SKILLHUB_HOME/compose.release.yml" -ENV_EXAMPLE_FILE="$SKILLHUB_HOME/.env.release.example" -ENV_FILE="$SKILLHUB_HOME/.env.release" - -find_compose() { - if docker compose version >/dev/null 2>&1; then - echo "docker compose" - return 0 - fi - - if command -v docker-compose >/dev/null 2>&1; then - echo "docker-compose" - return 0 - fi - - echo "Docker Compose is required." >&2 - exit 1 -} - -download_file() { - src="$1" - dest="$2" - tmp="$dest.tmp" - curl -fsSL "$src" -o "$tmp" - mv "$tmp" "$dest" -} - -set_env_value() { - key="$1" - value="$2" - - if [ ! -f "$ENV_FILE" ]; then - return 0 - fi - - tmp="$ENV_FILE.tmp" - if grep -q "^$key=" "$ENV_FILE"; then - sed "s|^$key=.*|$key=$value|" "$ENV_FILE" >"$tmp" - else - cat "$ENV_FILE" >"$tmp" - printf '%s=%s\n' "$key" "$value" >>"$tmp" - fi - mv "$tmp" "$ENV_FILE" -} - -prepare_runtime_files() { - mkdir -p "$SKILLHUB_HOME" - download_file "$SKILLHUB_RAW_BASE/compose.release.yml" "$COMPOSE_FILE" - download_file "$SKILLHUB_RAW_BASE/.env.release.example" "$ENV_EXAMPLE_FILE" - - if [ ! -f "$ENV_FILE" ]; then - cp "$ENV_EXAMPLE_FILE" "$ENV_FILE" - fi - - if [ -n "$SKILLHUB_VERSION_VALUE" ]; then - set_env_value "SKILLHUB_VERSION" "$SKILLHUB_VERSION_VALUE" - fi - - if [ -n "$SKILLHUB_SERVER_IMAGE_VALUE" ]; then - set_env_value "SKILLHUB_SERVER_IMAGE" "$SKILLHUB_SERVER_IMAGE_VALUE" - fi - - if [ -n "$SKILLHUB_WEB_IMAGE_VALUE" ]; then - set_env_value "SKILLHUB_WEB_IMAGE" "$SKILLHUB_WEB_IMAGE_VALUE" - fi -} - -run_compose() { - compose_cmd="$(find_compose)" - # shellcheck disable=SC2086 - $compose_cmd --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" -} - -prepare_runtime_files - -case "$COMMAND" in - up) - run_compose up -d - cat <&2 - echo "Usage: sh runtime.sh [up|down|clean|ps|logs|pull] [options]" >&2 - exit 1 - ;; -esac diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java index 927057318..668a3b44c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java @@ -15,15 +15,8 @@ public SkillMetadataParser skillMetadataParser() { } @Bean - public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser, - SkillPublishProperties skillPublishProperties) { - return new SkillPackageValidator( - skillMetadataParser, - skillPublishProperties.getMaxFileCount(), - skillPublishProperties.getMaxSingleFileSize(), - skillPublishProperties.getMaxPackageSize(), - skillPublishProperties.getAllowedFileExtensions() - ); + public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser) { + return new SkillPackageValidator(skillMetadataParser); } @Bean diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java deleted file mode 100644 index 7d5fadd7a..000000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.iflytek.skillhub.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.LinkedHashSet; -import java.util.Set; - -@Component -@ConfigurationProperties(prefix = "skillhub.publish") -public class SkillPublishProperties { - - private int maxFileCount = 100; - private long maxSingleFileSize = 1024 * 1024; - private long maxPackageSize = 100 * 1024 * 1024; - private Set allowedFileExtensions = new LinkedHashSet<>(Set.of( - ".md", ".txt", ".json", ".yaml", ".yml", - ".js", ".ts", ".py", ".sh", - ".png", ".jpg", ".svg" - )); - - public int getMaxFileCount() { - return maxFileCount; - } - - public void setMaxFileCount(int maxFileCount) { - this.maxFileCount = maxFileCount; - } - - public long getMaxSingleFileSize() { - return maxSingleFileSize; - } - - public void setMaxSingleFileSize(long maxSingleFileSize) { - this.maxSingleFileSize = maxSingleFileSize; - } - - public long getMaxPackageSize() { - return maxPackageSize; - } - - public void setMaxPackageSize(long maxPackageSize) { - this.maxPackageSize = maxPackageSize; - } - - public Set getAllowedFileExtensions() { - return allowedFileExtensions; - } - - public void setAllowedFileExtensions(Set allowedFileExtensions) { - this.allowedFileExtensions = new LinkedHashSet<>(allowedFileExtensions); - } -} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java index c27ee079e..76bae2152 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java @@ -1,7 +1,6 @@ package com.iflytek.skillhub.controller.cli; import com.iflytek.skillhub.controller.BaseApiController; -import com.iflytek.skillhub.controller.support.ZipPackageExtractor; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; @@ -13,21 +12,21 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.util.ArrayList; import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; @RestController @RequestMapping("/api/v1/cli") public class CliPublishController extends BaseApiController { private final SkillPublishService skillPublishService; - private final ZipPackageExtractor zipPackageExtractor; public CliPublishController(SkillPublishService skillPublishService, - ZipPackageExtractor zipPackageExtractor, ApiResponseFactory responseFactory) { super(responseFactory); this.skillPublishService = skillPublishService; - this.zipPackageExtractor = zipPackageExtractor; } @PostMapping("/publish") @@ -40,7 +39,7 @@ public ApiResponse publish( SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); - List entries = zipPackageExtractor.extract(file); + List entries = extractZipEntries(file); SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( namespace, @@ -61,4 +60,35 @@ public ApiResponse publish( return ok("response.success.published", response); } + + private List extractZipEntries(MultipartFile file) throws IOException { + List entries = new ArrayList<>(); + + try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { + ZipEntry zipEntry; + while ((zipEntry = zis.getNextEntry()) != null) { + if (!zipEntry.isDirectory()) { + byte[] content = zis.readAllBytes(); + entries.add(new PackageEntry( + zipEntry.getName(), + content, + content.length, + determineContentType(zipEntry.getName()) + )); + } + zis.closeEntry(); + } + } + + return entries; + } + + private String determineContentType(String filename) { + if (filename.endsWith(".py")) return "text/x-python"; + if (filename.endsWith(".json")) return "application/json"; + if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; + if (filename.endsWith(".txt")) return "text/plain"; + if (filename.endsWith(".md")) return "text/markdown"; + return "application/octet-stream"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index eb074de59..d65bf9f94 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 @@ -4,13 +4,10 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; -import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.review.PromotionRequest; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.PromotionService; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; -import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; -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.SkillVersion; @@ -22,7 +19,6 @@ import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.*; -import java.util.Map; import java.util.Set; @RestController @@ -58,13 +54,10 @@ public PromotionController(PromotionService promotionService, @PostMapping public ApiResponse submitPromotion( @RequestBody PromotionRequestDto request, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute("userId") String userId) { PromotionRequest promotion = promotionService.submitPromotion( request.sourceSkillId(), request.sourceVersionId(), - request.targetNamespaceId(), userId, - userNsRoles != null ? userNsRoles : Map.of(), - rbacService.getUserRoleCodes(userId)); + request.targetNamespaceId(), userId); return ok("response.success.created", toResponse(promotion)); } @@ -98,7 +91,7 @@ public ApiResponse> listPendingPromotions( Set platformRoles = rbacService.getUserRoleCodes(userId); boolean hasAdminRole = platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); if (!hasAdminRole) { - throw new DomainForbiddenException("promotion.no_permission"); + return ok("response.success.read", PageResponse.from(Page.empty())); } Page requests = promotionRequestRepository.findByStatus( ReviewTaskStatus.PENDING, PageRequest.of(page, size)); @@ -106,25 +99,16 @@ public ApiResponse> listPendingPromotions( } @GetMapping("/{id}") - public ApiResponse getPromotionDetail(@PathVariable Long id, - @RequestAttribute("userId") String userId) { - PromotionRequest promotion = promotionRequestRepository.findById(id) - .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", id)); - if (!promotionService.canViewPromotion(promotion, userId, rbacService.getUserRoleCodes(userId))) { - throw new DomainForbiddenException("promotion.no_permission"); - } + public ApiResponse getPromotionDetail(@PathVariable Long id) { + PromotionRequest promotion = promotionRequestRepository.findById(id).orElseThrow(); return ok("response.success.read", toResponse(promotion)); } private PromotionResponseDto toResponse(PromotionRequest req) { - Skill sourceSkill = skillRepository.findById(req.getSourceSkillId()) - .orElseThrow(() -> new DomainNotFoundException("skill.not_found", req.getSourceSkillId())); - SkillVersion sourceVersion = skillVersionRepository.findById(req.getSourceVersionId()) - .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", req.getSourceVersionId())); - Namespace sourceNs = namespaceRepository.findById(sourceSkill.getNamespaceId()) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); - Namespace targetNs = namespaceRepository.findById(req.getTargetNamespaceId()) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", req.getTargetNamespaceId())); + Skill sourceSkill = skillRepository.findById(req.getSourceSkillId()).orElseThrow(); + SkillVersion sourceVersion = skillVersionRepository.findById(req.getSourceVersionId()).orElseThrow(); + Namespace sourceNs = namespaceRepository.findById(sourceSkill.getNamespaceId()).orElseThrow(); + Namespace targetNs = namespaceRepository.findById(req.getTargetNamespaceId()).orElseThrow(); String submittedByName = userAccountRepository.findById(req.getSubmittedBy()) .map(UserAccount::getDisplayName).orElse(null); 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 364297521..781962a09 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 @@ -9,8 +9,6 @@ import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; -import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; -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.SkillVersion; @@ -58,14 +56,11 @@ public ReviewController(ReviewService reviewService, @PostMapping public ApiResponse submitReview( @RequestBody ReviewTaskRequest request, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - ReviewTask task = reviewService.submitReview( - request.skillVersionId(), - userId, - userNsRoles != null ? userNsRoles : Map.of(), - rbacService.getUserRoleCodes(userId) - ); + @RequestAttribute("userId") String userId) { + SkillVersion sv = skillVersionRepository.findById(request.skillVersionId()) + .orElseThrow(); + Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow(); + ReviewTask task = reviewService.submitReview(request.skillVersionId(), skill.getNamespaceId(), userId); return ok("response.success.created", toResponse(task)); } @@ -109,15 +104,7 @@ public ApiResponse> listPendingReviews( @RequestParam Long namespaceId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - Namespace namespace = namespaceRepository.findById(namespaceId) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); - ReviewTask probe = new ReviewTask(0L, namespaceId, "probe"); - if (!reviewService.canReviewNamespace(probe, userId, namespace.getType(), - userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId))) { - throw new DomainForbiddenException("review.no_permission"); - } + @RequestAttribute("userId") String userId) { Page tasks = reviewTaskRepository.findByNamespaceIdAndStatus( namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); @@ -134,27 +121,15 @@ public ApiResponse> listMySubmissions( } @GetMapping("/{id}") - public ApiResponse getReviewDetail(@PathVariable Long id, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - ReviewTask task = reviewTaskRepository.findById(id) - .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id)); - Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); - if (!reviewService.canViewReview(task, userId, namespace.getType(), - userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId))) { - throw new DomainForbiddenException("review.no_permission"); - } + public ApiResponse getReviewDetail(@PathVariable Long id) { + ReviewTask task = reviewTaskRepository.findById(id).orElseThrow(); return ok("response.success.read", toResponse(task)); } private ReviewTaskResponse toResponse(ReviewTask task) { - SkillVersion sv = skillVersionRepository.findById(task.getSkillVersionId()) - .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); - Skill skill = skillRepository.findById(sv.getSkillId()) - .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sv.getSkillId())); - Namespace ns = namespaceRepository.findById(skill.getNamespaceId()) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); + SkillVersion sv = skillVersionRepository.findById(task.getSkillVersionId()).orElseThrow(); + Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow(); + Namespace ns = namespaceRepository.findById(skill.getNamespaceId()).orElseThrow(); String submittedByName = userAccountRepository.findById(task.getSubmittedBy()) .map(UserAccount::getDisplayName).orElse(null); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 429c24579..2fba90df0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -75,16 +75,10 @@ public ApiResponse> listVersions( @PathVariable String namespace, @PathVariable String slug, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestParam(defaultValue = "20") int size) { Page versions = skillQueryService.listVersions( - namespace, - slug, - userId, - userNsRoles != null ? userNsRoles : Map.of(), - PageRequest.of(page, size)); + namespace, slug, PageRequest.of(page, size)); PageResponse response = PageResponse.from(versions.map(v -> new SkillVersionResponse( v.getId(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index 7e9278155..e984e31e0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -1,7 +1,6 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; -import com.iflytek.skillhub.controller.support.ZipPackageExtractor; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; @@ -13,21 +12,21 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.util.ArrayList; import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; @RestController @RequestMapping("/api/v1/skills") public class SkillPublishController extends BaseApiController { private final SkillPublishService skillPublishService; - private final ZipPackageExtractor zipPackageExtractor; public SkillPublishController(SkillPublishService skillPublishService, - ZipPackageExtractor zipPackageExtractor, ApiResponseFactory responseFactory) { super(responseFactory); this.skillPublishService = skillPublishService; - this.zipPackageExtractor = zipPackageExtractor; } @PostMapping("/{namespace}/publish") @@ -40,7 +39,7 @@ public ApiResponse publish( SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); - List entries = zipPackageExtractor.extract(file); + List entries = extractZipEntries(file); SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( namespace, @@ -61,4 +60,35 @@ public ApiResponse publish( return ok("response.success.published", response); } + + private List extractZipEntries(MultipartFile file) throws IOException { + List entries = new ArrayList<>(); + + try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { + ZipEntry zipEntry; + while ((zipEntry = zis.getNextEntry()) != null) { + if (!zipEntry.isDirectory()) { + byte[] content = zis.readAllBytes(); + entries.add(new PackageEntry( + zipEntry.getName(), + content, + content.length, + determineContentType(zipEntry.getName()) + )); + } + zis.closeEntry(); + } + } + + return entries; + } + + private String determineContentType(String filename) { + if (filename.endsWith(".py")) return "text/x-python"; + if (filename.endsWith(".json")) return "application/json"; + if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; + if (filename.endsWith(".txt")) return "text/plain"; + if (filename.endsWith(".md")) return "text/markdown"; + return "application/octet-stream"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java index eab43d3f1..1882dac8b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java @@ -1,7 +1,6 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; -import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.SkillTag; import com.iflytek.skillhub.domain.skill.service.SkillTagService; import com.iflytek.skillhub.dto.ApiResponse; @@ -13,7 +12,6 @@ import org.springframework.web.bind.annotation.*; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; @RestController @@ -31,16 +29,9 @@ public SkillTagController(SkillTagService skillTagService, @GetMapping public ApiResponse> listTags( @PathVariable String namespace, - @PathVariable String slug, - @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @PathVariable String slug) { - List tags = skillTagService.listTags( - namespace, - slug, - userId, - userNsRoles != null ? userNsRoles : Map.of() - ); + List tags = skillTagService.listTags(namespace, slug); List response = tags.stream() .map(TagResponse::from) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java deleted file mode 100644 index f9791115f..000000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.iflytek.skillhub.controller.support; - -import com.iflytek.skillhub.config.SkillPublishProperties; -import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -@Component -public class ZipPackageExtractor { - - private static final int BUFFER_SIZE = 8192; - - private final SkillPublishProperties properties; - - public ZipPackageExtractor(SkillPublishProperties properties) { - this.properties = properties; - } - - public List extract(MultipartFile file) throws IOException { - List entries = new ArrayList<>(); - Set seenPaths = new HashSet<>(); - long totalSize = 0L; - - try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { - ZipEntry zipEntry; - while ((zipEntry = zis.getNextEntry()) != null) { - if (zipEntry.isDirectory()) { - zis.closeEntry(); - continue; - } - - if (entries.size() >= properties.getMaxFileCount()) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "Too many files: max " + properties.getMaxFileCount()); - } - - String normalizedPath = normalizeEntryPath(zipEntry.getName()); - if (!seenPaths.add(normalizedPath)) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "Duplicate package path: " + normalizedPath); - } - - byte[] content = readEntry(zis, normalizedPath); - totalSize += content.length; - if (totalSize > properties.getMaxPackageSize()) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "Package too large: max " + properties.getMaxPackageSize() + " bytes"); - } - - entries.add(new PackageEntry( - normalizedPath, - content, - content.length, - determineContentType(normalizedPath) - )); - zis.closeEntry(); - } - } - - return entries; - } - - private byte[] readEntry(ZipInputStream zis, String path) throws IOException { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - byte[] buffer = new byte[BUFFER_SIZE]; - int read; - long fileSize = 0L; - while ((read = zis.read(buffer)) != -1) { - fileSize += read; - if (fileSize > properties.getMaxSingleFileSize()) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "File too large: " + path + " (max " + properties.getMaxSingleFileSize() + " bytes)"); - } - outputStream.write(buffer, 0, read); - } - return outputStream.toByteArray(); - } - - private String normalizeEntryPath(String path) { - if (path == null || path.isBlank()) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", "Package entry path is blank"); - } - if (path.contains("\\")) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "Package entry must use '/' separators: " + path); - } - - try { - Path normalized = Path.of(path).normalize(); - String normalizedPath = normalized.toString().replace('\\', '/'); - if (normalized.isAbsolute() - || normalizedPath.isBlank() - || normalizedPath.startsWith("../") - || normalizedPath.equals("..") - || path.startsWith("/") - || path.contains("//")) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "Unsafe package path: " + path); - } - return normalizedPath; - } catch (InvalidPathException ex) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "Invalid package path: " + path); - } - } - - private String determineContentType(String filename) { - if (filename.endsWith(".py")) return "text/x-python"; - if (filename.endsWith(".json")) return "application/json"; - if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; - if (filename.endsWith(".txt")) return "text/plain"; - if (filename.endsWith(".md")) return "text/markdown"; - return "application/octet-stream"; - } -} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 5ece30129..a9e252848 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -53,15 +53,13 @@ skillhub: access-policy: mode: OPEN storage: - provider: local + type: local local: base-path: ${STORAGE_BASE_PATH:/tmp/skillhub-storage} search: engine: postgres rebuild-on-startup: false publish: - max-file-count: 100 - max-single-file-size: 1048576 # 1MB max-package-size: 104857600 # 100MB allowed-file-extensions: .py,.json,.yaml,.yml,.txt,.md,.sh diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 84fa1f23c..a8c948651 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -75,21 +75,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/compat/v1/search", "/api/compat/v1/resolve/**" ).permitAll() - .requestMatchers(HttpMethod.GET, - "/api/v1/skills", - "/api/v1/skills/*/*", - "/api/v1/skills/*/*/versions", - "/api/v1/skills/*/*/versions/*", - "/api/v1/skills/*/*/versions/*/files", - "/api/v1/skills/*/*/versions/*/file", - "/api/v1/skills/*/*/resolve", - "/api/v1/skills/*/*/download", - "/api/v1/skills/*/*/versions/*/download", - "/api/v1/skills/*/*/tags", - "/api/v1/skills/*/*/tags/*/files", - "/api/v1/skills/*/*/tags/*/file", - "/api/v1/skills/*/*/tags/*/download" - ).permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/skills", "/api/v1/skills/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/namespaces", "/api/v1/namespaces/*").permitAll() .requestMatchers("/api/v1/admin/**").hasAnyRole("SUPER_ADMIN", "SKILL_ADMIN", "USER_ADMIN", "AUDITOR") .anyRequest().authenticated() 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 2e3db5647..8007f29b5 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 @@ -5,10 +5,7 @@ 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.RedisOperations; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.SessionCallback; -import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Service; import java.security.SecureRandom; @@ -79,48 +76,24 @@ public void authorizeDeviceCode(String userCode, String userId) { } public DeviceTokenResponse pollToken(String deviceCode) { - String key = DEVICE_CODE_PREFIX + deviceCode; - DeviceCodeData consumed = redisTemplate.execute(new SessionCallback<>() { - @Override - public DeviceCodeData execute(RedisOperations operations) { - while (true) { - operations.watch(key); - DeviceCodeData data = readDeviceCodeData(operations.opsForValue(), deviceCode); - - if (data == null) { - operations.unwatch(); - throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid"); - } - - switch (data.getStatus()) { - case PENDING -> { - operations.unwatch(); - return null; - } - case USED -> { - operations.unwatch(); - throw new DomainBadRequestException("error.deviceAuth.deviceCode.used"); - } - case AUTHORIZED -> { - data.setStatus(DeviceCodeStatus.USED); - operations.multi(); - operations.opsForValue().set(key, data, 1, TimeUnit.MINUTES); - if (operations.exec() != null) { - return data; - } - } - } - } - } - }); + DeviceCodeData data = readDeviceCodeData(deviceCode); - if (consumed == null) { - return DeviceTokenResponse.pending(); + if (data == null) { + throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid"); } - String token = apiTokenService.createToken( - consumed.getUserId(), "device-auth", "[]").rawToken(); - return DeviceTokenResponse.success(token); + return switch (data.getStatus()) { + case PENDING -> DeviceTokenResponse.pending(); + case AUTHORIZED -> { + data.setStatus(DeviceCodeStatus.USED); + redisTemplate.opsForValue().set( + DEVICE_CODE_PREFIX + deviceCode, data, 1, TimeUnit.MINUTES); + String token = apiTokenService.createToken( + data.getUserId(), "device-auth", "[]").rawToken(); + yield DeviceTokenResponse.success(token); + } + case USED -> throw new DomainBadRequestException("error.deviceAuth.deviceCode.used"); + }; } private String generateRandomDeviceCode() { @@ -139,11 +112,7 @@ private String generateUserCode() { } private DeviceCodeData readDeviceCodeData(String deviceCode) { - return readDeviceCodeData(redisTemplate.opsForValue(), deviceCode); - } - - private DeviceCodeData readDeviceCodeData(ValueOperations valueOperations, String deviceCode) { - Object raw = valueOperations.get(DEVICE_CODE_PREFIX + deviceCode); + Object raw = redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); if (raw == null) { return null; } 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 e3118d657..a8ada47d7 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 @@ -9,8 +9,6 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.redis.core.RedisOperations; -import org.springframework.data.redis.core.SessionCallback; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; @@ -31,8 +29,6 @@ class DeviceAuthServiceTest { @Mock private ValueOperations valueOperations; - @Mock - private RedisOperations redisOperations; @Mock private ApiTokenService apiTokenService; @@ -41,10 +37,7 @@ class DeviceAuthServiceTest { @BeforeEach void setUp() { - lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); - lenient().when(redisOperations.opsForValue()).thenReturn(valueOperations); - lenient().when(redisTemplate.execute(any(SessionCallback.class))) - .thenAnswer(invocation -> invocation.>getArgument(0).execute(redisOperations)); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device", new ObjectMapper()); } @@ -144,7 +137,6 @@ void pollToken_accepts_linked_hash_map_from_redis_serializer() { redisValue.put("status", "AUTHORIZED"); redisValue.put("userId", "42"); when(valueOperations.get("device:code:device123")).thenReturn(redisValue); - when(redisOperations.exec()).thenReturn(java.util.List.of("OK")); when(apiTokenService.createToken("42", "device-auth", "[]")) .thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null)); @@ -153,17 +145,13 @@ void pollToken_accepts_linked_hash_map_from_redis_serializer() { assertThat(response.error()).isNull(); assertThat(response.accessToken()).isEqualTo("sk_device_token"); assertThat(response.tokenType()).isEqualTo("Bearer"); - verify(redisOperations).watch("device:code:device123"); - verify(redisOperations).multi(); verify(valueOperations).set(eq("device:code:device123"), any(DeviceCodeData.class), eq(1L), eq(TimeUnit.MINUTES)); - verify(redisOperations).exec(); } @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(redisOperations.exec()).thenReturn(java.util.List.of("OK")); when(apiTokenService.createToken("42", "device-auth", "[]")) .thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null)); @@ -172,9 +160,6 @@ void pollToken_returns_access_token_when_authorized() { assertThat(response.error()).isNull(); assertThat(response.accessToken()).isEqualTo("sk_device_token"); assertThat(response.tokenType()).isEqualTo("Bearer"); - verify(redisOperations).watch("device:code:device123"); - verify(redisOperations).multi(); verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(1L), eq(TimeUnit.MINUTES)); - verify(redisOperations).exec(); } } 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 40aff667c..006ba1bf1 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 @@ -3,7 +3,6 @@ import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; -import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; @@ -52,9 +51,7 @@ public PromotionService(PromotionRequestRepository promotionRequestRepository, @Transactional public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId, - Long targetNamespaceId, String userId, - java.util.Map userNamespaceRoles, - Set platformRoles) { + Long targetNamespaceId, String userId) { Skill sourceSkill = skillRepository.findById(sourceSkillId) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId)); @@ -69,10 +66,6 @@ public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId throw new DomainBadRequestException("promotion.version_not_published", sourceVersionId); } - if (!permissionChecker.canSubmitPromotion(sourceSkill, userId, userNamespaceRoles, platformRoles)) { - throw new DomainForbiddenException("promotion.submit.no_permission"); - } - Namespace targetNamespace = namespaceRepository.findById(targetNamespaceId) .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", targetNamespaceId)); @@ -194,8 +187,4 @@ public PromotionRequest rejectPromotion(Long promotionId, String reviewerId, request.setReviewedAt(Instant.now()); return request; } - - public boolean canViewPromotion(PromotionRequest request, String userId, Set platformRoles) { - return permissionChecker.canViewPromotion(request, userId, platformRoles); - } } 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 e2f09e8b1..259372c96 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 @@ -2,7 +2,6 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; -import com.iflytek.skillhub.domain.skill.Skill; import org.springframework.stereotype.Component; import java.util.Map; @@ -31,52 +30,21 @@ public boolean canReview(ReviewTask task, return false; } - return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles); - } - - public boolean canSubmitForReview(Skill skill, - String userId, - Map userNamespaceRoles, - Set platformRoles) { - if (skill.getOwnerId().equals(userId)) { - return true; - } - - if (platformRoles.contains("SKILL_ADMIN") - || platformRoles.contains("SUPER_ADMIN")) { - return true; - } - - NamespaceRole role = userNamespaceRoles.get(skill.getNamespaceId()); - return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; - } - - public boolean canViewReview(ReviewTask task, - String userId, - NamespaceType namespaceType, - Map userNamespaceRoles, - Set platformRoles) { - if (task.getSubmittedBy().equals(userId)) { - return true; - } - return canReview(task, userId, namespaceType, userNamespaceRoles, platformRoles); - } - - public boolean canReviewNamespace(Long namespaceId, - NamespaceType namespaceType, - Map userNamespaceRoles, - Set platformRoles) { if (platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN")) { return true; } + // Global namespace: only SKILL_ADMIN or SUPER_ADMIN if (namespaceType == NamespaceType.GLOBAL) { return false; } - NamespaceRole role = userNamespaceRoles.get(namespaceId); - return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; + // Team namespace: namespace ADMIN or OWNER + NamespaceRole role = userNamespaceRoles.get( + task.getNamespaceId()); + return role == NamespaceRole.ADMIN + || role == NamespaceRole.OWNER; } /** @@ -93,20 +61,4 @@ public boolean canReviewPromotion( return platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); } - - public boolean canSubmitPromotion(Skill skill, - String userId, - Map userNamespaceRoles, - Set platformRoles) { - return canSubmitForReview(skill, userId, userNamespaceRoles, platformRoles); - } - - public boolean canViewPromotion(PromotionRequest request, - String userId, - Set platformRoles) { - if (request.getSubmittedBy().equals(userId)) { - return true; - } - return canReviewPromotion(request, userId, platformRoles); - } } 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 9207b8cb8..a9392024d 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 @@ -52,18 +52,9 @@ public ReviewService(ReviewTaskRepository reviewTaskRepository, } @Transactional - public ReviewTask submitReview(Long skillVersionId, - String userId, - Map userNamespaceRoles, - Set platformRoles) { + public ReviewTask submitReview(Long skillVersionId, Long namespaceId, String userId) { SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId)); - Skill skill = skillRepository.findById(skillVersion.getSkillId()) - .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); - - if (!permissionChecker.canSubmitForReview(skill, userId, userNamespaceRoles, platformRoles)) { - throw new DomainForbiddenException("review.submit.no_permission"); - } if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) { throw new DomainBadRequestException("review.submit.not_draft", skillVersionId); @@ -72,7 +63,7 @@ public ReviewTask submitReview(Long skillVersionId, skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW); skillVersionRepository.save(skillVersion); - ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId); + ReviewTask task = new ReviewTask(skillVersionId, namespaceId, userId); try { return reviewTaskRepository.save(task); } catch (DataIntegrityViolationException e) { @@ -182,20 +173,4 @@ public void withdrawReview(Long skillVersionId, String userId) { skillVersion.setStatus(SkillVersionStatus.DRAFT); skillVersionRepository.save(skillVersion); } - - public boolean canReviewNamespace(ReviewTask task, - String userId, - com.iflytek.skillhub.domain.namespace.NamespaceType namespaceType, - Map userNamespaceRoles, - Set platformRoles) { - return permissionChecker.canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles); - } - - public boolean canViewReview(ReviewTask task, - String userId, - com.iflytek.skillhub.domain.namespace.NamespaceType namespaceType, - Map userNamespaceRoles, - Set platformRoles) { - return permissionChecker.canViewReview(task, userId, namespaceType, userNamespaceRoles, platformRoles); - } } 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 580f9c223..009e81031 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 @@ -1,6 +1,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; @@ -16,6 +17,7 @@ import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; import com.iflytek.skillhub.domain.skill.validation.ValidationResult; import com.iflytek.skillhub.storage.ObjectStorageService; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -48,6 +50,7 @@ public record PublishResult( private final SkillPackageValidator skillPackageValidator; private final SkillMetadataParser skillMetadataParser; private final PrePublishValidator prePublishValidator; + private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; private final ReviewTaskRepository reviewTaskRepository; @@ -61,6 +64,7 @@ public SkillPublishService( SkillPackageValidator skillPackageValidator, SkillMetadataParser skillMetadataParser, PrePublishValidator prePublishValidator, + ApplicationEventPublisher eventPublisher, ObjectMapper objectMapper, ReviewTaskRepository reviewTaskRepository) { this.namespaceRepository = namespaceRepository; @@ -72,6 +76,7 @@ public SkillPublishService( this.skillPackageValidator = skillPackageValidator; this.skillMetadataParser = skillMetadataParser; this.prePublishValidator = prePublishValidator; + this.eventPublisher = eventPublisher; this.objectMapper = objectMapper; this.reviewTaskRepository = reviewTaskRepository; } @@ -212,13 +217,17 @@ public PublishResult publishFromEntries( ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId); reviewTaskRepository.save(reviewTask); - // 12. Update skill metadata without moving the published pointer + // 12. Update skill + skill.setLatestVersionId(version.getId()); skill.setDisplayName(metadata.name()); skill.setSummary(metadata.description()); skill.setUpdatedBy(publisherId); skillRepository.save(skill); - // 13. Return identifiers for the pending review version + // 13. Publish SkillPublishedEvent + eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId)); + + // 14. Return published identifiers return new PublishResult(skill.getId(), skill.getSlug(), version); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 0b127f28e..997b29823 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -230,13 +230,8 @@ public InputStream getFileContentByTag( return objectStorageService.getObject(file.getStorageKey()); } - public Page listVersions(String namespaceSlug, - String skillSlug, - String currentUserId, - Map userNsRoles, - Pageable pageable) { + public Page listVersions(String namespaceSlug, String skillSlug, Pageable pageable) { Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); List publishedVersions = skillVersionRepository.findBySkillIdAndStatus( skill.getId(), SkillVersionStatus.PUBLISHED); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java index a65ad7d06..39ca41f92 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java @@ -22,33 +22,24 @@ public class SkillTagService { private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final SkillTagRepository skillTagRepository; - private final VisibilityChecker visibilityChecker; public SkillTagService( NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, - SkillTagRepository skillTagRepository, - VisibilityChecker visibilityChecker) { + SkillTagRepository skillTagRepository) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillTagRepository = skillTagRepository; - this.visibilityChecker = visibilityChecker; } - public List listTags(String namespaceSlug, - String skillSlug, - String currentUserId, - java.util.Map userNamespaceRoles) { + public List listTags(String namespaceSlug, String skillSlug) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); - if (!visibilityChecker.canAccess(skill, currentUserId, userNamespaceRoles)) { - throw new DomainForbiddenException("error.skill.access.denied", skillSlug); - } List tags = new java.util.ArrayList<>(skillTagRepository.findBySkillId(skill.getId())); if (skill.getLatestVersionId() != null) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java index 4f11b546a..57977dc60 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java @@ -3,64 +3,32 @@ import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Set; public class SkillPackageValidator { + private static final int MAX_FILE_COUNT = 100; + private static final long MAX_SINGLE_FILE_SIZE = 1024 * 1024; // 1MB + private static final long MAX_TOTAL_PACKAGE_SIZE = 10 * 1024 * 1024; // 10MB private static final String SKILL_MD_PATH = "SKILL.md"; - private static final Set DEFAULT_ALLOWED_EXTENSIONS = Set.of( + private static final Set ALLOWED_EXTENSIONS = Set.of( ".md", ".txt", ".json", ".yaml", ".yml", ".js", ".ts", ".py", ".sh", ".png", ".jpg", ".svg" ); private final SkillMetadataParser metadataParser; - private final int maxFileCount; - private final long maxSingleFileSize; - private final long maxTotalPackageSize; - private final Set allowedExtensions; public SkillPackageValidator(SkillMetadataParser metadataParser) { - this(metadataParser, 100, 1024 * 1024, 10 * 1024 * 1024, DEFAULT_ALLOWED_EXTENSIONS); - } - - public SkillPackageValidator(SkillMetadataParser metadataParser, - int maxFileCount, - long maxSingleFileSize, - long maxTotalPackageSize, - Set allowedExtensions) { this.metadataParser = metadataParser; - this.maxFileCount = maxFileCount; - this.maxSingleFileSize = maxSingleFileSize; - this.maxTotalPackageSize = maxTotalPackageSize; - this.allowedExtensions = allowedExtensions.stream() - .map(String::toLowerCase) - .collect(java.util.stream.Collectors.toUnmodifiableSet()); } public ValidationResult validate(List entries) { List errors = new ArrayList<>(); - Set seenPaths = new HashSet<>(); - - // 1. Check file count - if (entries.size() > maxFileCount) { - errors.add("Too many files: " + entries.size() + " (max: " + maxFileCount + ")"); - } - - // 2. Validate paths and duplicates - for (PackageEntry entry : entries) { - String normalizedPath = validateAndNormalizePath(entry.path(), errors); - if (normalizedPath != null && !seenPaths.add(normalizedPath)) { - errors.add("Duplicate file path: " + normalizedPath); - } - } - // 3. Check SKILL.md exists at root + // 1. Check SKILL.md exists at root PackageEntry skillMd = entries.stream() .filter(e -> e.path().equals(SKILL_MD_PATH)) .findFirst() @@ -71,7 +39,7 @@ public ValidationResult validate(List entries) { return ValidationResult.fail(errors); } - // 4. Validate frontmatter + // 2. Validate frontmatter try { String content = new String(skillMd.content()); metadataParser.parse(content); @@ -82,60 +50,34 @@ public ValidationResult validate(List entries) { errors.add("Invalid SKILL.md frontmatter: " + detail); } - // 5. Check file extensions + // 3. Check file count + if (entries.size() > MAX_FILE_COUNT) { + errors.add("Too many files: " + entries.size() + " (max: " + MAX_FILE_COUNT + ")"); + } + + // 4. Check file extensions for (PackageEntry entry : entries) { - String path = entry.path().toLowerCase(); - boolean hasAllowedExtension = allowedExtensions.stream().anyMatch(path::endsWith); + String path = entry.path(); + boolean hasAllowedExtension = ALLOWED_EXTENSIONS.stream() + .anyMatch(path::endsWith); if (!hasAllowedExtension) { errors.add("Disallowed file extension: " + path); } } - // 6. Check single file size + // 5. Check single file size for (PackageEntry entry : entries) { - if (entry.size() > maxSingleFileSize) { - errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + maxSingleFileSize + ")"); + if (entry.size() > MAX_SINGLE_FILE_SIZE) { + errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + MAX_SINGLE_FILE_SIZE + ")"); } } - // 7. Check total package size + // 6. Check total package size long totalSize = entries.stream().mapToLong(PackageEntry::size).sum(); - if (totalSize > maxTotalPackageSize) { - errors.add("Package too large: " + totalSize + " bytes (max: " + maxTotalPackageSize + ")"); + if (totalSize > MAX_TOTAL_PACKAGE_SIZE) { + errors.add("Package too large: " + totalSize + " bytes (max: " + MAX_TOTAL_PACKAGE_SIZE + ")"); } return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors); } - - private String validateAndNormalizePath(String path, List errors) { - if (path == null || path.isBlank()) { - errors.add("Package entry path must not be blank"); - return null; - } - if (path.contains("\\")) { - errors.add("Package entry must use '/' separators: " + path); - return null; - } - if (path.startsWith("/") || path.contains("//")) { - errors.add("Unsafe file path: " + path); - return null; - } - - try { - Path normalized = Path.of(path).normalize(); - String normalizedPath = normalized.toString().replace('\\', '/'); - if (normalized.isAbsolute() - || normalizedPath.isBlank() - || normalizedPath.equals(".") - || normalizedPath.equals("..") - || normalizedPath.startsWith("../")) { - errors.add("Unsafe file path: " + path); - return null; - } - return normalizedPath; - } catch (InvalidPathException ex) { - errors.add("Invalid file path: " + path); - return null; - } - } } 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 71677aa37..b73e9e52d 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 @@ -2,7 +2,6 @@ import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; -import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; @@ -21,6 +20,7 @@ import java.util.*; +import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -123,7 +123,6 @@ void shouldSubmitPromotionSuccessfully() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); - when(permissionChecker.canSubmitPromotion(eq(sourceSkill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(globalNs)); when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.empty()); @@ -135,8 +134,7 @@ void shouldSubmitPromotionSuccessfully() { }); PromotionRequest result = promotionService.submitPromotion( - SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, - Map.of(5L, NamespaceRole.OWNER), Set.of()); + SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID); assertNotNull(result); assertEquals(SOURCE_SKILL_ID, result.getSourceSkillId()); @@ -151,7 +149,7 @@ void shouldThrowWhenSourceSkillNotFound() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); } @Test @@ -160,7 +158,7 @@ void shouldThrowWhenSourceVersionNotFound() { when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); } @Test @@ -173,7 +171,7 @@ void shouldThrowWhenVersionDoesNotBelongToSkill() { when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sv)); assertThrows(DomainBadRequestException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); } @Test @@ -186,42 +184,39 @@ void shouldThrowWhenVersionNotPublished() { when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sv)); assertThrows(DomainBadRequestException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); } @Test void shouldThrowWhenTargetNamespaceNotFound() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); - when(permissionChecker.canSubmitPromotion(any(), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); } @Test void shouldThrowWhenTargetNamespaceNotGlobal() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); - when(permissionChecker.canSubmitPromotion(any(), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createTeamNamespace())); assertThrows(DomainBadRequestException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); } @Test void shouldThrowWhenDuplicatePendingExists() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); - when(permissionChecker.canSubmitPromotion(any(), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createGlobalNamespace())); when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(createPendingPromotion())); assertThrows(DomainBadRequestException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); } } 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 786f6af29..42bbad071 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 @@ -106,14 +106,11 @@ class SubmitReview { @Test void shouldSubmitReviewSuccessfully() { SkillVersion sv = createDraftSkillVersion(); - Skill skill = createSkill(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); - when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); - when(permissionChecker.canSubmitForReview(eq(skill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); ReviewTask savedTask = createPendingReviewTask(); when(reviewTaskRepository.save(any(ReviewTask.class))).thenReturn(savedTask); - ReviewTask result = reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of()); + ReviewTask result = reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID); assertNotNull(result); assertEquals(SkillVersionStatus.PENDING_REVIEW, sv.getStatus()); @@ -126,33 +123,27 @@ void shouldThrowWhenSkillVersionNotFound() { when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, - () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of())); + () -> reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID)); } @Test void shouldThrowWhenStatusNotDraft() { SkillVersion sv = createPendingReviewSkillVersion(); - Skill skill = createSkill(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); - when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); - when(permissionChecker.canSubmitForReview(eq(skill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); assertThrows(DomainBadRequestException.class, - () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of())); + () -> reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID)); } @Test void shouldThrowOnDuplicateSubmission() { SkillVersion sv = createDraftSkillVersion(); - Skill skill = createSkill(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); - when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); - when(permissionChecker.canSubmitForReview(eq(skill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(reviewTaskRepository.save(any(ReviewTask.class))) .thenThrow(new DataIntegrityViolationException("duplicate")); assertThrows(DomainBadRequestException.class, - () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of())); + () -> reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID)); } } 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 9d6a87811..7f87af7b5 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 @@ -1,6 +1,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -22,6 +23,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import java.lang.reflect.Field; import java.util.List; @@ -54,6 +56,8 @@ class SkillPublishServiceTest { @Mock private PrePublishValidator prePublishValidator; @Mock + private ApplicationEventPublisher eventPublisher; + @Mock private ReviewTaskRepository reviewTaskRepository; private SkillPublishService service; @@ -72,6 +76,7 @@ void setUp() { skillPackageValidator, skillMetadataParser, prePublishValidator, + eventPublisher, objectMapper, reviewTaskRepository ); @@ -121,6 +126,7 @@ void testPublishFromEntries_Success() throws Exception { assertEquals(1L, result.skillId()); assertEquals("test-skill", result.slug()); assertEquals("1.0.0", result.version().getVersion()); + verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); verify(skillFileRepository).saveAll(anyList()); verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString()); verify(reviewTaskRepository).save(any(ReviewTask.class)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java index 223b24dc2..85b5f317c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java @@ -35,8 +35,6 @@ class SkillTagServiceTest { private SkillVersionRepository skillVersionRepository; @Mock private SkillTagRepository skillTagRepository; - @Mock - private VisibilityChecker visibilityChecker; private SkillTagService service; @@ -47,8 +45,7 @@ void setUp() { namespaceMemberRepository, skillRepository, skillVersionRepository, - skillTagRepository, - visibilityChecker + skillTagRepository ); } @@ -177,10 +174,9 @@ void testListTags() throws Exception { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); when(skillTagRepository.findBySkillId(1L)).thenReturn(List.of(tag1, tag2)); - when(visibilityChecker.canAccess(eq(skill), isNull(), eq(java.util.Map.of()))).thenReturn(true); // Act - List result = service.listTags(namespaceSlug, skillSlug, null, java.util.Map.of()); + List result = service.listTags(namespaceSlug, skillSlug); // Assert assertEquals(2, result.size()); diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java index f322cc101..5fd9ee8d6 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java @@ -15,7 +15,7 @@ public class LocalFileStorageService implements ObjectStorageService { private final Path basePath; public LocalFileStorageService(StorageProperties properties) { - this.basePath = Paths.get(properties.getLocal().getBasePath()).toAbsolutePath().normalize(); + this.basePath = Paths.get(properties.getLocal().getBasePath()); } @Override @@ -58,11 +58,5 @@ public ObjectMetadata getMetadata(String key) { } catch (IOException e) { throw new UncheckedIOException("Failed to get metadata: " + key, e); } } - private Path resolve(String key) { - Path resolved = basePath.resolve(key).normalize(); - if (!resolved.startsWith(basePath)) { - throw new IllegalArgumentException("Resolved path escapes storage base path: " + key); - } - return resolved; - } + private Path resolve(String key) { return basePath.resolve(key); } } From b6f3f2b049e0e2577dbcd4f54daa1ef04a9e8781 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 22:29:51 +0800 Subject: [PATCH 002/313] fix: address review-reported auth and publish issues --- .../skillhub/config/DomainBeanConfig.java | 11 +- .../config/SkillPublishProperties.java | 53 ++++++++ .../controller/cli/CliPublishController.java | 40 +----- .../portal/PromotionController.java | 34 +++-- .../controller/portal/ReviewController.java | 47 +++++-- .../controller/portal/SkillController.java | 10 +- .../portal/SkillPublishController.java | 40 +----- .../controller/portal/SkillTagController.java | 13 +- .../support/ZipPackageExtractor.java | 127 ++++++++++++++++++ .../src/main/resources/application.yml | 4 +- .../skillhub/auth/config/SecurityConfig.java | 16 ++- .../auth/device/DeviceAuthService.java | 63 ++++++--- .../auth/device/DeviceAuthServiceTest.java | 17 ++- .../domain/review/PromotionService.java | 13 +- .../review/ReviewPermissionChecker.java | 60 ++++++++- .../skillhub/domain/review/ReviewService.java | 29 +++- .../skill/service/SkillPublishService.java | 13 +- .../skill/service/SkillQueryService.java | 7 +- .../domain/skill/service/SkillTagService.java | 13 +- .../validation/SkillPackageValidator.java | 100 +++++++++++--- .../domain/review/PromotionServiceTest.java | 23 ++-- .../domain/review/ReviewServiceTest.java | 17 ++- .../service/SkillPublishServiceTest.java | 6 - .../skill/service/SkillTagServiceTest.java | 8 +- .../storage/LocalFileStorageService.java | 10 +- 25 files changed, 592 insertions(+), 182 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java index 668a3b44c..927057318 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java @@ -15,8 +15,15 @@ public SkillMetadataParser skillMetadataParser() { } @Bean - public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser) { - return new SkillPackageValidator(skillMetadataParser); + public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser, + SkillPublishProperties skillPublishProperties) { + return new SkillPackageValidator( + skillMetadataParser, + skillPublishProperties.getMaxFileCount(), + skillPublishProperties.getMaxSingleFileSize(), + skillPublishProperties.getMaxPackageSize(), + skillPublishProperties.getAllowedFileExtensions() + ); } @Bean diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java new file mode 100644 index 000000000..7d5fadd7a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.Set; + +@Component +@ConfigurationProperties(prefix = "skillhub.publish") +public class SkillPublishProperties { + + private int maxFileCount = 100; + private long maxSingleFileSize = 1024 * 1024; + private long maxPackageSize = 100 * 1024 * 1024; + private Set allowedFileExtensions = new LinkedHashSet<>(Set.of( + ".md", ".txt", ".json", ".yaml", ".yml", + ".js", ".ts", ".py", ".sh", + ".png", ".jpg", ".svg" + )); + + public int getMaxFileCount() { + return maxFileCount; + } + + public void setMaxFileCount(int maxFileCount) { + this.maxFileCount = maxFileCount; + } + + public long getMaxSingleFileSize() { + return maxSingleFileSize; + } + + public void setMaxSingleFileSize(long maxSingleFileSize) { + this.maxSingleFileSize = maxSingleFileSize; + } + + public long getMaxPackageSize() { + return maxPackageSize; + } + + public void setMaxPackageSize(long maxPackageSize) { + this.maxPackageSize = maxPackageSize; + } + + public Set getAllowedFileExtensions() { + return allowedFileExtensions; + } + + public void setAllowedFileExtensions(Set allowedFileExtensions) { + this.allowedFileExtensions = new LinkedHashSet<>(allowedFileExtensions); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java index 76bae2152..c27ee079e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.cli; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.controller.support.ZipPackageExtractor; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; @@ -12,21 +13,21 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.util.ArrayList; import java.util.List; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; @RestController @RequestMapping("/api/v1/cli") public class CliPublishController extends BaseApiController { private final SkillPublishService skillPublishService; + private final ZipPackageExtractor zipPackageExtractor; public CliPublishController(SkillPublishService skillPublishService, + ZipPackageExtractor zipPackageExtractor, ApiResponseFactory responseFactory) { super(responseFactory); this.skillPublishService = skillPublishService; + this.zipPackageExtractor = zipPackageExtractor; } @PostMapping("/publish") @@ -39,7 +40,7 @@ public ApiResponse publish( SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); - List entries = extractZipEntries(file); + List entries = zipPackageExtractor.extract(file); SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( namespace, @@ -60,35 +61,4 @@ public ApiResponse publish( return ok("response.success.published", response); } - - private List extractZipEntries(MultipartFile file) throws IOException { - List entries = new ArrayList<>(); - - try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { - ZipEntry zipEntry; - while ((zipEntry = zis.getNextEntry()) != null) { - if (!zipEntry.isDirectory()) { - byte[] content = zis.readAllBytes(); - entries.add(new PackageEntry( - zipEntry.getName(), - content, - content.length, - determineContentType(zipEntry.getName()) - )); - } - zis.closeEntry(); - } - } - - return entries; - } - - private String determineContentType(String filename) { - if (filename.endsWith(".py")) return "text/x-python"; - if (filename.endsWith(".json")) return "application/json"; - if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; - if (filename.endsWith(".txt")) return "text/plain"; - if (filename.endsWith(".md")) return "text/markdown"; - return "application/octet-stream"; - } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index d65bf9f94..eb074de59 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 @@ -4,10 +4,13 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.review.PromotionRequest; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.PromotionService; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +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.SkillVersion; @@ -19,6 +22,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.*; +import java.util.Map; import java.util.Set; @RestController @@ -54,10 +58,13 @@ public PromotionController(PromotionService promotionService, @PostMapping public ApiResponse submitPromotion( @RequestBody PromotionRequestDto request, - @RequestAttribute("userId") String userId) { + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { PromotionRequest promotion = promotionService.submitPromotion( request.sourceSkillId(), request.sourceVersionId(), - request.targetNamespaceId(), userId); + request.targetNamespaceId(), userId, + userNsRoles != null ? userNsRoles : Map.of(), + rbacService.getUserRoleCodes(userId)); return ok("response.success.created", toResponse(promotion)); } @@ -91,7 +98,7 @@ public ApiResponse> listPendingPromotions( Set platformRoles = rbacService.getUserRoleCodes(userId); boolean hasAdminRole = platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); if (!hasAdminRole) { - return ok("response.success.read", PageResponse.from(Page.empty())); + throw new DomainForbiddenException("promotion.no_permission"); } Page requests = promotionRequestRepository.findByStatus( ReviewTaskStatus.PENDING, PageRequest.of(page, size)); @@ -99,16 +106,25 @@ public ApiResponse> listPendingPromotions( } @GetMapping("/{id}") - public ApiResponse getPromotionDetail(@PathVariable Long id) { - PromotionRequest promotion = promotionRequestRepository.findById(id).orElseThrow(); + public ApiResponse getPromotionDetail(@PathVariable Long id, + @RequestAttribute("userId") String userId) { + PromotionRequest promotion = promotionRequestRepository.findById(id) + .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", id)); + if (!promotionService.canViewPromotion(promotion, userId, rbacService.getUserRoleCodes(userId))) { + throw new DomainForbiddenException("promotion.no_permission"); + } return ok("response.success.read", toResponse(promotion)); } private PromotionResponseDto toResponse(PromotionRequest req) { - Skill sourceSkill = skillRepository.findById(req.getSourceSkillId()).orElseThrow(); - SkillVersion sourceVersion = skillVersionRepository.findById(req.getSourceVersionId()).orElseThrow(); - Namespace sourceNs = namespaceRepository.findById(sourceSkill.getNamespaceId()).orElseThrow(); - Namespace targetNs = namespaceRepository.findById(req.getTargetNamespaceId()).orElseThrow(); + Skill sourceSkill = skillRepository.findById(req.getSourceSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", req.getSourceSkillId())); + SkillVersion sourceVersion = skillVersionRepository.findById(req.getSourceVersionId()) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", req.getSourceVersionId())); + Namespace sourceNs = namespaceRepository.findById(sourceSkill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); + Namespace targetNs = namespaceRepository.findById(req.getTargetNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", req.getTargetNamespaceId())); String submittedByName = userAccountRepository.findById(req.getSubmittedBy()) .map(UserAccount::getDisplayName).orElse(null); 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 781962a09..364297521 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 @@ -9,6 +9,8 @@ import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +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.SkillVersion; @@ -56,11 +58,14 @@ public ReviewController(ReviewService reviewService, @PostMapping public ApiResponse submitReview( @RequestBody ReviewTaskRequest request, - @RequestAttribute("userId") String userId) { - SkillVersion sv = skillVersionRepository.findById(request.skillVersionId()) - .orElseThrow(); - Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow(); - ReviewTask task = reviewService.submitReview(request.skillVersionId(), skill.getNamespaceId(), userId); + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + ReviewTask task = reviewService.submitReview( + request.skillVersionId(), + userId, + userNsRoles != null ? userNsRoles : Map.of(), + rbacService.getUserRoleCodes(userId) + ); return ok("response.success.created", toResponse(task)); } @@ -104,7 +109,15 @@ public ApiResponse> listPendingReviews( @RequestParam Long namespaceId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") String userId) { + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + Namespace namespace = namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); + ReviewTask probe = new ReviewTask(0L, namespaceId, "probe"); + if (!reviewService.canReviewNamespace(probe, userId, namespace.getType(), + userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId))) { + throw new DomainForbiddenException("review.no_permission"); + } Page tasks = reviewTaskRepository.findByNamespaceIdAndStatus( namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); @@ -121,15 +134,27 @@ public ApiResponse> listMySubmissions( } @GetMapping("/{id}") - public ApiResponse getReviewDetail(@PathVariable Long id) { - ReviewTask task = reviewTaskRepository.findById(id).orElseThrow(); + public ApiResponse getReviewDetail(@PathVariable Long id, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + ReviewTask task = reviewTaskRepository.findById(id) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id)); + Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + if (!reviewService.canViewReview(task, userId, namespace.getType(), + userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId))) { + throw new DomainForbiddenException("review.no_permission"); + } return ok("response.success.read", toResponse(task)); } private ReviewTaskResponse toResponse(ReviewTask task) { - SkillVersion sv = skillVersionRepository.findById(task.getSkillVersionId()).orElseThrow(); - Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow(); - Namespace ns = namespaceRepository.findById(skill.getNamespaceId()).orElseThrow(); + SkillVersion sv = skillVersionRepository.findById(task.getSkillVersionId()) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); + Skill skill = skillRepository.findById(sv.getSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sv.getSkillId())); + Namespace ns = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); String submittedByName = userAccountRepository.findById(task.getSubmittedBy()) .map(UserAccount::getDisplayName).orElse(null); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 2fba90df0..429c24579 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -75,10 +75,16 @@ public ApiResponse> listVersions( @PathVariable String namespace, @PathVariable String slug, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { + @RequestParam(defaultValue = "20") int size, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { Page versions = skillQueryService.listVersions( - namespace, slug, PageRequest.of(page, size)); + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + PageRequest.of(page, size)); PageResponse response = PageResponse.from(versions.map(v -> new SkillVersionResponse( v.getId(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index e984e31e0..7e9278155 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.controller.support.ZipPackageExtractor; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; @@ -12,21 +13,21 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.util.ArrayList; import java.util.List; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; @RestController @RequestMapping("/api/v1/skills") public class SkillPublishController extends BaseApiController { private final SkillPublishService skillPublishService; + private final ZipPackageExtractor zipPackageExtractor; public SkillPublishController(SkillPublishService skillPublishService, + ZipPackageExtractor zipPackageExtractor, ApiResponseFactory responseFactory) { super(responseFactory); this.skillPublishService = skillPublishService; + this.zipPackageExtractor = zipPackageExtractor; } @PostMapping("/{namespace}/publish") @@ -39,7 +40,7 @@ public ApiResponse publish( SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); - List entries = extractZipEntries(file); + List entries = zipPackageExtractor.extract(file); SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( namespace, @@ -60,35 +61,4 @@ public ApiResponse publish( return ok("response.success.published", response); } - - private List extractZipEntries(MultipartFile file) throws IOException { - List entries = new ArrayList<>(); - - try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { - ZipEntry zipEntry; - while ((zipEntry = zis.getNextEntry()) != null) { - if (!zipEntry.isDirectory()) { - byte[] content = zis.readAllBytes(); - entries.add(new PackageEntry( - zipEntry.getName(), - content, - content.length, - determineContentType(zipEntry.getName()) - )); - } - zis.closeEntry(); - } - } - - return entries; - } - - private String determineContentType(String filename) { - if (filename.endsWith(".py")) return "text/x-python"; - if (filename.endsWith(".json")) return "application/json"; - if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; - if (filename.endsWith(".txt")) return "text/plain"; - if (filename.endsWith(".md")) return "text/markdown"; - return "application/octet-stream"; - } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java index 1882dac8b..eab43d3f1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.SkillTag; import com.iflytek.skillhub.domain.skill.service.SkillTagService; import com.iflytek.skillhub.dto.ApiResponse; @@ -12,6 +13,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @RestController @@ -29,9 +31,16 @@ public SkillTagController(SkillTagService skillTagService, @GetMapping public ApiResponse> listTags( @PathVariable String namespace, - @PathVariable String slug) { + @PathVariable String slug, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - List tags = skillTagService.listTags(namespace, slug); + List tags = skillTagService.listTags( + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); List response = tags.stream() .map(TagResponse::from) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java new file mode 100644 index 000000000..f9791115f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -0,0 +1,127 @@ +package com.iflytek.skillhub.controller.support; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +@Component +public class ZipPackageExtractor { + + private static final int BUFFER_SIZE = 8192; + + private final SkillPublishProperties properties; + + public ZipPackageExtractor(SkillPublishProperties properties) { + this.properties = properties; + } + + public List extract(MultipartFile file) throws IOException { + List entries = new ArrayList<>(); + Set seenPaths = new HashSet<>(); + long totalSize = 0L; + + try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { + ZipEntry zipEntry; + while ((zipEntry = zis.getNextEntry()) != null) { + if (zipEntry.isDirectory()) { + zis.closeEntry(); + continue; + } + + if (entries.size() >= properties.getMaxFileCount()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Too many files: max " + properties.getMaxFileCount()); + } + + String normalizedPath = normalizeEntryPath(zipEntry.getName()); + if (!seenPaths.add(normalizedPath)) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Duplicate package path: " + normalizedPath); + } + + byte[] content = readEntry(zis, normalizedPath); + totalSize += content.length; + if (totalSize > properties.getMaxPackageSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Package too large: max " + properties.getMaxPackageSize() + " bytes"); + } + + entries.add(new PackageEntry( + normalizedPath, + content, + content.length, + determineContentType(normalizedPath) + )); + zis.closeEntry(); + } + } + + return entries; + } + + private byte[] readEntry(ZipInputStream zis, String path) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + long fileSize = 0L; + while ((read = zis.read(buffer)) != -1) { + fileSize += read; + if (fileSize > properties.getMaxSingleFileSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "File too large: " + path + " (max " + properties.getMaxSingleFileSize() + " bytes)"); + } + outputStream.write(buffer, 0, read); + } + return outputStream.toByteArray(); + } + + private String normalizeEntryPath(String path) { + if (path == null || path.isBlank()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", "Package entry path is blank"); + } + if (path.contains("\\")) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Package entry must use '/' separators: " + path); + } + + try { + Path normalized = Path.of(path).normalize(); + String normalizedPath = normalized.toString().replace('\\', '/'); + if (normalized.isAbsolute() + || normalizedPath.isBlank() + || normalizedPath.startsWith("../") + || normalizedPath.equals("..") + || path.startsWith("/") + || path.contains("//")) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Unsafe package path: " + path); + } + return normalizedPath; + } catch (InvalidPathException ex) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Invalid package path: " + path); + } + } + + private String determineContentType(String filename) { + if (filename.endsWith(".py")) return "text/x-python"; + if (filename.endsWith(".json")) return "application/json"; + if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; + if (filename.endsWith(".txt")) return "text/plain"; + if (filename.endsWith(".md")) return "text/markdown"; + return "application/octet-stream"; + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index a9e252848..5ece30129 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -53,13 +53,15 @@ skillhub: access-policy: mode: OPEN storage: - type: local + provider: local local: base-path: ${STORAGE_BASE_PATH:/tmp/skillhub-storage} search: engine: postgres rebuild-on-startup: false publish: + max-file-count: 100 + max-single-file-size: 1048576 # 1MB max-package-size: 104857600 # 100MB allowed-file-extensions: .py,.json,.yaml,.yml,.txt,.md,.sh diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index a8c948651..84fa1f23c 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -75,7 +75,21 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/compat/v1/search", "/api/compat/v1/resolve/**" ).permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/skills", "/api/v1/skills/**").permitAll() + .requestMatchers(HttpMethod.GET, + "/api/v1/skills", + "/api/v1/skills/*/*", + "/api/v1/skills/*/*/versions", + "/api/v1/skills/*/*/versions/*", + "/api/v1/skills/*/*/versions/*/files", + "/api/v1/skills/*/*/versions/*/file", + "/api/v1/skills/*/*/resolve", + "/api/v1/skills/*/*/download", + "/api/v1/skills/*/*/versions/*/download", + "/api/v1/skills/*/*/tags", + "/api/v1/skills/*/*/tags/*/files", + "/api/v1/skills/*/*/tags/*/file", + "/api/v1/skills/*/*/tags/*/download" + ).permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/namespaces", "/api/v1/namespaces/*").permitAll() .requestMatchers("/api/v1/admin/**").hasAnyRole("SUPER_ADMIN", "SKILL_ADMIN", "USER_ADMIN", "AUDITOR") .anyRequest().authenticated() 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 8007f29b5..2e3db5647 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 @@ -5,7 +5,10 @@ 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.RedisOperations; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SessionCallback; +import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Service; import java.security.SecureRandom; @@ -76,24 +79,48 @@ public void authorizeDeviceCode(String userCode, String userId) { } public DeviceTokenResponse pollToken(String deviceCode) { - DeviceCodeData data = readDeviceCodeData(deviceCode); + String key = DEVICE_CODE_PREFIX + deviceCode; + DeviceCodeData consumed = redisTemplate.execute(new SessionCallback<>() { + @Override + public DeviceCodeData execute(RedisOperations operations) { + while (true) { + operations.watch(key); + DeviceCodeData data = readDeviceCodeData(operations.opsForValue(), deviceCode); + + if (data == null) { + operations.unwatch(); + throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid"); + } + + switch (data.getStatus()) { + case PENDING -> { + operations.unwatch(); + return null; + } + case USED -> { + operations.unwatch(); + throw new DomainBadRequestException("error.deviceAuth.deviceCode.used"); + } + case AUTHORIZED -> { + data.setStatus(DeviceCodeStatus.USED); + operations.multi(); + operations.opsForValue().set(key, data, 1, TimeUnit.MINUTES); + if (operations.exec() != null) { + return data; + } + } + } + } + } + }); - if (data == null) { - throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid"); + if (consumed == null) { + return DeviceTokenResponse.pending(); } - return switch (data.getStatus()) { - case PENDING -> DeviceTokenResponse.pending(); - case AUTHORIZED -> { - data.setStatus(DeviceCodeStatus.USED); - redisTemplate.opsForValue().set( - DEVICE_CODE_PREFIX + deviceCode, data, 1, TimeUnit.MINUTES); - String token = apiTokenService.createToken( - data.getUserId(), "device-auth", "[]").rawToken(); - yield DeviceTokenResponse.success(token); - } - case USED -> throw new DomainBadRequestException("error.deviceAuth.deviceCode.used"); - }; + String token = apiTokenService.createToken( + consumed.getUserId(), "device-auth", "[]").rawToken(); + return DeviceTokenResponse.success(token); } private String generateRandomDeviceCode() { @@ -112,7 +139,11 @@ private String generateUserCode() { } private DeviceCodeData readDeviceCodeData(String deviceCode) { - Object raw = redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); + return readDeviceCodeData(redisTemplate.opsForValue(), deviceCode); + } + + private DeviceCodeData readDeviceCodeData(ValueOperations valueOperations, String deviceCode) { + Object raw = valueOperations.get(DEVICE_CODE_PREFIX + deviceCode); if (raw == null) { return null; } 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 a8ada47d7..e3118d657 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 @@ -9,6 +9,8 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.SessionCallback; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; @@ -29,6 +31,8 @@ class DeviceAuthServiceTest { @Mock private ValueOperations valueOperations; + @Mock + private RedisOperations redisOperations; @Mock private ApiTokenService apiTokenService; @@ -37,7 +41,10 @@ class DeviceAuthServiceTest { @BeforeEach void setUp() { - when(redisTemplate.opsForValue()).thenReturn(valueOperations); + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + lenient().when(redisOperations.opsForValue()).thenReturn(valueOperations); + lenient().when(redisTemplate.execute(any(SessionCallback.class))) + .thenAnswer(invocation -> invocation.>getArgument(0).execute(redisOperations)); service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device", new ObjectMapper()); } @@ -137,6 +144,7 @@ void pollToken_accepts_linked_hash_map_from_redis_serializer() { redisValue.put("status", "AUTHORIZED"); redisValue.put("userId", "42"); when(valueOperations.get("device:code:device123")).thenReturn(redisValue); + when(redisOperations.exec()).thenReturn(java.util.List.of("OK")); when(apiTokenService.createToken("42", "device-auth", "[]")) .thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null)); @@ -145,13 +153,17 @@ void pollToken_accepts_linked_hash_map_from_redis_serializer() { assertThat(response.error()).isNull(); assertThat(response.accessToken()).isEqualTo("sk_device_token"); assertThat(response.tokenType()).isEqualTo("Bearer"); + verify(redisOperations).watch("device:code:device123"); + verify(redisOperations).multi(); verify(valueOperations).set(eq("device:code:device123"), any(DeviceCodeData.class), eq(1L), eq(TimeUnit.MINUTES)); + verify(redisOperations).exec(); } @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(redisOperations.exec()).thenReturn(java.util.List.of("OK")); when(apiTokenService.createToken("42", "device-auth", "[]")) .thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null)); @@ -160,6 +172,9 @@ void pollToken_returns_access_token_when_authorized() { assertThat(response.error()).isNull(); assertThat(response.accessToken()).isEqualTo("sk_device_token"); assertThat(response.tokenType()).isEqualTo("Bearer"); + verify(redisOperations).watch("device:code:device123"); + verify(redisOperations).multi(); verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(1L), eq(TimeUnit.MINUTES)); + verify(redisOperations).exec(); } } 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 006ba1bf1..40aff667c 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 @@ -3,6 +3,7 @@ import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; @@ -51,7 +52,9 @@ public PromotionService(PromotionRequestRepository promotionRequestRepository, @Transactional public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId, - Long targetNamespaceId, String userId) { + Long targetNamespaceId, String userId, + java.util.Map userNamespaceRoles, + Set platformRoles) { Skill sourceSkill = skillRepository.findById(sourceSkillId) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId)); @@ -66,6 +69,10 @@ public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId throw new DomainBadRequestException("promotion.version_not_published", sourceVersionId); } + if (!permissionChecker.canSubmitPromotion(sourceSkill, userId, userNamespaceRoles, platformRoles)) { + throw new DomainForbiddenException("promotion.submit.no_permission"); + } + Namespace targetNamespace = namespaceRepository.findById(targetNamespaceId) .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", targetNamespaceId)); @@ -187,4 +194,8 @@ public PromotionRequest rejectPromotion(Long promotionId, String reviewerId, request.setReviewedAt(Instant.now()); return request; } + + public boolean canViewPromotion(PromotionRequest request, String userId, Set platformRoles) { + return permissionChecker.canViewPromotion(request, userId, platformRoles); + } } 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 259372c96..e2f09e8b1 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 @@ -2,6 +2,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.skill.Skill; import org.springframework.stereotype.Component; import java.util.Map; @@ -30,21 +31,52 @@ public boolean canReview(ReviewTask task, return false; } + return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles); + } + + public boolean canSubmitForReview(Skill skill, + String userId, + Map userNamespaceRoles, + Set platformRoles) { + if (skill.getOwnerId().equals(userId)) { + return true; + } + + if (platformRoles.contains("SKILL_ADMIN") + || platformRoles.contains("SUPER_ADMIN")) { + return true; + } + + NamespaceRole role = userNamespaceRoles.get(skill.getNamespaceId()); + return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; + } + + public boolean canViewReview(ReviewTask task, + String userId, + NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + if (task.getSubmittedBy().equals(userId)) { + return true; + } + return canReview(task, userId, namespaceType, userNamespaceRoles, platformRoles); + } + + public boolean canReviewNamespace(Long namespaceId, + NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { if (platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN")) { return true; } - // Global namespace: only SKILL_ADMIN or SUPER_ADMIN if (namespaceType == NamespaceType.GLOBAL) { return false; } - // Team namespace: namespace ADMIN or OWNER - NamespaceRole role = userNamespaceRoles.get( - task.getNamespaceId()); - return role == NamespaceRole.ADMIN - || role == NamespaceRole.OWNER; + NamespaceRole role = userNamespaceRoles.get(namespaceId); + return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; } /** @@ -61,4 +93,20 @@ public boolean canReviewPromotion( return platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); } + + public boolean canSubmitPromotion(Skill skill, + String userId, + Map userNamespaceRoles, + Set platformRoles) { + return canSubmitForReview(skill, userId, userNamespaceRoles, platformRoles); + } + + public boolean canViewPromotion(PromotionRequest request, + String userId, + Set platformRoles) { + if (request.getSubmittedBy().equals(userId)) { + return true; + } + return canReviewPromotion(request, userId, platformRoles); + } } 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 a9392024d..9207b8cb8 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 @@ -52,9 +52,18 @@ public ReviewService(ReviewTaskRepository reviewTaskRepository, } @Transactional - public ReviewTask submitReview(Long skillVersionId, Long namespaceId, String userId) { + public ReviewTask submitReview(Long skillVersionId, + String userId, + Map userNamespaceRoles, + Set platformRoles) { SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId)); + Skill skill = skillRepository.findById(skillVersion.getSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + + if (!permissionChecker.canSubmitForReview(skill, userId, userNamespaceRoles, platformRoles)) { + throw new DomainForbiddenException("review.submit.no_permission"); + } if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) { throw new DomainBadRequestException("review.submit.not_draft", skillVersionId); @@ -63,7 +72,7 @@ public ReviewTask submitReview(Long skillVersionId, Long namespaceId, String use skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW); skillVersionRepository.save(skillVersion); - ReviewTask task = new ReviewTask(skillVersionId, namespaceId, userId); + ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId); try { return reviewTaskRepository.save(task); } catch (DataIntegrityViolationException e) { @@ -173,4 +182,20 @@ public void withdrawReview(Long skillVersionId, String userId) { skillVersion.setStatus(SkillVersionStatus.DRAFT); skillVersionRepository.save(skillVersion); } + + public boolean canReviewNamespace(ReviewTask task, + String userId, + com.iflytek.skillhub.domain.namespace.NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + return permissionChecker.canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles); + } + + public boolean canViewReview(ReviewTask task, + String userId, + com.iflytek.skillhub.domain.namespace.NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + return permissionChecker.canViewReview(task, userId, namespaceType, userNamespaceRoles, platformRoles); + } } 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 009e81031..580f9c223 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 @@ -1,7 +1,6 @@ package com.iflytek.skillhub.domain.skill.service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; @@ -17,7 +16,6 @@ import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; import com.iflytek.skillhub.domain.skill.validation.ValidationResult; import com.iflytek.skillhub.storage.ObjectStorageService; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,7 +48,6 @@ public record PublishResult( private final SkillPackageValidator skillPackageValidator; private final SkillMetadataParser skillMetadataParser; private final PrePublishValidator prePublishValidator; - private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; private final ReviewTaskRepository reviewTaskRepository; @@ -64,7 +61,6 @@ public SkillPublishService( SkillPackageValidator skillPackageValidator, SkillMetadataParser skillMetadataParser, PrePublishValidator prePublishValidator, - ApplicationEventPublisher eventPublisher, ObjectMapper objectMapper, ReviewTaskRepository reviewTaskRepository) { this.namespaceRepository = namespaceRepository; @@ -76,7 +72,6 @@ public SkillPublishService( this.skillPackageValidator = skillPackageValidator; this.skillMetadataParser = skillMetadataParser; this.prePublishValidator = prePublishValidator; - this.eventPublisher = eventPublisher; this.objectMapper = objectMapper; this.reviewTaskRepository = reviewTaskRepository; } @@ -217,17 +212,13 @@ public PublishResult publishFromEntries( ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId); reviewTaskRepository.save(reviewTask); - // 12. Update skill - skill.setLatestVersionId(version.getId()); + // 12. Update skill metadata without moving the published pointer skill.setDisplayName(metadata.name()); skill.setSummary(metadata.description()); skill.setUpdatedBy(publisherId); skillRepository.save(skill); - // 13. Publish SkillPublishedEvent - eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId)); - - // 14. Return published identifiers + // 13. Return identifiers for the pending review version return new PublishResult(skill.getId(), skill.getSlug(), version); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 997b29823..0b127f28e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -230,8 +230,13 @@ public InputStream getFileContentByTag( return objectStorageService.getObject(file.getStorageKey()); } - public Page listVersions(String namespaceSlug, String skillSlug, Pageable pageable) { + public Page listVersions(String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Pageable pageable) { Skill skill = findSkill(namespaceSlug, skillSlug); + assertPublishedAccessible(skill, currentUserId, userNsRoles); List publishedVersions = skillVersionRepository.findBySkillIdAndStatus( skill.getId(), SkillVersionStatus.PUBLISHED); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java index 39ca41f92..a65ad7d06 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java @@ -22,24 +22,33 @@ public class SkillTagService { private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final SkillTagRepository skillTagRepository; + private final VisibilityChecker visibilityChecker; public SkillTagService( NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, - SkillTagRepository skillTagRepository) { + SkillTagRepository skillTagRepository, + VisibilityChecker visibilityChecker) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillTagRepository = skillTagRepository; + this.visibilityChecker = visibilityChecker; } - public List listTags(String namespaceSlug, String skillSlug) { + public List listTags(String namespaceSlug, + String skillSlug, + String currentUserId, + java.util.Map userNamespaceRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + if (!visibilityChecker.canAccess(skill, currentUserId, userNamespaceRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skillSlug); + } List tags = new java.util.ArrayList<>(skillTagRepository.findBySkillId(skill.getId())); if (skill.getLatestVersionId() != null) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java index 57977dc60..4f11b546a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java @@ -3,32 +3,64 @@ import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Set; public class SkillPackageValidator { - private static final int MAX_FILE_COUNT = 100; - private static final long MAX_SINGLE_FILE_SIZE = 1024 * 1024; // 1MB - private static final long MAX_TOTAL_PACKAGE_SIZE = 10 * 1024 * 1024; // 10MB private static final String SKILL_MD_PATH = "SKILL.md"; - private static final Set ALLOWED_EXTENSIONS = Set.of( + private static final Set DEFAULT_ALLOWED_EXTENSIONS = Set.of( ".md", ".txt", ".json", ".yaml", ".yml", ".js", ".ts", ".py", ".sh", ".png", ".jpg", ".svg" ); private final SkillMetadataParser metadataParser; + private final int maxFileCount; + private final long maxSingleFileSize; + private final long maxTotalPackageSize; + private final Set allowedExtensions; public SkillPackageValidator(SkillMetadataParser metadataParser) { + this(metadataParser, 100, 1024 * 1024, 10 * 1024 * 1024, DEFAULT_ALLOWED_EXTENSIONS); + } + + public SkillPackageValidator(SkillMetadataParser metadataParser, + int maxFileCount, + long maxSingleFileSize, + long maxTotalPackageSize, + Set allowedExtensions) { this.metadataParser = metadataParser; + this.maxFileCount = maxFileCount; + this.maxSingleFileSize = maxSingleFileSize; + this.maxTotalPackageSize = maxTotalPackageSize; + this.allowedExtensions = allowedExtensions.stream() + .map(String::toLowerCase) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); } public ValidationResult validate(List entries) { List errors = new ArrayList<>(); + Set seenPaths = new HashSet<>(); + + // 1. Check file count + if (entries.size() > maxFileCount) { + errors.add("Too many files: " + entries.size() + " (max: " + maxFileCount + ")"); + } + + // 2. Validate paths and duplicates + for (PackageEntry entry : entries) { + String normalizedPath = validateAndNormalizePath(entry.path(), errors); + if (normalizedPath != null && !seenPaths.add(normalizedPath)) { + errors.add("Duplicate file path: " + normalizedPath); + } + } - // 1. Check SKILL.md exists at root + // 3. Check SKILL.md exists at root PackageEntry skillMd = entries.stream() .filter(e -> e.path().equals(SKILL_MD_PATH)) .findFirst() @@ -39,7 +71,7 @@ public ValidationResult validate(List entries) { return ValidationResult.fail(errors); } - // 2. Validate frontmatter + // 4. Validate frontmatter try { String content = new String(skillMd.content()); metadataParser.parse(content); @@ -50,34 +82,60 @@ public ValidationResult validate(List entries) { errors.add("Invalid SKILL.md frontmatter: " + detail); } - // 3. Check file count - if (entries.size() > MAX_FILE_COUNT) { - errors.add("Too many files: " + entries.size() + " (max: " + MAX_FILE_COUNT + ")"); - } - - // 4. Check file extensions + // 5. Check file extensions for (PackageEntry entry : entries) { - String path = entry.path(); - boolean hasAllowedExtension = ALLOWED_EXTENSIONS.stream() - .anyMatch(path::endsWith); + String path = entry.path().toLowerCase(); + boolean hasAllowedExtension = allowedExtensions.stream().anyMatch(path::endsWith); if (!hasAllowedExtension) { errors.add("Disallowed file extension: " + path); } } - // 5. Check single file size + // 6. Check single file size for (PackageEntry entry : entries) { - if (entry.size() > MAX_SINGLE_FILE_SIZE) { - errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + MAX_SINGLE_FILE_SIZE + ")"); + if (entry.size() > maxSingleFileSize) { + errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + maxSingleFileSize + ")"); } } - // 6. Check total package size + // 7. Check total package size long totalSize = entries.stream().mapToLong(PackageEntry::size).sum(); - if (totalSize > MAX_TOTAL_PACKAGE_SIZE) { - errors.add("Package too large: " + totalSize + " bytes (max: " + MAX_TOTAL_PACKAGE_SIZE + ")"); + if (totalSize > maxTotalPackageSize) { + errors.add("Package too large: " + totalSize + " bytes (max: " + maxTotalPackageSize + ")"); } return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors); } + + private String validateAndNormalizePath(String path, List errors) { + if (path == null || path.isBlank()) { + errors.add("Package entry path must not be blank"); + return null; + } + if (path.contains("\\")) { + errors.add("Package entry must use '/' separators: " + path); + return null; + } + if (path.startsWith("/") || path.contains("//")) { + errors.add("Unsafe file path: " + path); + return null; + } + + try { + Path normalized = Path.of(path).normalize(); + String normalizedPath = normalized.toString().replace('\\', '/'); + if (normalized.isAbsolute() + || normalizedPath.isBlank() + || normalizedPath.equals(".") + || normalizedPath.equals("..") + || normalizedPath.startsWith("../")) { + errors.add("Unsafe file path: " + path); + return null; + } + return normalizedPath; + } catch (InvalidPathException ex) { + errors.add("Invalid file path: " + path); + return null; + } + } } 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 b73e9e52d..71677aa37 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 @@ -2,6 +2,7 @@ import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; @@ -20,7 +21,6 @@ import java.util.*; -import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -123,6 +123,7 @@ void shouldSubmitPromotionSuccessfully() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(permissionChecker.canSubmitPromotion(eq(sourceSkill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(globalNs)); when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.empty()); @@ -134,7 +135,8 @@ void shouldSubmitPromotionSuccessfully() { }); PromotionRequest result = promotionService.submitPromotion( - SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID); + SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, + Map.of(5L, NamespaceRole.OWNER), Set.of()); assertNotNull(result); assertEquals(SOURCE_SKILL_ID, result.getSourceSkillId()); @@ -149,7 +151,7 @@ void shouldThrowWhenSourceSkillNotFound() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } @Test @@ -158,7 +160,7 @@ void shouldThrowWhenSourceVersionNotFound() { when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } @Test @@ -171,7 +173,7 @@ void shouldThrowWhenVersionDoesNotBelongToSkill() { when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sv)); assertThrows(DomainBadRequestException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } @Test @@ -184,39 +186,42 @@ void shouldThrowWhenVersionNotPublished() { when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sv)); assertThrows(DomainBadRequestException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } @Test void shouldThrowWhenTargetNamespaceNotFound() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(permissionChecker.canSubmitPromotion(any(), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } @Test void shouldThrowWhenTargetNamespaceNotGlobal() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(permissionChecker.canSubmitPromotion(any(), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createTeamNamespace())); assertThrows(DomainBadRequestException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } @Test void shouldThrowWhenDuplicatePendingExists() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(permissionChecker.canSubmitPromotion(any(), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createGlobalNamespace())); when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(createPendingPromotion())); assertThrows(DomainBadRequestException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } } 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 42bbad071..786f6af29 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 @@ -106,11 +106,14 @@ class SubmitReview { @Test void shouldSubmitReviewSuccessfully() { SkillVersion sv = createDraftSkillVersion(); + Skill skill = createSkill(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(permissionChecker.canSubmitForReview(eq(skill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); ReviewTask savedTask = createPendingReviewTask(); when(reviewTaskRepository.save(any(ReviewTask.class))).thenReturn(savedTask); - ReviewTask result = reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID); + ReviewTask result = reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of()); assertNotNull(result); assertEquals(SkillVersionStatus.PENDING_REVIEW, sv.getStatus()); @@ -123,27 +126,33 @@ void shouldThrowWhenSkillVersionNotFound() { when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, - () -> reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID)); + () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of())); } @Test void shouldThrowWhenStatusNotDraft() { SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(permissionChecker.canSubmitForReview(eq(skill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); assertThrows(DomainBadRequestException.class, - () -> reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID)); + () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of())); } @Test void shouldThrowOnDuplicateSubmission() { SkillVersion sv = createDraftSkillVersion(); + Skill skill = createSkill(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(permissionChecker.canSubmitForReview(eq(skill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(reviewTaskRepository.save(any(ReviewTask.class))) .thenThrow(new DataIntegrityViolationException("duplicate")); assertThrows(DomainBadRequestException.class, - () -> reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID)); + () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of())); } } 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 7f87af7b5..9d6a87811 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 @@ -1,7 +1,6 @@ package com.iflytek.skillhub.domain.skill.service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -23,7 +22,6 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; import java.lang.reflect.Field; import java.util.List; @@ -56,8 +54,6 @@ class SkillPublishServiceTest { @Mock private PrePublishValidator prePublishValidator; @Mock - private ApplicationEventPublisher eventPublisher; - @Mock private ReviewTaskRepository reviewTaskRepository; private SkillPublishService service; @@ -76,7 +72,6 @@ void setUp() { skillPackageValidator, skillMetadataParser, prePublishValidator, - eventPublisher, objectMapper, reviewTaskRepository ); @@ -126,7 +121,6 @@ void testPublishFromEntries_Success() throws Exception { assertEquals(1L, result.skillId()); assertEquals("test-skill", result.slug()); assertEquals("1.0.0", result.version().getVersion()); - verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); verify(skillFileRepository).saveAll(anyList()); verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString()); verify(reviewTaskRepository).save(any(ReviewTask.class)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java index 85b5f317c..223b24dc2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java @@ -35,6 +35,8 @@ class SkillTagServiceTest { private SkillVersionRepository skillVersionRepository; @Mock private SkillTagRepository skillTagRepository; + @Mock + private VisibilityChecker visibilityChecker; private SkillTagService service; @@ -45,7 +47,8 @@ void setUp() { namespaceMemberRepository, skillRepository, skillVersionRepository, - skillTagRepository + skillTagRepository, + visibilityChecker ); } @@ -174,9 +177,10 @@ void testListTags() throws Exception { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); when(skillTagRepository.findBySkillId(1L)).thenReturn(List.of(tag1, tag2)); + when(visibilityChecker.canAccess(eq(skill), isNull(), eq(java.util.Map.of()))).thenReturn(true); // Act - List result = service.listTags(namespaceSlug, skillSlug); + List result = service.listTags(namespaceSlug, skillSlug, null, java.util.Map.of()); // Assert assertEquals(2, result.size()); diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java index 5fd9ee8d6..f322cc101 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java @@ -15,7 +15,7 @@ public class LocalFileStorageService implements ObjectStorageService { private final Path basePath; public LocalFileStorageService(StorageProperties properties) { - this.basePath = Paths.get(properties.getLocal().getBasePath()); + this.basePath = Paths.get(properties.getLocal().getBasePath()).toAbsolutePath().normalize(); } @Override @@ -58,5 +58,11 @@ public ObjectMetadata getMetadata(String key) { } catch (IOException e) { throw new UncheckedIOException("Failed to get metadata: " + key, e); } } - private Path resolve(String key) { return basePath.resolve(key); } + private Path resolve(String key) { + Path resolved = basePath.resolve(key).normalize(); + if (!resolved.startsWith(basePath)) { + throw new IllegalArgumentException("Resolved path escapes storage base path: " + key); + } + return resolved; + } } From c9f19a2a3b3b25bb1236ea81cbeff9b2ddbed57c Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 10:09:35 +0800 Subject: [PATCH 003/313] fix: remove top-level name from compose file for docker-compose v1 compatibility --- compose.release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/compose.release.yml b/compose.release.yml index 2f8f8d0cd..04c043956 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -1,5 +1,3 @@ -name: skillhub-release - services: postgres: image: postgres:16-alpine From 6e0781d509e44d5cbbc5c9180dbbb83b5ca1aea1 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 10:13:50 +0800 Subject: [PATCH 004/313] chore(release): cut v0.1.0-beta.2 --- server/pom.xml | 2 +- server/skillhub-app/pom.xml | 2 +- .../java/com/iflytek/skillhub/config/OpenApiConfig.java | 2 +- server/skillhub-auth/pom.xml | 2 +- server/skillhub-domain/pom.xml | 2 +- server/skillhub-infra/pom.xml | 2 +- server/skillhub-search/pom.xml | 2 +- server/skillhub-storage/pom.xml | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 10 files changed, 12 insertions(+), 12 deletions(-) diff --git a/server/pom.xml b/server/pom.xml index 6a167777b..5e9a9b259 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -14,7 +14,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 pom diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index 6689adc82..279958f09 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -8,7 +8,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 skillhub-app diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java index e45f3c7ff..e79a8ea68 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java @@ -17,7 +17,7 @@ public OpenAPI skillhubOpenAPI() { .info(new Info() .title("SkillHub API") .description("Skills Registry Platform") - .version("0.1.0")) + .version("0.1.0-beta.2")) .servers(List.of( new Server().url("http://localhost:8080").description("Local development") )); diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml index e0b7f665b..40e3400f3 100644 --- a/server/skillhub-auth/pom.xml +++ b/server/skillhub-auth/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 skillhub-auth diff --git a/server/skillhub-domain/pom.xml b/server/skillhub-domain/pom.xml index 425105bca..de1527d17 100644 --- a/server/skillhub-domain/pom.xml +++ b/server/skillhub-domain/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 skillhub-domain diff --git a/server/skillhub-infra/pom.xml b/server/skillhub-infra/pom.xml index e3d346ef6..567f856e5 100644 --- a/server/skillhub-infra/pom.xml +++ b/server/skillhub-infra/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 skillhub-infra diff --git a/server/skillhub-search/pom.xml b/server/skillhub-search/pom.xml index 83643cd5a..e4fde4c12 100644 --- a/server/skillhub-search/pom.xml +++ b/server/skillhub-search/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 skillhub-search diff --git a/server/skillhub-storage/pom.xml b/server/skillhub-storage/pom.xml index 2aeb5cc64..eb7659775 100644 --- a/server/skillhub-storage/pom.xml +++ b/server/skillhub-storage/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 skillhub-storage diff --git a/web/package-lock.json b/web/package-lock.json index b22eedcce..e1d6483bd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "skillhub-web", - "version": "0.1.0", + "version": "0.1.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "skillhub-web", - "version": "0.1.0", + "version": "0.1.0-beta.2", "dependencies": { "@tanstack/react-query": "^5.64.0", "@tanstack/react-router": "^1.95.0", @@ -5980,7 +5980,7 @@ } }, "node_modules/yocto-queue": { - "version": "0.1.0", + "version": "0.1.0-beta.2", "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, diff --git a/web/package.json b/web/package.json index 78a40f989..edce32cf6 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "skillhub-web", "private": true, - "version": "0.1.0", + "version": "0.1.0-beta.2", "type": "module", "scripts": { "install:ci": "pnpm install --frozen-lockfile", From c5f5e2c4788a69036f81702cf117022d85e5a1d2 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 10:23:46 +0800 Subject: [PATCH 005/313] fix(ops): align smoke test with csrf and metrics access --- scripts/smoke-test.sh | 12 ++++++++++++ .../iflytek/skillhub/auth/config/SecurityConfig.java | 1 + 2 files changed, 13 insertions(+) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 62a357ab2..4a80e0f4c 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -4,6 +4,13 @@ set -euo pipefail BASE_URL="${1:-http://localhost:8080}" PASS=0 FAIL=0 +COOKIE_JAR="$(mktemp)" + +cleanup() { + rm -f "$COOKIE_JAR" +} + +trap cleanup EXIT check() { local desc="$1" @@ -29,8 +36,13 @@ check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200" check "Namespaces API" "$BASE_URL/api/v1/namespaces" "200" check "Auth required" "$BASE_URL/api/v1/auth/me" "401" +curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null +CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_JAR" | tail -n 1)" + REGISTER_STATUS="$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$BASE_URL/api/v1/auth/local/register" \ + -b "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ -H "Content-Type: application/json" \ -d '{"username":"smoketest","password":"Smoke@2026","email":"smoketest@example.com"}')" if [[ "$REGISTER_STATUS" == "200" || "$REGISTER_STATUS" == "409" ]]; then diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index a2501712c..01fa10321 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -73,6 +73,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/cli/auth/device/**", "/api/v1/cli/check", "/actuator/health", + "/actuator/prometheus", "/v3/api-docs/**", "/swagger-ui/**", "/.well-known/**", From 09a85c4d3c462f3b5d95cfe5f6f26da3a4c0f3a4 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 10:27:55 +0800 Subject: [PATCH 006/313] docs: add curl quick start command --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7ebd1f873..352cdb0b5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ firewall, with the same polish you'd expect from a public registry. ## Quick Start -Start all local services with the one-command script: `make dev-all`. +Start the full local stack with: +`curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/compose.release.yml -o compose.release.yml && curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/.env.release.example -o .env.release && docker compose --env-file .env.release -f compose.release.yml up -d` ### Prerequisites From 66a35c0195702da0e4a93bb4c46231859f575616 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 10:28:17 +0800 Subject: [PATCH 007/313] test(ops): expand local smoke coverage for auth sessions --- scripts/smoke-test.sh | 54 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 4a80e0f4c..e4fc7382a 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -5,6 +5,10 @@ BASE_URL="${1:-http://localhost:8080}" PASS=0 FAIL=0 COOKIE_JAR="$(mktemp)" +USERNAME="smoketest_$(date +%s)" +EMAIL="${USERNAME}@example.com" +PASSWORD="Smoke@2026" +NEW_PASSWORD="Smoke@2027" cleanup() { rm -f "$COOKIE_JAR" @@ -42,10 +46,11 @@ CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_JAR" | tail -n 1)" REGISTER_STATUS="$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$BASE_URL/api/v1/auth/local/register" \ -b "$COOKIE_JAR" \ + -c "$COOKIE_JAR" \ -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ -H "Content-Type: application/json" \ - -d '{"username":"smoketest","password":"Smoke@2026","email":"smoketest@example.com"}')" -if [[ "$REGISTER_STATUS" == "200" || "$REGISTER_STATUS" == "409" ]]; then + -d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\",\"email\":\"$EMAIL\"}")" +if [[ "$REGISTER_STATUS" == "200" ]]; then echo "PASS: Register (HTTP $REGISTER_STATUS)" PASS=$((PASS + 1)) else @@ -53,6 +58,51 @@ else FAIL=$((FAIL + 1)) fi +AUTH_ME_STATUS="$(curl -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me")" +if [[ "$AUTH_ME_STATUS" == "200" ]]; then + echo "PASS: Auth me with session (HTTP $AUTH_ME_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Auth me with session (got $AUTH_ME_STATUS)" + FAIL=$((FAIL + 1)) +fi + +CHANGE_PASSWORD_STATUS="$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/local/change-password" \ + -b "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"currentPassword\":\"$PASSWORD\",\"newPassword\":\"$NEW_PASSWORD\"}")" +if [[ "$CHANGE_PASSWORD_STATUS" == "200" ]]; then + echo "PASS: Change password (HTTP $CHANGE_PASSWORD_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Change password (got $CHANGE_PASSWORD_STATUS)" + FAIL=$((FAIL + 1)) +fi + +LOGOUT_STATUS="$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/logout" \ + -b "$COOKIE_JAR" \ + -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN")" +if [[ "$LOGOUT_STATUS" == "302" || "$LOGOUT_STATUS" == "200" || "$LOGOUT_STATUS" == "204" ]]; then + echo "PASS: Logout (HTTP $LOGOUT_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Logout (got $LOGOUT_STATUS)" + FAIL=$((FAIL + 1)) +fi + +POST_LOGOUT_STATUS="$(curl -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me")" +if [[ "$POST_LOGOUT_STATUS" == "401" ]]; then + echo "PASS: Auth me after logout (HTTP $POST_LOGOUT_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Auth me after logout (got $POST_LOGOUT_STATUS)" + FAIL=$((FAIL + 1)) +fi + echo echo "Results: $PASS passed, $FAIL failed" if [[ "$FAIL" -ne 0 ]]; then From 0e6246d679d9746c3f098a8e6fdfd50ef3c692b5 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 10:31:50 +0800 Subject: [PATCH 008/313] docs(repo): add contribution and community templates --- .github/ISSUE_TEMPLATE/bug_report.yml | 35 +++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 28 ++++ .github/pull_request_template.md | 27 ++++ CODE_OF_CONDUCT.md | 33 ++++ CONTRIBUTING.md | 75 +++++++++ README.md | 5 + scripts/runtime.sh | 174 +++++++++++++++++++++ 8 files changed, 382 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 scripts/runtime.sh diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..e7f9c8f37 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,35 @@ +name: Bug Report +description: Report a defect in SkillHub +title: "[Bug] " +labels: + - bug +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What happened? + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps To Reproduce + description: Include commands, requests, or UI flow + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Behavior + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Branch, commit, runtime profile, browser, OS, etc. + - type: textarea + id: logs + attributes: + label: Logs Or Screenshots diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..b40231a64 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security Report + url: https://example.invalid/security-contact + about: Do not file public issues for suspected vulnerabilities. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..0cbacbfda --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,28 @@ +name: Feature Request +description: Propose a new capability or workflow improvement +title: "[Feature] " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What user or operator problem does this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed Solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + - type: textarea + id: impact + attributes: + label: Impact + description: Auth, API, migration, deployment, observability, or UX impact diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..a74bf0af3 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +## Summary + +- What changed? +- Why is this needed? + +## Validation + +- [ ] Backend tests passed +- [ ] Frontend typecheck/build passed +- [ ] Smoke test run when relevant + +Commands run: + +```bash +# paste commands here +``` + +## Risk + +- User-facing impact: +- Deployment or migration impact: +- Rollback approach: + +## Notes + +- Related issue: +- Follow-up work: diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..90643adc0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,33 @@ +# Code of Conduct + +## Our Standard + +Contributors and maintainers are expected to keep discussion technical, +respectful, and constructive. + +Examples of expected behavior: + +- Focus on the problem, tradeoffs, and evidence. +- Assume good intent, but challenge weak reasoning directly. +- Share actionable feedback. +- Respect different levels of experience and domain knowledge. + +Examples of unacceptable behavior: + +- Harassment, insults, or personal attacks +- Bad-faith argumentation or repeated hostility +- Publishing private or sensitive information without permission +- Disruptive behavior that blocks productive collaboration + +## Enforcement + +Project maintainers may remove comments, reject contributions, or restrict +participation for behavior that violates this code of conduct. + +Serious or repeated violations may result in a temporary or permanent ban from +project spaces. + +## Reporting + +Report conduct issues privately to the maintainers through an internal contact +channel. Do not use public issues for personal or sensitive reports. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..99f87e14e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing to SkillHub + +## Scope + +SkillHub is a self-hosted registry for agent skills. Contributions should +preserve the existing architecture and product direction documented in +[`docs/`](./docs). + +## Before You Start + +- Read [`README.md`](./README.md) for local development commands. +- Check the relevant design docs before changing behavior. +- Open an issue for non-trivial changes before sending a large pull request. + +## Development Setup + +Prerequisites: + +- Docker and Docker Compose +- Java 21 +- Node.js and `pnpm` + +Start the local stack: + +```bash +make dev-all +``` + +Useful commands: + +```bash +make test +make typecheck-web +make build-web +./scripts/smoke-test.sh +``` + +Stop the stack: + +```bash +make dev-all-down +``` + +## Change Guidelines + +- Keep changes focused. Avoid mixing refactors with behavior changes. +- Follow existing module boundaries across `server/`, `web/`, and `docs/`. +- Add or update tests when behavior changes. +- Update docs when APIs, auth flows, deployment, or operator workflows change. +- Prefer backward-compatible changes unless the issue explicitly allows a break. + +## Pull Requests + +Before opening a pull request, make sure: + +- The branch is rebased or merged cleanly from the target branch. +- Relevant backend tests pass. +- Frontend typecheck/build passes when frontend files changed. +- Smoke coverage is updated when operator-facing workflows change. +- The pull request description explains motivation, scope, and rollout impact. + +## Commit Style + +Conventional-style subjects are preferred, for example: + +- `feat(auth): add local account login` +- `fix(ops): align smoke test with csrf flow` +- `docs(deploy): clarify runtime image usage` + +## Reporting Security Issues + +Do not open public issues for suspected security vulnerabilities. + +Report them privately to the maintainers through your internal security process +or a private maintainer contact channel. diff --git a/README.md b/README.md index a4ed20c5d..3cba27eeb 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ firewall, with the same polish you'd expect from a public registry. ## Quick Start +Start the full local stack with: `curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up` + ### Prerequisites - Docker & Docker Compose @@ -149,6 +151,9 @@ private, run `docker login ghcr.io` before `docker compose up -d`. Contributions are welcome. Please open an issue first to discuss what you'd like to change. +- Contribution guide: [`CONTRIBUTING.md`](./CONTRIBUTING.md) +- Code of conduct: [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md) + ## License MIT diff --git a/scripts/runtime.sh b/scripts/runtime.sh new file mode 100644 index 000000000..1d04b4d5c --- /dev/null +++ b/scripts/runtime.sh @@ -0,0 +1,174 @@ +#!/bin/sh + +set -eu + +COMMAND="up" +if [ "$#" -gt 0 ] && [ "${1#-}" = "$1" ]; then + COMMAND="$1" + shift +fi + +SKILLHUB_REF="${SKILLHUB_REF:-main}" +SKILLHUB_HOME_DEFAULT="${TMPDIR:-/tmp}/skillhub-runtime" +SKILLHUB_HOME="${SKILLHUB_HOME:-$SKILLHUB_HOME_DEFAULT}" +SKILLHUB_VERSION_VALUE="${SKILLHUB_VERSION:-}" +SKILLHUB_SERVER_IMAGE_VALUE="${SKILLHUB_SERVER_IMAGE:-}" +SKILLHUB_WEB_IMAGE_VALUE="${SKILLHUB_WEB_IMAGE:-}" + +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || { echo "Missing value for --version" >&2; exit 1; } + SKILLHUB_VERSION_VALUE="$2" + shift 2 + ;; + --home) + [ "$#" -ge 2 ] || { echo "Missing value for --home" >&2; exit 1; } + SKILLHUB_HOME="$2" + shift 2 + ;; + --ref) + [ "$#" -ge 2 ] || { echo "Missing value for --ref" >&2; exit 1; } + SKILLHUB_REF="$2" + shift 2 + ;; + --server-image) + [ "$#" -ge 2 ] || { echo "Missing value for --server-image" >&2; exit 1; } + SKILLHUB_SERVER_IMAGE_VALUE="$2" + shift 2 + ;; + --web-image) + [ "$#" -ge 2 ] || { echo "Missing value for --web-image" >&2; exit 1; } + SKILLHUB_WEB_IMAGE_VALUE="$2" + shift 2 + ;; + --help|-h) + cat < Use a specific image tag, for example v0.1.0 + --home Store runtime files in a specific directory + --ref Download runtime files from a specific Git ref + --server-image Override backend image repository + --web-image Override frontend image repository +EOF + exit 0 + ;; + *) + echo "Unsupported argument: $1" >&2 + exit 1 + ;; + esac +done + +SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}" +COMPOSE_FILE="$SKILLHUB_HOME/compose.release.yml" +ENV_EXAMPLE_FILE="$SKILLHUB_HOME/.env.release.example" +ENV_FILE="$SKILLHUB_HOME/.env.release" + +find_compose() { + if docker compose version >/dev/null 2>&1; then + echo "docker compose" + return 0 + fi + + if command -v docker-compose >/dev/null 2>&1; then + echo "docker-compose" + return 0 + fi + + echo "Docker Compose is required." >&2 + exit 1 +} + +download_file() { + src="$1" + dest="$2" + tmp="$dest.tmp" + curl -fsSL "$src" -o "$tmp" + mv "$tmp" "$dest" +} + +set_env_value() { + key="$1" + value="$2" + + if [ ! -f "$ENV_FILE" ]; then + return 0 + fi + + tmp="$ENV_FILE.tmp" + if grep -q "^$key=" "$ENV_FILE"; then + sed "s|^$key=.*|$key=$value|" "$ENV_FILE" >"$tmp" + else + cat "$ENV_FILE" >"$tmp" + printf '%s=%s\n' "$key" "$value" >>"$tmp" + fi + mv "$tmp" "$ENV_FILE" +} + +prepare_runtime_files() { + mkdir -p "$SKILLHUB_HOME" + download_file "$SKILLHUB_RAW_BASE/compose.release.yml" "$COMPOSE_FILE" + download_file "$SKILLHUB_RAW_BASE/.env.release.example" "$ENV_EXAMPLE_FILE" + + if [ ! -f "$ENV_FILE" ]; then + cp "$ENV_EXAMPLE_FILE" "$ENV_FILE" + fi + + if [ -n "$SKILLHUB_VERSION_VALUE" ]; then + set_env_value "SKILLHUB_VERSION" "$SKILLHUB_VERSION_VALUE" + fi + + if [ -n "$SKILLHUB_SERVER_IMAGE_VALUE" ]; then + set_env_value "SKILLHUB_SERVER_IMAGE" "$SKILLHUB_SERVER_IMAGE_VALUE" + fi + + if [ -n "$SKILLHUB_WEB_IMAGE_VALUE" ]; then + set_env_value "SKILLHUB_WEB_IMAGE" "$SKILLHUB_WEB_IMAGE_VALUE" + fi +} + +run_compose() { + compose_cmd="$(find_compose)" + # shellcheck disable=SC2086 + $compose_cmd --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" +} + +prepare_runtime_files + +case "$COMMAND" in + up) + run_compose up -d + cat <&2 + echo "Usage: sh runtime.sh [up|down|clean|ps|logs|pull] [options]" >&2 + exit 1 + ;; +esac From 5210b298da3b8af17d4852d519b63ebbe9f8f884 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 10:56:28 +0800 Subject: [PATCH 009/313] fix(phase4): harden smoke checks and metrics access --- scripts/smoke-test.sh | 70 +++++++++++++++++-- .../skillhub/auth/config/SecurityConfig.java | 1 + web/src/features/skill/markdown-renderer.tsx | 3 +- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 62a357ab2..add4c748b 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -4,13 +4,24 @@ set -euo pipefail BASE_URL="${1:-http://localhost:8080}" PASS=0 FAIL=0 +COOKIE_JAR="$(mktemp)" +USERNAME="smoketest_$(date +%s)" +EMAIL="${USERNAME}@example.com" +PASSWORD="Smoke@2026" +NEW_PASSWORD="Smoke@2027" + +cleanup() { + rm -f "$COOKIE_JAR" +} + +trap cleanup EXIT check() { local desc="$1" local url="$2" local expected="$3" local status - status="$(curl -s -o /dev/null -w "%{http_code}" "$url")" + status="$(curl --retry 3 --retry-delay 1 --max-time 10 -s -o /dev/null -w "%{http_code}" "$url" || true)" if [[ "$status" == "$expected" ]]; then echo "PASS: $desc (HTTP $status)" PASS=$((PASS + 1)) @@ -29,11 +40,17 @@ check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200" check "Namespaces API" "$BASE_URL/api/v1/namespaces" "200" check "Auth required" "$BASE_URL/api/v1/auth/me" "401" -REGISTER_STATUS="$(curl -s -o /dev/null -w "%{http_code}" \ +curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null +CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_JAR" | tail -n 1)" + +REGISTER_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ -X POST "$BASE_URL/api/v1/auth/local/register" \ + -b "$COOKIE_JAR" \ + -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ -H "Content-Type: application/json" \ - -d '{"username":"smoketest","password":"Smoke@2026","email":"smoketest@example.com"}')" -if [[ "$REGISTER_STATUS" == "200" || "$REGISTER_STATUS" == "409" ]]; then + -d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\",\"email\":\"$EMAIL\"}" || true)" +if [[ "$REGISTER_STATUS" == "200" ]]; then echo "PASS: Register (HTTP $REGISTER_STATUS)" PASS=$((PASS + 1)) else @@ -41,6 +58,51 @@ else FAIL=$((FAIL + 1)) fi +AUTH_ME_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" || true)" +if [[ "$AUTH_ME_STATUS" == "200" ]]; then + echo "PASS: Auth me with session (HTTP $AUTH_ME_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Auth me with session (got $AUTH_ME_STATUS)" + FAIL=$((FAIL + 1)) +fi + +CHANGE_PASSWORD_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/local/change-password" \ + -b "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"currentPassword\":\"$PASSWORD\",\"newPassword\":\"$NEW_PASSWORD\"}" || true)" +if [[ "$CHANGE_PASSWORD_STATUS" == "200" ]]; then + echo "PASS: Change password (HTTP $CHANGE_PASSWORD_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Change password (got $CHANGE_PASSWORD_STATUS)" + FAIL=$((FAIL + 1)) +fi + +LOGOUT_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/logout" \ + -b "$COOKIE_JAR" \ + -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" || true)" +if [[ "$LOGOUT_STATUS" == "302" || "$LOGOUT_STATUS" == "200" || "$LOGOUT_STATUS" == "204" ]]; then + echo "PASS: Logout (HTTP $LOGOUT_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Logout (got $LOGOUT_STATUS)" + FAIL=$((FAIL + 1)) +fi + +POST_LOGOUT_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" || true)" +if [[ "$POST_LOGOUT_STATUS" == "401" ]]; then + echo "PASS: Auth me after logout (HTTP $POST_LOGOUT_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Auth me after logout (got $POST_LOGOUT_STATUS)" + FAIL=$((FAIL + 1)) +fi + echo echo "Results: $PASS passed, $FAIL failed" if [[ "$FAIL" -ne 0 ]]; then diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index a2501712c..01fa10321 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -73,6 +73,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/cli/auth/device/**", "/api/v1/cli/check", "/actuator/health", + "/actuator/prometheus", "/v3/api-docs/**", "/swagger-ui/**", "/.well-known/**", diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 865ec2203..b4b409a39 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -15,8 +15,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize, rehypeHighlight]} components={{ - // @ts-ignore - react-markdown types issue - div: ({ node, ...props }) =>
, + div: (props) =>
, }} > {content} From 2b2ab52423887ee9492162591611a0d97707e531 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:06:55 +0800 Subject: [PATCH 010/313] Add OpenAPI drift validation and docs updates --- .github/ISSUE_TEMPLATE/bug_report.yml | 5 + .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yml | 5 + .github/pull_request_template.md | 2 + .github/workflows/validate-openapi.yml | 43 ++++ CODE_OF_CONDUCT.md | 2 +- CONTRIBUTING.md | 10 +- LICENSE | 222 +++++++++++++++++++-- README.md | 29 ++- scripts/check-openapi-generated.sh | 46 +++++ 10 files changed, 337 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/validate-openapi.yml create mode 100755 scripts/check-openapi-generated.sh diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e7f9c8f37..e5ce7df0b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -29,6 +29,11 @@ body: attributes: label: Environment description: Branch, commit, runtime profile, browser, OS, etc. + - type: textarea + id: api-impact + attributes: + label: API Contract Impact + description: If relevant, include the request path, response shape, and whether `web/src/api/generated/schema.d.ts` appears stale. - type: textarea id: logs attributes: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index b40231a64..3ed8eaeba 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Security Report - url: https://example.invalid/security-contact + url: https://github.com/iflytek/skillhub/security/advisories/new about: Do not file public issues for suspected vulnerabilities. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 0cbacbfda..f3ae637d7 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -26,3 +26,8 @@ body: attributes: label: Impact description: Auth, API, migration, deployment, observability, or UX impact + - type: textarea + id: contract + attributes: + label: Contract Or SDK Impact + description: Note whether this proposal changes OpenAPI, generated SDKs, CLI protocol, or operator docs. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a74bf0af3..1e9094b21 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,6 +7,7 @@ - [ ] Backend tests passed - [ ] Frontend typecheck/build passed +- [ ] OpenAPI SDK regenerated or checked when API contracts changed - [ ] Smoke test run when relevant Commands run: @@ -25,3 +26,4 @@ Commands run: - Related issue: - Follow-up work: +- Docs or operator runbooks updated when behavior changed: diff --git a/.github/workflows/validate-openapi.yml b/.github/workflows/validate-openapi.yml new file mode 100644 index 000000000..c912e0461 --- /dev/null +++ b/.github/workflows/validate-openapi.yml @@ -0,0 +1,43 @@ +name: Validate OpenAPI SDK + +on: + pull_request: + push: + branches: + - main + - feature/project-init + +jobs: + openapi-sdk: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install frontend dependencies + working-directory: web + run: pnpm install --frozen-lockfile + + - name: Validate generated OpenAPI SDK + run: ./scripts/check-openapi-generated.sh diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 90643adc0..f1dc7ad02 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -29,5 +29,5 @@ project spaces. ## Reporting -Report conduct issues privately to the maintainers through an internal contact +Report conduct issues privately to the maintainers through a private maintainer channel. Do not use public issues for personal or sensitive reports. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99f87e14e..a382e5d76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,8 @@ Useful commands: make test make typecheck-web make build-web +make generate-api +./scripts/check-openapi-generated.sh ./scripts/smoke-test.sh ``` @@ -47,6 +49,8 @@ make dev-all-down - Follow existing module boundaries across `server/`, `web/`, and `docs/`. - Add or update tests when behavior changes. - Update docs when APIs, auth flows, deployment, or operator workflows change. +- Regenerate and commit `web/src/api/generated/schema.d.ts` when backend OpenAPI + contracts change. - Prefer backward-compatible changes unless the issue explicitly allows a break. ## Pull Requests @@ -56,6 +60,8 @@ Before opening a pull request, make sure: - The branch is rebased or merged cleanly from the target branch. - Relevant backend tests pass. - Frontend typecheck/build passes when frontend files changed. +- `make generate-api` or `./scripts/check-openapi-generated.sh` has been run when + backend API contracts changed. - Smoke coverage is updated when operator-facing workflows change. - The pull request description explains motivation, scope, and rollout impact. @@ -71,5 +77,5 @@ Conventional-style subjects are preferred, for example: Do not open public issues for suspected security vulnerabilities. -Report them privately to the maintainers through your internal security process -or a private maintainer contact channel. +Use GitHub Security Advisories or your internal security process to report them +privately to the maintainers. diff --git a/LICENSE b/LICENSE index 97d302767..0c5fba18c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,201 @@ -MIT License - -Copyright (c) 2026 iFLYTEK - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets.) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 5068185b0..350f437bf 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,12 @@ firewall, with the same polish you'd expect from a public registry. Each namespace has its own members, roles (Owner / Admin / Member), and publishing policies. - **Review & Governance** — Team admins review within their namespace; - platform admins gate promotions to the global scope. Every - action is audit-logged for compliance. + platform admins gate promotions to the global scope. Governance + actions are audit-logged for compliance. - **CLI-First** — Native REST API plus a compatibility layer for - existing ClawHub CLI tools — no client changes needed. + existing ClawHub-style registry clients. Native CLI APIs are the + primary supported path while protocol compatibility continues to + expand. - **Pluggable Storage** — Local filesystem for development, S3 / MinIO for production. Swap via config. @@ -71,6 +73,25 @@ make dev-all-reset Run `make help` to see all available commands. +### API Contract Sync + +OpenAPI types for the web client are checked into the repository. +When backend API contracts change, regenerate the SDK and commit the +updated generated file: + +```bash +make generate-api +``` + +For a stricter end-to-end drift check, run: + +```bash +./scripts/check-openapi-generated.sh +``` + +This starts local dependencies, boots the backend, regenerates the +frontend schema, and fails if the checked-in SDK is stale. + ### Container Runtime Published runtime images are built by GitHub Actions and pushed to GHCR. @@ -209,4 +230,4 @@ what you'd like to change. ## License -MIT +Apache License 2.0 diff --git a/scripts/check-openapi-generated.sh b/scripts/check-openapi-generated.sh new file mode 100755 index 000000000..2cc21ef59 --- /dev/null +++ b/scripts/check-openapi-generated.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SERVER_DIR="$ROOT_DIR/server" +WEB_DIR="$ROOT_DIR/web" +API_LOG="${TMPDIR:-/tmp}/skillhub-openapi-check.log" +SERVER_PID="" + +cleanup() { + if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" >/dev/null 2>&1 || true + fi + (cd "$ROOT_DIR" && docker compose down >/dev/null 2>&1) || true +} + +trap cleanup EXIT + +cd "$ROOT_DIR" +docker compose up -d --wait postgres redis + +( + cd "$SERVER_DIR" + SPRING_PROFILES_ACTIVE=local ./mvnw -pl skillhub-app spring-boot:run +) >"$API_LOG" 2>&1 & +SERVER_PID=$! + +for _ in $(seq 1 90); do + if curl -fsS "http://127.0.0.1:8080/v3/api-docs" >/dev/null 2>&1; then + break + fi + sleep 2 +done + +if ! curl -fsS "http://127.0.0.1:8080/v3/api-docs" >/dev/null 2>&1; then + echo "Backend did not expose /v3/api-docs. See $API_LOG" >&2 + exit 1 +fi + +cd "$WEB_DIR" +pnpm run generate-api + +cd "$ROOT_DIR" +git diff --exit-code -- web/src/api/generated/schema.d.ts From 8549816754f89877618406a9049a064b0d60c5c1 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:08:35 +0800 Subject: [PATCH 011/313] fix(web): landing page no longer nested inside Layout header/footer When pathname is '/', Layout now renders only the Outlet without its own header/footer chrome, so the landing page's self-contained layout displays correctly. --- web/src/app/layout.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 8418a4cbb..f2478a8b3 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,9 +1,19 @@ import { Suspense } from 'react' -import { Outlet, Link } from '@tanstack/react-router' +import { Outlet, Link, useRouterState } from '@tanstack/react-router' import { useAuth } from '@/features/auth/use-auth' export function Layout() { const { user, isLoading } = useAuth() + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const isLanding = pathname === '/' + + if (isLanding) { + return ( + + + + ) + } return (
From 93c66bc7325af02165a128a504d0ca69c48c9e03 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:09:02 +0800 Subject: [PATCH 012/313] fix(dev): harden local process startup checks --- Makefile | 27 +++++++++++++++++++-------- scripts/dev_process.py | 5 +++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index b33b02a50..ed9263675 100644 --- a/Makefile +++ b/Makefile @@ -26,13 +26,13 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) echo "Installing frontend dependencies..."; \ $(MAKE) web-install; \ fi - @if [ -f $(DEV_SERVER_PID) ] && kill -0 "$$(cat $(DEV_SERVER_PID))" 2>/dev/null; then \ + @if $(DEV_PROCESS) status --pid-file $(DEV_SERVER_PID) >/dev/null 2>&1; then \ echo "Backend already running with PID $$(cat $(DEV_SERVER_PID))"; \ else \ echo "Starting backend..."; \ $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /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 \ + @if $(DEV_PROCESS) status --pid-file $(DEV_WEB_PID) >/dev/null 2>&1; then \ echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \ else \ echo "Starting frontend..."; \ @@ -40,13 +40,24 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) fi @echo "Waiting for backend on $(DEV_API_URL) ..." @backend_ready=0; \ - for i in $$(seq 1 60); do \ - if curl -sf $(DEV_API_URL)/actuator/health >/dev/null; then \ - echo "Backend ready."; \ - backend_ready=1; \ - break; \ + for attempt in 1 2; do \ + for i in $$(seq 1 30); do \ + if curl -sf $(DEV_API_URL)/actuator/health >/dev/null; then \ + echo "Backend ready."; \ + backend_ready=1; \ + break 2; \ + fi; \ + if ! $(DEV_PROCESS) status --pid-file $(DEV_SERVER_PID) >/dev/null 2>&1; then \ + break; \ + fi; \ + sleep 2; \ + done; \ + if [ "$$attempt" -lt 2 ]; then \ + echo "Backend did not become ready on attempt $$attempt. Restarting..."; \ + $(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID); \ + sleep 2; \ + $(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; \ - sleep 2; \ done; \ if [ "$$backend_ready" -ne 1 ]; then \ echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \ diff --git a/scripts/dev_process.py b/scripts/dev_process.py index 3213c108a..8dd33f565 100644 --- a/scripts/dev_process.py +++ b/scripts/dev_process.py @@ -60,6 +60,11 @@ def start_process(args: argparse.Namespace) -> int: start_new_session=True, ) + time.sleep(0.2) + if process.poll() is not None: + pid_file.unlink(missing_ok=True) + return process.returncode or 1 + write_pid(pid_file, process.pid) print(process.pid) return 0 From 573388fb48bbc17e38c213b426dcdced95c550fe Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:10:02 +0800 Subject: [PATCH 013/313] chore(git): ignore python cache files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index c0900a12e..2129c847d 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,8 @@ coverage/ # Temporary files .tmp/ tmp/ +__pycache__/ +*.py[cod] # Git worktrees .worktrees/ From 6a193e9f998b2d27c86f12f58b39055cb00773df Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:21:32 +0800 Subject: [PATCH 014/313] fix(dev): provide explicit skill repository bean --- .../infra/jpa/JpaSkillRepositoryAdapter.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java new file mode 100644 index 000000000..887d24760 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -0,0 +1,64 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@Primary +public class JpaSkillRepositoryAdapter implements SkillRepository { + + private final SkillJpaRepository delegate; + private final JpaRepository jpaDelegate; + + public JpaSkillRepositoryAdapter(SkillJpaRepository delegate) { + this.delegate = delegate; + this.jpaDelegate = delegate; + } + + @Override + public Optional findById(Long id) { + return jpaDelegate.findById(id); + } + + @Override + public List findByIdIn(List ids) { + return delegate.findByIdIn(ids); + } + + @Override + public List findAll() { + return jpaDelegate.findAll(); + } + + @Override + public Optional findByNamespaceIdAndSlug(Long namespaceId, String slug) { + return delegate.findByNamespaceIdAndSlug(namespaceId, slug); + } + + @Override + public List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) { + return delegate.findByNamespaceIdAndStatus(namespaceId, status); + } + + @Override + public Skill save(Skill skill) { + return jpaDelegate.save(skill); + } + + @Override + public List findByOwnerId(String ownerId) { + return delegate.findByOwnerId(ownerId); + } + + @Override + public void incrementDownloadCount(Long skillId) { + delegate.incrementDownloadCount(skillId); + } +} From 38d58b0c6cef30b4172c1bd52ae6c94d320d5290 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:29:01 +0800 Subject: [PATCH 015/313] fix(auth): grant global membership to new users --- .../auth/identity/IdentityBindingService.java | 11 ++- .../skillhub/auth/local/LocalAuthService.java | 5 + .../identity/IdentityBindingServiceTest.java | 92 +++++++++++++++++++ .../auth/local/LocalAuthServiceTest.java | 6 ++ .../GlobalNamespaceMembershipService.java | 30 ++++++ .../GlobalNamespaceMembershipServiceTest.java | 71 ++++++++++++++ 6 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java index 4227b326b..871f79cb2 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java @@ -5,6 +5,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; @@ -20,13 +21,16 @@ public class IdentityBindingService { private final IdentityBindingRepository bindingRepo; private final UserAccountRepository userRepo; private final UserRoleBindingRepository roleBindingRepo; + private final GlobalNamespaceMembershipService globalNamespaceMembershipService; public IdentityBindingService(IdentityBindingRepository bindingRepo, - UserAccountRepository userRepo, - UserRoleBindingRepository roleBindingRepo) { + UserAccountRepository userRepo, + UserRoleBindingRepository roleBindingRepo, + GlobalNamespaceMembershipService globalNamespaceMembershipService) { this.bindingRepo = bindingRepo; this.userRepo = userRepo; this.roleBindingRepo = roleBindingRepo; + this.globalNamespaceMembershipService = globalNamespaceMembershipService; } @Transactional @@ -54,6 +58,9 @@ public PlatformPrincipal bindOrCreate(OAuthClaims claims, UserStatus initialStat ); user.setStatus(initialStatus); user = userRepo.save(user); + if (initialStatus == UserStatus.ACTIVE) { + globalNamespaceMembershipService.ensureMember(user.getId()); + } binding = new IdentityBinding(user.getId(), claims.provider(), claims.subject(), claims.providerLogin()); bindingRepo.save(binding); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index ab779e742..1addd5f7f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -3,6 +3,7 @@ import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; @@ -28,17 +29,20 @@ public class LocalAuthService { private final LocalCredentialRepository credentialRepository; private final UserAccountRepository userAccountRepository; private final UserRoleBindingRepository userRoleBindingRepository; + private final GlobalNamespaceMembershipService globalNamespaceMembershipService; private final PasswordPolicyValidator passwordPolicyValidator; private final PasswordEncoder passwordEncoder; public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, UserRoleBindingRepository userRoleBindingRepository, + GlobalNamespaceMembershipService globalNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, PasswordEncoder passwordEncoder) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; + this.globalNamespaceMembershipService = globalNamespaceMembershipService; this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; } @@ -76,6 +80,7 @@ public PlatformPrincipal register(String username, String password, String email normalizedUsername, passwordEncoder.encode(password) )); + globalNamespaceMembershipService.ensureMember(user.getId()); return buildPrincipal(user); } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java new file mode 100644 index 000000000..c9e9ca5d7 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java @@ -0,0 +1,92 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class IdentityBindingServiceTest { + + @Mock + private IdentityBindingRepository bindingRepo; + + @Mock + private UserAccountRepository userRepo; + + @Mock + private UserRoleBindingRepository roleBindingRepo; + + @Mock + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + + private IdentityBindingService service; + + @BeforeEach + void setUp() { + service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo, globalNamespaceMembershipService); + } + + @Test + void bindOrCreate_assignsGlobalMembershipForActiveNewUsers() { + OAuthClaims claims = new OAuthClaims( + "github", + "gh_1", + "alice@example.com", + true, + "alice", + Map.of("avatar_url", "https://example.test/a.png") + ); + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); + + PlatformPrincipal principal = service.bindOrCreate(claims, UserStatus.ACTIVE); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userRepo).save(userCaptor.capture()); + verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); + verify(bindingRepo).save(any(IdentityBinding.class)); + assertThat(principal.displayName()).isEqualTo("alice"); + assertThat(principal.oauthProvider()).isEqualTo("github"); + } + + @Test + void bindOrCreate_doesNotAssignGlobalMembershipForPendingUsers() { + OAuthClaims claims = new OAuthClaims( + "github", + "gh_1", + "alice@example.com", + true, + "alice", + Map.of() + ); + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); + + service.bindOrCreate(claims, UserStatus.PENDING); + + verify(globalNamespaceMembershipService, never()).ensureMember(any()); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 361083da9..8fc505ccd 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -11,6 +11,7 @@ import com.iflytek.skillhub.auth.entity.Role; import com.iflytek.skillhub.auth.entity.UserRoleBinding; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; @@ -38,6 +39,9 @@ class LocalAuthServiceTest { @Mock private UserRoleBindingRepository userRoleBindingRepository; + @Mock + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + @Mock private PasswordEncoder passwordEncoder; @@ -49,6 +53,7 @@ void setUp() { credentialRepository, userAccountRepository, userRoleBindingRepository, + globalNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder ); @@ -70,6 +75,7 @@ void register_createsUserAndCredential() { assertThat(principal.displayName()).isEqualTo("alice"); assertThat(principal.email()).isEqualTo("alice@example.com"); verify(credentialRepository).save(any(LocalCredential.class)); + verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); } @Test diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java new file mode 100644 index 000000000..947d414fa --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class GlobalNamespaceMembershipService { + + private static final String GLOBAL_NAMESPACE_SLUG = "global"; + + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + + public GlobalNamespaceMembershipService(NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository) { + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + } + + @Transactional + public void ensureMember(String userId) { + Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE_SLUG) + .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + + namespaceMemberRepository.findByNamespaceIdAndUserId(globalNamespace.getId(), userId) + .orElseGet(() -> namespaceMemberRepository.save( + new NamespaceMember(globalNamespace.getId(), userId, NamespaceRole.MEMBER) + )); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java new file mode 100644 index 000000000..c4e211bb1 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java @@ -0,0 +1,71 @@ +package com.iflytek.skillhub.domain.namespace; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.lang.reflect.Field; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class GlobalNamespaceMembershipServiceTest { + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + + private GlobalNamespaceMembershipService service; + + @BeforeEach + void setUp() { + service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository); + } + + @Test + void ensureMember_createsGlobalMembershipWhenMissing() throws Exception { + Namespace global = new Namespace("global", "Global", "system"); + setNamespaceId(global, 1L); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.empty()); + + service.ensureMember("usr_1"); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository).save(memberCaptor.capture()); + assertThat(memberCaptor.getValue().getNamespaceId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().getUserId()).isEqualTo("usr_1"); + assertThat(memberCaptor.getValue().getRole()).isEqualTo(NamespaceRole.MEMBER); + } + + @Test + void ensureMember_keepsExistingGlobalMembership() throws Exception { + Namespace global = new Namespace("global", "Global", "system"); + setNamespaceId(global, 1L); + NamespaceMember existing = new NamespaceMember(1L, "usr_1", NamespaceRole.ADMIN); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.of(existing)); + + service.ensureMember("usr_1"); + + verify(namespaceMemberRepository, never()).save(any()); + } + + private void setNamespaceId(Namespace namespace, Long id) throws Exception { + Field field = Namespace.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(namespace, id); + } +} From 0e909aa21a6a6427e144ee984281da870e192ae7 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:32:50 +0800 Subject: [PATCH 016/313] test(auth): align auth module tests with current flows --- .../identity/IdentityBindingServiceTest.java | 6 +- .../auth/merge/AccountMergeServiceTest.java | 55 +++++++++++++++---- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java index c9e9ca5d7..f3d4d6590 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.auth.identity; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; @@ -8,6 +9,7 @@ import com.iflytek.skillhub.auth.entity.IdentityBinding; import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import com.iflytek.skillhub.auth.oauth.AccountPendingException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; @@ -83,9 +85,9 @@ void bindOrCreate_doesNotAssignGlobalMembershipForPendingUsers() { ); when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); - service.bindOrCreate(claims, UserStatus.PENDING); + assertThatThrownBy(() -> service.bindOrCreate(claims, UserStatus.PENDING)) + .isInstanceOf(AccountPendingException.class); verify(globalNamespaceMembershipService, never()).ensureMember(any()); } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java index cd1ecbcea..fa38a89de 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java @@ -23,6 +23,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.lang.reflect.Field; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -91,10 +93,29 @@ void initiate_withLocalUsername_createsPendingRequest() { } @Test - void verifyAndComplete_migratesBindingsRolesTokensAndMemberships() { + void verify_marksRequestVerifiedWhenTokenMatches() throws Exception { UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null); - AccountMergeRequest request = new AccountMergeRequest("usr_primary", "usr_secondary", "encoded", java.time.LocalDateTime.now().plusMinutes(10)); + AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); + + given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); + given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); + given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); + given(passwordEncoder.matches("raw-token", "encoded")).willReturn(true); + given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); + + service.verify("usr_primary", 7L, "raw-token"); + + assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_VERIFIED); + verify(mergeRequestRepository).save(request); + } + + @Test + void confirm_migratesBindingsRolesTokensAndMemberships() throws Exception { + UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); + UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null); + AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); Role role = mock(Role.class); given(role.getCode()).willReturn("AUDITOR"); UserRoleBinding secondaryRole = new UserRoleBinding("usr_secondary", role); @@ -102,10 +123,10 @@ void verifyAndComplete_migratesBindingsRolesTokensAndMemberships() { ApiToken token = new ApiToken("usr_secondary", "cli", "sk_123", "hash", "[]"); NamespaceMember secondaryMembership = new NamespaceMember(1L, "usr_secondary", NamespaceRole.ADMIN); - given(mergeRequestRepository.findByIdAndPrimaryUserId(request.getId(), "usr_primary")).willReturn(Optional.of(request)); + given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); - given(passwordEncoder.matches("raw-token", "encoded")).willReturn(true); + given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); given(identityBindingRepository.findByUserId("usr_secondary")).willReturn(List.of(binding)); given(apiTokenRepository.findByUserId("usr_secondary")).willReturn(List.of(token)); given(userRoleBindingRepository.findByUserId("usr_primary")).willReturn(List.of()); @@ -115,7 +136,7 @@ void verifyAndComplete_migratesBindingsRolesTokensAndMemberships() { given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty()); given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.empty()); - service.verifyAndComplete("usr_primary", request.getId(), "raw-token"); + service.confirm("usr_primary", 7L); assertThat(binding.getUserId()).isEqualTo("usr_primary"); assertThat(token.getUserId()).isEqualTo("usr_primary"); @@ -123,21 +144,35 @@ void verifyAndComplete_migratesBindingsRolesTokensAndMemberships() { assertThat(secondaryMembership.getUserId()).isEqualTo("usr_primary"); assertThat(secondary.getStatus()).isEqualTo(com.iflytek.skillhub.domain.user.UserStatus.MERGED); assertThat(secondary.getMergedToUserId()).isEqualTo("usr_primary"); + assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_COMPLETED); + assertThat(request.getVerificationToken()).isNull(); verify(userRoleBindingRepository).save(any(UserRoleBinding.class)); verify(userRoleBindingRepository).deleteAll(List.of(secondaryRole)); } @Test - void verifyAndComplete_rejectsInvalidToken() { - UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); - AccountMergeRequest request = new AccountMergeRequest("usr_primary", "usr_secondary", "encoded", java.time.LocalDateTime.now().plusMinutes(10)); - given(mergeRequestRepository.findByIdAndPrimaryUserId(request.getId(), "usr_primary")).willReturn(Optional.of(request)); + void verify_rejectsInvalidToken() throws Exception { + AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); + given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); given(passwordEncoder.matches("bad-token", "encoded")).willReturn(false); - assertThatThrownBy(() -> service.verifyAndComplete("usr_primary", request.getId(), "bad-token")) + assertThatThrownBy(() -> service.verify("usr_primary", 7L, "bad-token")) .isInstanceOf(AuthFlowException.class) .hasMessageContaining("error.auth.merge.invalidToken"); verify(identityBindingRepository, never()).saveAll(any()); } + + private AccountMergeRequest request(String primaryUserId, String secondaryUserId, String token) throws Exception { + AccountMergeRequest request = new AccountMergeRequest( + primaryUserId, + secondaryUserId, + token, + LocalDateTime.now().plusMinutes(10) + ); + Field idField = AccountMergeRequest.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(request, 7L); + return request; + } } From b632a9c0b41dfcb780b7fb12f2129b3166489213 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:36:34 +0800 Subject: [PATCH 017/313] Complete phase 3 and 4 backend workflows --- .../bootstrap/DockerSeedDataRunner.java | 100 +++++++++++++ .../compat/ClawHubCompatController.java | 105 ++++++++++++- .../controller/AccountMergeController.java | 14 +- .../skillhub/controller/CliController.java | 37 ++++- .../controller/DeviceAuthWebController.java | 22 ++- .../admin/UserManagementController.java | 50 +++++-- .../controller/cli/CliPublishController.java | 21 ++- .../controller/portal/MeController.java | 10 ++ .../portal/PromotionController.java | 59 +++++++- .../controller/portal/ReviewController.java | 85 ++++++++++- .../controller/portal/SkillController.java | 3 + .../dto/AdminUserSummaryResponse.java | 9 +- .../skillhub/dto/SkillDetailResponse.java | 5 + .../service/AdminUserManagementService.java | 140 ++++++++++++++++++ .../skillhub/service/MySkillAppService.java | 51 ++++++- .../src/main/resources/messages.properties | 5 + .../src/main/resources/messages_zh.properties | 5 + .../compat/ClawHubCompatControllerTest.java | 73 +++++++++ .../AccountMergeControllerTest.java | 22 +++ .../admin/UserManagementControllerTest.java | 41 ++++- .../skillhub/auth/config/SecurityConfig.java | 3 +- .../auth/merge/AccountMergeRequest.java | 1 + .../auth/merge/AccountMergeService.java | 19 ++- .../repository/UserRoleBindingRepository.java | 3 + .../domain/review/ReviewTaskRepository.java | 1 + .../skill/service/SkillQueryService.java | 6 + .../domain/user/UserAccountRepository.java | 6 + .../infra/jpa/ReviewTaskJpaRepository.java | 2 + .../infra/jpa/UserAccountJpaRepository.java | 21 +++ 29 files changed, 878 insertions(+), 41 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java new file mode 100644 index 000000000..1879e54fd --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java @@ -0,0 +1,100 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +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.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.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Seeds default admin account for Docker one-click startup. + * Idempotent: skips if admin credential already exists. + */ +@Component +@Profile("docker") +public class DockerSeedDataRunner implements ApplicationRunner { + + private static final String ADMIN_USER_ID = "docker-admin"; + private static final String ADMIN_USERNAME = "admin"; + private static final String ADMIN_PASSWORD = "Admin@2026"; + + private static final Logger log = LoggerFactory.getLogger(DockerSeedDataRunner.class); + + private final UserAccountRepository userAccountRepository; + private final LocalCredentialRepository localCredentialRepository; + private final RoleRepository roleRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final PasswordEncoder passwordEncoder; + + public DockerSeedDataRunner(UserAccountRepository userAccountRepository, + LocalCredentialRepository localCredentialRepository, + RoleRepository roleRepository, + UserRoleBindingRepository userRoleBindingRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + PasswordEncoder passwordEncoder) { + this.userAccountRepository = userAccountRepository; + this.localCredentialRepository = localCredentialRepository; + this.roleRepository = roleRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + if (localCredentialRepository.existsByUsernameIgnoreCase(ADMIN_USERNAME)) { + log.info("Docker seed data already exists, skipping"); + return; + } + + // 1. Create admin user account + UserAccount admin = userAccountRepository.findById(ADMIN_USER_ID) + .orElseGet(() -> userAccountRepository.save( + new UserAccount(ADMIN_USER_ID, "Admin", "admin@skillhub.dev", null) + )); + + // 2. Create local credential (username/password) + localCredentialRepository.save( + new LocalCredential(admin.getId(), ADMIN_USERNAME, passwordEncoder.encode(ADMIN_PASSWORD)) + ); + + // 3. Assign SUPER_ADMIN role + Role superAdmin = roleRepository.findByCode("SUPER_ADMIN") + .orElseThrow(() -> new IllegalStateException("Missing built-in role: SUPER_ADMIN")); + boolean hasRole = userRoleBindingRepository.findByUserId(admin.getId()).stream() + .anyMatch(b -> b.getRole().getCode().equals("SUPER_ADMIN")); + if (!hasRole) { + userRoleBindingRepository.save(new UserRoleBinding(admin.getId(), superAdmin)); + } + + // 4. Ensure global namespace + membership + Namespace globalNs = namespaceRepository.findBySlug("global") + .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + if (namespaceMemberRepository.findByNamespaceIdAndUserId(globalNs.getId(), admin.getId()).isEmpty()) { + namespaceMemberRepository.save(new NamespaceMember(globalNs.getId(), admin.getId(), NamespaceRole.OWNER)); + } + + log.info("Docker seed data initialized — admin account: {} / {}", ADMIN_USERNAME, ADMIN_PASSWORD); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index 707171708..3bbf4a18f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -1,39 +1,130 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.compat.dto.ClawHubPublishResponse; +import com.iflytek.skillhub.controller.support.ZipPackageExtractor; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse; import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse; +import com.iflytek.skillhub.compat.dto.ClawHubSkillItem; import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse; +import com.iflytek.skillhub.service.SkillSearchAppService; +import java.io.IOException; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; +import java.util.Map; +import org.slf4j.MDC; @RestController @RequestMapping("/api/compat/v1") public class ClawHubCompatController { private final CanonicalSlugMapper mapper; + private final SkillSearchAppService skillSearchAppService; + private final SkillQueryService skillQueryService; + private final SkillPublishService skillPublishService; + private final ZipPackageExtractor zipPackageExtractor; + private final AuditLogService auditLogService; - public ClawHubCompatController(CanonicalSlugMapper mapper) { + public ClawHubCompatController(CanonicalSlugMapper mapper, + SkillSearchAppService skillSearchAppService, + SkillQueryService skillQueryService, + SkillPublishService skillPublishService, + ZipPackageExtractor zipPackageExtractor, + AuditLogService auditLogService) { this.mapper = mapper; + this.skillSearchAppService = skillSearchAppService; + this.skillQueryService = skillQueryService; + this.skillPublishService = skillPublishService; + this.zipPackageExtractor = zipPackageExtractor; + this.auditLogService = auditLogService; } @GetMapping("/search") - public ClawHubSearchResponse search(@RequestParam String q) { - // Return empty results for now (placeholder) - return new ClawHubSearchResponse(List.of()); + public ClawHubSearchResponse search(@RequestParam String q, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + var result = skillSearchAppService.search(q, null, "relevance", 0, 20, userId, userNsRoles != null ? userNsRoles : Map.of()); + return new ClawHubSearchResponse(result.items().stream() + .map(item -> new ClawHubSkillItem( + mapper.toCanonical(item.namespace(), item.slug()), + item.summary(), + item.latestVersion(), + item.starCount() + )) + .toList()); } @GetMapping("/resolve/{canonicalSlug}") public ClawHubResolveResponse resolve( @PathVariable String canonicalSlug, - @RequestParam(defaultValue = "latest") String version) { + @RequestParam(defaultValue = "latest") String version, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + var resolved = skillQueryService.resolveVersion( + coord.namespace(), + coord.slug(), + "latest".equals(version) ? null : version, + "latest".equals(version) ? "latest" : null, + null, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); return new ClawHubResolveResponse( canonicalSlug, - version, - "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" + resolved.version(), + resolved.downloadUrl() + ); + } + + @GetMapping("/download/{canonicalSlug}") + public ResponseEntity download(@PathVariable String canonicalSlug, + @RequestParam(defaultValue = "latest") String version) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + String location = "latest".equals(version) + ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" + : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, location) + .build(); + } + + @PostMapping("/publish") + public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file, + @RequestParam("namespace") String namespace, + @RequestAttribute("userId") String userId, + jakarta.servlet.http.HttpServletRequest request) throws IOException { + var result = skillPublishService.publishFromEntries( + namespace, + zipPackageExtractor.extract(file), + userId, + SkillVisibility.PUBLIC + ); + auditLogService.record( + userId, + "COMPAT_PUBLISH", + "SKILL_VERSION", + result.version().getId(), + MDC.get("requestId"), + request.getRemoteAddr(), + request.getHeader("User-Agent"), + "{\"namespace\":\"" + namespace + "\"}" + ); + return new ClawHubPublishResponse( + mapper.toCanonical(namespace, result.slug()), + result.version().getVersion(), + result.version().getStatus().name() ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java index 9288aba0b..7e812f96c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java @@ -49,11 +49,23 @@ public ApiResponse verify(@AuthenticationPrincipal PlatformPrin if (principal == null) { throw new UnauthorizedException("error.auth.required"); } - accountMergeService.verifyAndComplete( + accountMergeService.verify( principal.userId(), request.mergeRequestId(), request.verificationToken() ); + return ok("response.success.updated", new MessageResponse("Account merge verified")); + } + + @PostMapping("/confirm") + public ApiResponse confirm(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody ConfirmMergeRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + accountMergeService.confirm(principal.userId(), request.mergeRequestId()); return ok("response.success.updated", new MessageResponse("Account merge completed")); } + + public record ConfirmMergeRequest(@jakarta.validation.constraints.NotNull Long mergeRequestId) {} } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java index cf0273ec3..d5b031b1c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java @@ -7,7 +7,10 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.CliWhoamiResponse; +import com.iflytek.skillhub.dto.ResolveVersionResponse; import com.iflytek.skillhub.dto.SkillCheckResponse; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import org.springframework.security.core.annotation.AuthenticationPrincipal; import com.iflytek.skillhub.exception.UnauthorizedException; import org.springframework.web.bind.annotation.*; @@ -24,11 +27,14 @@ public class CliController extends BaseApiController { private final SkillPackageValidator skillPackageValidator; + private final SkillQueryService skillQueryService; public CliController(ApiResponseFactory responseFactory, - SkillPackageValidator skillPackageValidator) { + SkillPackageValidator skillPackageValidator, + SkillQueryService skillQueryService) { super(responseFactory); this.skillPackageValidator = skillPackageValidator; + this.skillQueryService = skillQueryService; } @GetMapping("/whoami") @@ -55,6 +61,35 @@ public ApiResponse check(@RequestParam("file") MultipartFile return ok("response.success.validated", response); } + @GetMapping("/resolve/{namespace}/{slug}") + public ApiResponse resolve(@PathVariable String namespace, + @PathVariable String slug, + @RequestParam(required = false) String version, + @RequestParam(required = false) String tag, + @RequestParam(required = false) String hash, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) java.util.Map userNsRoles) { + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + namespace, + slug, + version, + tag, + hash, + userId, + userNsRoles != null ? userNsRoles : java.util.Map.of() + ); + return ok("response.success.read", new ResolveVersionResponse( + resolved.skillId(), + resolved.namespace(), + resolved.slug(), + resolved.version(), + resolved.versionId(), + resolved.fingerprint(), + resolved.matched(), + resolved.downloadUrl() + )); + } + private List extractZipEntries(MultipartFile file) throws IOException { List entries = new ArrayList<>(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java index 13eca9daa..aec99cf1c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java @@ -2,9 +2,12 @@ import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.MessageResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -16,18 +19,33 @@ public class DeviceAuthWebController extends BaseApiController { private final DeviceAuthService deviceAuthService; + private final AuditLogService auditLogService; - public DeviceAuthWebController(ApiResponseFactory responseFactory, DeviceAuthService deviceAuthService) { + public DeviceAuthWebController(ApiResponseFactory responseFactory, + DeviceAuthService deviceAuthService, + AuditLogService auditLogService) { super(responseFactory); this.deviceAuthService = deviceAuthService; + this.auditLogService = auditLogService; } @PostMapping("/authorize") public ApiResponse authorizeDevice( @RequestBody AuthorizeRequest request, - @AuthenticationPrincipal PlatformPrincipal principal + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest ) { deviceAuthService.authorizeDeviceCode(request.userCode(), principal.userId()); + auditLogService.record( + principal.userId(), + "DEVICE_AUTHORIZE", + "DEVICE_CODE", + null, + MDC.get("requestId"), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + "{\"userCode\":\"" + request.userCode() + "\"}" + ); return ok("response.success.updated", new MessageResponse("Device authorized successfully")); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java index 8f4bb6576..1d6363b40 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.controller.admin; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.dto.AdminUserMutationResponse; import com.iflytek.skillhub.dto.AdminUserRoleUpdateRequest; @@ -8,39 +9,42 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.AdminUserManagementService; import jakarta.validation.Valid; -import org.springframework.data.domain.PageImpl; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequestMapping("/api/v1/admin/users") public class UserManagementController extends BaseApiController { - public UserManagementController(ApiResponseFactory responseFactory) { + private final AdminUserManagementService adminUserManagementService; + + public UserManagementController(ApiResponseFactory responseFactory, + AdminUserManagementService adminUserManagementService) { super(responseFactory); + this.adminUserManagementService = adminUserManagementService; } @GetMapping @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") public ApiResponse> listUsers( + @RequestParam(required = false) String search, + @RequestParam(required = false) String status, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { - List users = List.of( - new AdminUserSummaryResponse("user-1", "alice", "USER", "ACTIVE"), - new AdminUserSummaryResponse("user-2", "bob", "USER", "ACTIVE") - ); - return ok("response.success.read", PageResponse.from(new PageImpl<>(users))); + return ok("response.success.read", adminUserManagementService.listUsers(search, status, page, size)); } @PutMapping("/{userId}/role") @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") public ApiResponse updateUserRole( @PathVariable String userId, - @Valid @RequestBody AdminUserRoleUpdateRequest request) { - return ok("response.success.updated", new AdminUserMutationResponse(userId, request.role(), null)); + @Valid @RequestBody AdminUserRoleUpdateRequest request, + @AuthenticationPrincipal PlatformPrincipal principal) { + AdminUserSummaryResponse user = adminUserManagementService.updateUserRole(userId, request.role(), principal); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), request.role(), user.status())); } @PutMapping("/{userId}/status") @@ -48,6 +52,28 @@ public ApiResponse updateUserRole( public ApiResponse updateUserStatus( @PathVariable String userId, @Valid @RequestBody AdminUserStatusUpdateRequest request) { - return ok("response.success.updated", new AdminUserMutationResponse(userId, null, request.status())); + AdminUserSummaryResponse user = adminUserManagementService.updateUserStatus(userId, request.status()); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status())); + } + + @PostMapping("/{userId}/approve") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse approveUser(@PathVariable String userId) { + AdminUserSummaryResponse user = adminUserManagementService.approveUser(userId); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status())); + } + + @PostMapping("/{userId}/disable") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse disableUser(@PathVariable String userId) { + AdminUserSummaryResponse user = adminUserManagementService.disableUser(userId); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status())); + } + + @PostMapping("/{userId}/enable") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse enableUser(@PathVariable String userId) { + AdminUserSummaryResponse user = adminUserManagementService.enableUser(userId); + return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status())); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java index e4e0ea7e9..c1a743b93 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java @@ -2,6 +2,7 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.controller.support.ZipPackageExtractor; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; @@ -10,6 +11,8 @@ import com.iflytek.skillhub.dto.PublishResponse; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -23,15 +26,18 @@ public class CliPublishController extends BaseApiController { private final SkillPublishService skillPublishService; private final ZipPackageExtractor zipPackageExtractor; private final SkillHubMetrics skillHubMetrics; + private final AuditLogService auditLogService; public CliPublishController(SkillPublishService skillPublishService, ZipPackageExtractor zipPackageExtractor, ApiResponseFactory responseFactory, - SkillHubMetrics skillHubMetrics) { + SkillHubMetrics skillHubMetrics, + AuditLogService auditLogService) { super(responseFactory); this.skillPublishService = skillPublishService; this.zipPackageExtractor = zipPackageExtractor; this.skillHubMetrics = skillHubMetrics; + this.auditLogService = auditLogService; } @PostMapping("/publish") @@ -40,7 +46,8 @@ public ApiResponse publish( @RequestParam("file") MultipartFile file, @RequestParam("namespace") String namespace, @RequestParam("visibility") String visibility, - @RequestAttribute("userId") String userId) throws IOException { + @RequestAttribute("userId") String userId, + HttpServletRequest request) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); @@ -63,6 +70,16 @@ public ApiResponse publish( publishResult.version().getTotalSize() ); skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name()); + auditLogService.record( + userId, + "CLI_PUBLISH", + "SKILL_VERSION", + publishResult.version().getId(), + MDC.get("requestId"), + request.getRemoteAddr(), + request.getHeader("User-Agent"), + "{\"namespace\":\"" + namespace + "\"}" + ); return ok("response.success.published", response); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java index 9b4c8f75b..630cc91cd 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java @@ -34,4 +34,14 @@ public ApiResponse> listMySkills( return ok("response.success.read", mySkillAppService.listMySkills(principal.userId())); } + + @GetMapping("/stars") + public ApiResponse> listMyStars( + @AuthenticationPrincipal PlatformPrincipal principal) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + + return ok("response.success.read", mySkillAppService.listMyStars(principal.userId())); + } } 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 eb074de59..f7e541cfe 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 @@ -2,6 +2,7 @@ import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -18,12 +19,14 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.*; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.*; import java.util.Map; import java.util.Set; +import org.slf4j.MDC; @RestController @RequestMapping("/api/v1/promotions") @@ -36,6 +39,7 @@ public class PromotionController extends BaseApiController { private final NamespaceRepository namespaceRepository; private final UserAccountRepository userAccountRepository; private final RbacService rbacService; + private final AuditLogService auditLogService; public PromotionController(PromotionService promotionService, PromotionRequestRepository promotionRequestRepository, @@ -44,6 +48,7 @@ public PromotionController(PromotionService promotionService, NamespaceRepository namespaceRepository, UserAccountRepository userAccountRepository, RbacService rbacService, + AuditLogService auditLogService, ApiResponseFactory responseFactory) { super(responseFactory); this.promotionService = promotionService; @@ -53,18 +58,22 @@ public PromotionController(PromotionService promotionService, this.namespaceRepository = namespaceRepository; this.userAccountRepository = userAccountRepository; this.rbacService = rbacService; + this.auditLogService = auditLogService; } @PostMapping public ApiResponse submitPromotion( @RequestBody PromotionRequestDto request, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { PromotionRequest promotion = promotionService.submitPromotion( request.sourceSkillId(), request.sourceVersionId(), request.targetNamespaceId(), userId, userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId)); + recordAudit("PROMOTION_SUBMIT", userId, promotion.getId(), httpRequest, + "{\"sourceSkillId\":" + request.sourceSkillId() + ",\"sourceVersionId\":" + request.sourceVersionId() + "}"); return ok("response.success.created", toResponse(promotion)); } @@ -72,10 +81,12 @@ public ApiResponse submitPromotion( public ApiResponse approvePromotion( @PathVariable Long id, @RequestBody(required = false) PromotionActionRequest request, - @RequestAttribute("userId") String userId) { + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, platformRoles); + recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(promotion)); } @@ -83,13 +94,31 @@ public ApiResponse approvePromotion( public ApiResponse rejectPromotion( @PathVariable Long id, @RequestBody(required = false) PromotionActionRequest request, - @RequestAttribute("userId") String userId) { + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, platformRoles); + recordAudit("PROMOTION_REJECT", userId, promotion.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(promotion)); } + @GetMapping + public ApiResponse> listPromotions( + @RequestParam(defaultValue = "PENDING") String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId) { + Set platformRoles = rbacService.getUserRoleCodes(userId); + boolean hasAdminRole = platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); + if (!hasAdminRole) { + throw new DomainForbiddenException("promotion.no_permission"); + } + ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); + Page requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + return ok("response.success.read", PageResponse.from(requests.map(this::toResponse))); + } + @GetMapping("/pending") public ApiResponse> listPendingPromotions( @RequestParam(defaultValue = "0") int page, @@ -152,4 +181,28 @@ private PromotionResponseDto toResponse(PromotionRequest req) { req.getReviewedAt() ); } + + private void recordAudit(String action, + String userId, + Long targetId, + HttpServletRequest httpRequest, + String detailJson) { + auditLogService.record( + userId, + action, + "PROMOTION_REQUEST", + targetId, + MDC.get("requestId"), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + detailJson + ); + } + + private String detailWithComment(String comment) { + if (comment == null || comment.isBlank()) { + return null; + } + return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + } } 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 364297521..6e766132b 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 @@ -2,6 +2,7 @@ import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -18,12 +19,15 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.*; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.*; import java.util.Map; import java.util.Set; +import org.slf4j.MDC; @RestController @RequestMapping("/api/v1/reviews") @@ -36,6 +40,7 @@ public class ReviewController extends BaseApiController { private final NamespaceRepository namespaceRepository; private final UserAccountRepository userAccountRepository; private final RbacService rbacService; + private final AuditLogService auditLogService; public ReviewController(ReviewService reviewService, ReviewTaskRepository reviewTaskRepository, @@ -44,6 +49,7 @@ public ReviewController(ReviewService reviewService, NamespaceRepository namespaceRepository, UserAccountRepository userAccountRepository, RbacService rbacService, + AuditLogService auditLogService, ApiResponseFactory responseFactory) { super(responseFactory); this.reviewService = reviewService; @@ -53,19 +59,22 @@ public ReviewController(ReviewService reviewService, this.namespaceRepository = namespaceRepository; this.userAccountRepository = userAccountRepository; this.rbacService = rbacService; + this.auditLogService = auditLogService; } @PostMapping public ApiResponse submitReview( @RequestBody ReviewTaskRequest request, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { ReviewTask task = reviewService.submitReview( request.skillVersionId(), userId, userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId) ); + recordAudit("REVIEW_SUBMIT", userId, task.getId(), httpRequest, "{\"skillVersionId\":" + request.skillVersionId() + "}"); return ok("response.success.created", toResponse(task)); } @@ -74,11 +83,13 @@ public ApiResponse approveReview( @PathVariable Long id, @RequestBody(required = false) ReviewActionRequest request, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); ReviewTask task = reviewService.approveReview(id, userId, comment, userNsRoles != null ? userNsRoles : Map.of(), platformRoles); + recordAudit("REVIEW_APPROVE", userId, task.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(task)); } @@ -87,23 +98,59 @@ public ApiResponse rejectReview( @PathVariable Long id, @RequestBody(required = false) ReviewActionRequest request, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; Set platformRoles = rbacService.getUserRoleCodes(userId); ReviewTask task = reviewService.rejectReview(id, userId, comment, userNsRoles != null ? userNsRoles : Map.of(), platformRoles); + recordAudit("REVIEW_REJECT", userId, task.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(task)); } @PostMapping("/{id}/withdraw") public ApiResponse withdrawReview( @PathVariable Long id, - @RequestAttribute("userId") String userId) { + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { ReviewTask task = reviewTaskRepository.findById(id).orElseThrow(); reviewService.withdrawReview(task.getSkillVersionId(), userId); + recordAudit("REVIEW_WITHDRAW", userId, id, httpRequest, "{\"skillVersionId\":" + task.getSkillVersionId() + "}"); return ok("response.success.updated", null); } + @GetMapping + public ApiResponse> listReviews( + @RequestParam String status, + @RequestParam(required = false) Long namespaceId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); + Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); + + Page tasks; + if (namespaceId != null) { + Namespace namespace = namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); + ReviewTask probe = new ReviewTask(0L, namespaceId, userId); + if (!reviewService.canReviewNamespace(probe, userId, namespace.getType(), namespaceRoles, rbacService.getUserRoleCodes(userId))) { + throw new DomainForbiddenException("review.no_permission"); + } + tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, PageRequest.of(page, size)); + } else { + tasks = reviewTaskRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + } + + java.util.List visibleItems = tasks.getContent().stream() + .filter(task -> canViewReview(task, userId, namespaceRoles)) + .map(this::toResponse) + .toList(); + Page responsePage = new PageImpl<>(visibleItems, tasks.getPageable(), visibleItems.size()); + return ok("response.success.read", PageResponse.from(responsePage)); + } + @GetMapping("/pending") public ApiResponse> listPendingReviews( @RequestParam Long namespaceId, @@ -180,4 +227,34 @@ private ReviewTaskResponse toResponse(ReviewTask task) { task.getReviewedAt() ); } + + private boolean canViewReview(ReviewTask task, String userId, Map namespaceRoles) { + Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + return reviewService.canViewReview(task, userId, namespace.getType(), namespaceRoles, rbacService.getUserRoleCodes(userId)); + } + + private void recordAudit(String action, + String userId, + Long targetId, + HttpServletRequest httpRequest, + String detailJson) { + auditLogService.record( + userId, + action, + "REVIEW_TASK", + targetId, + MDC.get("requestId"), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + detailJson + ); + } + + private String detailWithComment(String comment) { + if (comment == null || comment.isBlank()) { + return null; + } + return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 90dde6429..e265ce579 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -64,6 +64,9 @@ public ApiResponse getSkillDetail( detail.status(), detail.downloadCount(), detail.starCount(), + detail.ratingAvg(), + detail.ratingCount(), + detail.hidden(), detail.latestVersion(), namespace ); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java index 3d5699676..3412fb1a3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java @@ -1,9 +1,14 @@ package com.iflytek.skillhub.dto; +import java.time.LocalDateTime; +import java.util.List; + public record AdminUserSummaryResponse( String userId, String username, - String role, - String status + String email, + List platformRoles, + String status, + LocalDateTime createdAt ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index 6bdafbf66..12d0019af 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.dto; +import java.math.BigDecimal; + public record SkillDetailResponse( Long id, String slug, @@ -9,6 +11,9 @@ public record SkillDetailResponse( String status, Long downloadCount, Integer starCount, + BigDecimal ratingAvg, + Integer ratingCount, + boolean hidden, String latestVersion, String namespace ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java new file mode 100644 index 000000000..7be258b77 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java @@ -0,0 +1,140 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +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.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.dto.AdminUserSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AdminUserManagementService { + + private final UserAccountRepository userAccountRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final RoleRepository roleRepository; + + public AdminUserManagementService(UserAccountRepository userAccountRepository, + UserRoleBindingRepository userRoleBindingRepository, + RoleRepository roleRepository) { + this.userAccountRepository = userAccountRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.roleRepository = roleRepository; + } + + @Transactional(readOnly = true) + public PageResponse listUsers(String keyword, String status, int page, int size) { + UserStatus userStatus = parseStatus(status); + Page users = userAccountRepository.search(normalize(keyword), userStatus, PageRequest.of(page, size)); + List items = users.getContent().stream() + .map(this::toSummary) + .toList(); + return PageResponse.from(new PageImpl<>(items, users.getPageable(), users.getTotalElements())); + } + + @Transactional + public AdminUserSummaryResponse updateUserRole(String userId, String roleCode, PlatformPrincipal principal) { + UserAccount user = loadUser(userId); + if (principal != null + && !principal.platformRoles().contains("SUPER_ADMIN") + && "SUPER_ADMIN".equalsIgnoreCase(roleCode)) { + throw new DomainForbiddenException("error.admin.role.assign_super_admin_forbidden"); + } + Role role = roleRepository.findByCode(roleCode) + .orElseThrow(() -> new DomainBadRequestException("error.role.notFound", roleCode)); + + List existing = userRoleBindingRepository.findByUserId(userId); + boolean alreadyAssigned = existing.stream().anyMatch(binding -> binding.getRole().getCode().equals(roleCode)); + if (!alreadyAssigned) { + userRoleBindingRepository.save(new UserRoleBinding(userId, role)); + } + return toSummary(user); + } + + @Transactional + public AdminUserSummaryResponse approveUser(String userId) { + UserAccount user = loadUser(userId); + user.setStatus(UserStatus.ACTIVE); + return toSummary(userAccountRepository.save(user)); + } + + @Transactional + public AdminUserSummaryResponse updateUserStatus(String userId, String status) { + UserAccount user = loadUser(userId); + user.setStatus(parseRequiredStatus(status)); + return toSummary(userAccountRepository.save(user)); + } + + @Transactional + public AdminUserSummaryResponse disableUser(String userId) { + UserAccount user = loadUser(userId); + user.setStatus(UserStatus.DISABLED); + return toSummary(userAccountRepository.save(user)); + } + + @Transactional + public AdminUserSummaryResponse enableUser(String userId) { + UserAccount user = loadUser(userId); + user.setStatus(UserStatus.ACTIVE); + return toSummary(userAccountRepository.save(user)); + } + + private UserAccount loadUser(String userId) { + return userAccountRepository.findById(userId) + .orElseThrow(() -> new DomainNotFoundException("error.user.notFound", userId)); + } + + private AdminUserSummaryResponse toSummary(UserAccount user) { + Set roles = new LinkedHashSet<>(); + userRoleBindingRepository.findByUserId(user.getId()).stream() + .map(binding -> binding.getRole().getCode()) + .sorted(Comparator.naturalOrder()) + .forEach(roles::add); + return new AdminUserSummaryResponse( + user.getId(), + user.getDisplayName(), + user.getEmail(), + List.copyOf(roles), + user.getStatus().name(), + user.getCreatedAt() + ); + } + + private String normalize(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return keyword.trim(); + } + + private UserStatus parseStatus(String status) { + if (status == null || status.isBlank()) { + return null; + } + return parseRequiredStatus(status); + } + + private UserStatus parseRequiredStatus(String status) { + try { + return UserStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("error.user.status.invalid", status); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index b262621f3..6f2684f03 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -5,8 +5,10 @@ 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.social.SkillStarRepository; import com.iflytek.skillhub.dto.SkillSummaryResponse; import org.springframework.stereotype.Service; +import org.springframework.data.domain.PageRequest; import java.util.Comparator; import java.util.List; @@ -21,14 +23,17 @@ public class MySkillAppService { private final SkillRepository skillRepository; private final NamespaceRepository namespaceRepository; private final SkillVersionRepository skillVersionRepository; + private final SkillStarRepository skillStarRepository; public MySkillAppService( SkillRepository skillRepository, NamespaceRepository namespaceRepository, - SkillVersionRepository skillVersionRepository) { + SkillVersionRepository skillVersionRepository, + SkillStarRepository skillStarRepository) { this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.skillVersionRepository = skillVersionRepository; + this.skillStarRepository = skillStarRepository; } public List listMySkills(String userId) { @@ -62,6 +67,50 @@ public List listMySkills(String userId) { .toList(); } + public List listMyStars(String userId) { + List stars = skillStarRepository.findByUserId( + userId, + PageRequest.of(0, 200) + ).getContent(); + + List skillIds = stars.stream() + .map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId) + .distinct() + .toList(); + Map skillsById = skillIds.isEmpty() + ? Map.of() + : skillRepository.findByIdIn(skillIds).stream() + .collect(Collectors.toMap(Skill::getId, Function.identity())); + + List latestVersionIds = skillsById.values().stream() + .map(Skill::getLatestVersionId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + Map versionsById = latestVersionIds.isEmpty() + ? Map.of() + : skillVersionRepository.findByIdIn(latestVersionIds).stream() + .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + + List namespaceIds = skillsById.values().stream() + .map(Skill::getNamespaceId) + .distinct() + .toList(); + Map namespaceSlugsById = namespaceIds.isEmpty() + ? Map.of() + : namespaceRepository.findByIdIn(namespaceIds).stream() + .collect(Collectors.toMap( + com.iflytek.skillhub.domain.namespace.Namespace::getId, + com.iflytek.skillhub.domain.namespace.Namespace::getSlug)); + + return stars.stream() + .sorted(Comparator.comparing(com.iflytek.skillhub.domain.social.SkillStar::getCreatedAt).reversed()) + .map(star -> skillsById.get(star.getSkillId())) + .filter(java.util.Objects::nonNull) + .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) + .toList(); + } + private SkillSummaryResponse toSummaryResponse( Skill skill, Map versionsById, diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 1c0274c92..59782dabc 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -100,5 +100,10 @@ error.auth.merge.pendingExists=A pending merge request already exists for this s error.auth.merge.localCredentialConflict=Both accounts already have local credentials error.auth.merge.requestNotFound=Merge request not found error.auth.merge.requestNotPending=Merge request is not pending +error.auth.merge.requestNotVerified=Merge request is not verified error.auth.merge.tokenExpired=Merge verification token has expired error.auth.merge.invalidToken=Invalid merge verification token +error.admin.role.assign_super_admin_forbidden=Only SUPER_ADMIN can assign the SUPER_ADMIN role +error.role.notFound=Role not found: {0} +error.user.notFound=User not found: {0} +error.user.status.invalid=Invalid user status: {0} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index fda7b01ce..e2a62d345 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -100,5 +100,10 @@ error.auth.merge.pendingExists=该待合并账号已有进行中的合并请求 error.auth.merge.localCredentialConflict=两个账号都已启用本地密码登录,无法自动合并 error.auth.merge.requestNotFound=未找到合并请求 error.auth.merge.requestNotPending=该合并请求不处于待验证状态 +error.auth.merge.requestNotVerified=该合并请求尚未完成验证 error.auth.merge.tokenExpired=合并验证 token 已过期 error.auth.merge.invalidToken=合并验证 token 无效 +error.admin.role.assign_super_admin_forbidden=只有 SUPER_ADMIN 才能分配 SUPER_ADMIN 角色 +error.role.notFound=角色不存在:{0} +error.user.notFound=用户不存在:{0} +error.user.status.invalid=非法的用户状态:{0} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index 90e6b164e..aa93b4332 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -3,6 +3,9 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.service.SkillSearchAppService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -14,8 +17,12 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; +import java.util.Map; import java.util.Set; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -35,8 +42,17 @@ class ClawHubCompatControllerTest { @MockBean private DeviceAuthService deviceAuthService; + @MockBean + private SkillSearchAppService skillSearchAppService; + + @MockBean + private SkillQueryService skillQueryService; + @Test void search_returns_200() throws Exception { + given(skillSearchAppService.search("test", null, "relevance", 0, 20, null, Map.of())) + .willReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); + mockMvc.perform(get("/api/compat/v1/search") .param("q", "test")) .andExpect(status().isOk()) @@ -46,6 +62,25 @@ void search_returns_200() throws Exception { @Test void resolve_returns_correct_downloadUrl() throws Exception { + given(skillQueryService.resolveVersion( + eq("global"), + eq("my-skill"), + isNull(), + eq("latest"), + isNull(), + isNull(), + eq(Map.of()))) + .willReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, + "global", + "my-skill", + "latest", + 1L, + "sha256:test", + true, + "/api/v1/skills/global/my-skill/download" + )); + mockMvc.perform(get("/api/compat/v1/resolve/my-skill")) .andExpect(status().isOk()) .andExpect(jsonPath("$.canonicalSlug").value("my-skill")) @@ -55,6 +90,25 @@ void resolve_returns_correct_downloadUrl() throws Exception { @Test void resolve_with_namespace_returns_correct_downloadUrl() throws Exception { + given(skillQueryService.resolveVersion( + eq("team-ai"), + eq("my-skill"), + isNull(), + eq("latest"), + isNull(), + isNull(), + eq(Map.of()))) + .willReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, + "team-ai", + "my-skill", + "latest", + 1L, + "sha256:test", + true, + "/api/v1/skills/team-ai/my-skill/download" + )); + mockMvc.perform(get("/api/compat/v1/resolve/team-ai--my-skill")) .andExpect(status().isOk()) .andExpect(jsonPath("$.canonicalSlug").value("team-ai--my-skill")) @@ -64,6 +118,25 @@ void resolve_with_namespace_returns_correct_downloadUrl() throws Exception { @Test void resolve_with_version_returns_specified_version() throws Exception { + given(skillQueryService.resolveVersion( + eq("global"), + eq("my-skill"), + eq("1.0.0"), + isNull(), + isNull(), + isNull(), + eq(Map.of()))) + .willReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, + "global", + "my-skill", + "1.0.0", + 2L, + "sha256:test", + true, + "/api/v1/skills/global/my-skill/download" + )); + mockMvc.perform(get("/api/compat/v1/resolve/my-skill") .param("version", "1.0.0")) .andExpect(status().isOk()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java index 167c0f1ec..7d0afcd6b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -73,6 +74,27 @@ void verify_returnsSuccessMessage() throws Exception { """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.message").value("Account merge verified")); + + verify(accountMergeService).verify("usr_primary", 1L, "merge-token"); + } + + @Test + void confirm_returnsSuccessMessage() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); + + mockMvc.perform(post("/api/v1/account/merge/confirm") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"mergeRequestId":1} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.message").value("Account merge completed")); + + verify(accountMergeService).confirm("usr_primary", 1L); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java index f642cb7cb..3ec1746dc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java @@ -4,6 +4,9 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.dto.AdminUserSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.AdminUserManagementService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -17,7 +20,9 @@ import java.util.List; import java.util.Set; +import java.time.LocalDateTime; +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -41,6 +46,9 @@ class UserManagementControllerTest { @MockBean private DeviceAuthService deviceAuthService; + @MockBean + private AdminUserManagementService adminUserManagementService; + @Test void listUsers_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/users")) @@ -49,6 +57,17 @@ void listUsers_unauthenticated_returns401() throws Exception { @Test void listUsers_withUserAdminRole_returns200() throws Exception { + given(adminUserManagementService.listUsers(null, null, 0, 20)) + .willReturn(new PageResponse<>( + List.of( + new AdminUserSummaryResponse("user-1", "alice", "alice@example.com", List.of("USER"), "ACTIVE", LocalDateTime.parse("2026-03-12T12:00:00")), + new AdminUserSummaryResponse("user-2", "bob", "bob@example.com", List.of("USER_ADMIN"), "PENDING", LocalDateTime.parse("2026-03-12T13:00:00")) + ), + 2, + 0, + 20 + )); + PlatformPrincipal principal = new PlatformPrincipal( "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") ); @@ -65,6 +84,14 @@ void listUsers_withUserAdminRole_returns200() throws Exception { @Test void listUsers_withSuperAdminRole_returns200() throws Exception { + given(adminUserManagementService.listUsers(null, null, 0, 20)) + .willReturn(new PageResponse<>( + List.of(new AdminUserSummaryResponse("user-99", "superadmin", "super@example.com", List.of("SUPER_ADMIN"), "ACTIVE", LocalDateTime.parse("2026-03-12T14:00:00"))), + 1, + 0, + 20 + )); + PlatformPrincipal principal = new PlatformPrincipal( "user-99", "superadmin", "super@example.com", "", "github", Set.of("SUPER_ADMIN") ); @@ -79,6 +106,9 @@ void listUsers_withSuperAdminRole_returns200() throws Exception { @Test void updateUserRole_withUserAdminRole_returns200() throws Exception { + given(adminUserManagementService.updateUserRole(org.mockito.ArgumentMatchers.eq("user-123"), org.mockito.ArgumentMatchers.eq("USER_ADMIN"), org.mockito.ArgumentMatchers.any())) + .willReturn(new AdminUserSummaryResponse("user-123", "target", "target@example.com", List.of("USER_ADMIN"), "ACTIVE", LocalDateTime.parse("2026-03-12T15:00:00"))); + PlatformPrincipal principal = new PlatformPrincipal( "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") ); @@ -86,7 +116,7 @@ void updateUserRole_withUserAdminRole_returns200() throws Exception { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) ); - String requestBody = "{\"role\":\"MODERATOR\"}"; + String requestBody = "{\"role\":\"USER_ADMIN\"}"; mockMvc.perform(put("/api/v1/admin/users/user-123/role") .with(authentication(auth)) @@ -96,11 +126,14 @@ void updateUserRole_withUserAdminRole_returns200() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("user-123")) - .andExpect(jsonPath("$.data.role").value("MODERATOR")); + .andExpect(jsonPath("$.data.role").value("USER_ADMIN")); } @Test void updateUserStatus_withUserAdminRole_returns200() throws Exception { + given(adminUserManagementService.updateUserStatus("user-123", "DISABLED")) + .willReturn(new AdminUserSummaryResponse("user-123", "target", "target@example.com", List.of("USER"), "DISABLED", LocalDateTime.parse("2026-03-12T16:00:00"))); + PlatformPrincipal principal = new PlatformPrincipal( "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") ); @@ -108,7 +141,7 @@ void updateUserStatus_withUserAdminRole_returns200() throws Exception { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) ); - String requestBody = "{\"status\":\"BANNED\"}"; + String requestBody = "{\"status\":\"DISABLED\"}"; mockMvc.perform(put("/api/v1/admin/users/user-123/status") .with(authentication(auth)) @@ -118,6 +151,6 @@ void updateUserStatus_withUserAdminRole_returns200() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("user-123")) - .andExpect(jsonPath("$.data.status").value("BANNED")); + .andExpect(jsonPath("$.data.status").value("DISABLED")); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 01fa10321..660e4c3c1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -78,7 +78,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/swagger-ui/**", "/.well-known/**", "/api/compat/v1/search", - "/api/compat/v1/resolve/**" + "/api/compat/v1/resolve/**", + "/api/compat/v1/download/**" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/skills", diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java index c1c96a19c..b671f60b3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java @@ -14,6 +14,7 @@ public class AccountMergeRequest { public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_VERIFIED = "VERIFIED"; public static final String STATUS_COMPLETED = "COMPLETED"; public static final String STATUS_CANCELLED = "CANCELLED"; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java index 7a571bc55..9badccb5f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java @@ -100,7 +100,7 @@ public InitiationResult initiate(String primaryUserId, String secondaryIdentifie } @Transactional - public void verifyAndComplete(String primaryUserId, Long mergeRequestId, String verificationToken) { + public void verify(String primaryUserId, Long mergeRequestId, String verificationToken) { AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId) .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound")); if (!AccountMergeRequest.STATUS_PENDING.equals(request.getStatus())) { @@ -113,6 +113,23 @@ public void verifyAndComplete(String primaryUserId, Long mergeRequestId, String throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.merge.invalidToken"); } + loadActiveUser(primaryUserId); + UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + validateMergePair(loadActiveUser(primaryUserId), secondaryUser); + + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + mergeRequestRepository.save(request); + } + + @Transactional + public void confirm(String primaryUserId, Long mergeRequestId) { + AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound")); + if (!AccountMergeRequest.STATUS_VERIFIED.equals(request.getStatus())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotVerified"); + } + UserAccount primaryUser = loadActiveUser(primaryUserId); UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId()) .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java index 76aeaf6b8..ceb753c39 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.repository; import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import java.util.Collection; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; @@ -8,4 +9,6 @@ @Repository public interface UserRoleBindingRepository extends JpaRepository { List findByUserId(String userId); + List findByUserIdIn(Collection userIds); + long deleteByUserId(String userId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java index 9dfd545c3..9d3a3c490 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java @@ -8,6 +8,7 @@ public interface ReviewTaskRepository { ReviewTask save(ReviewTask reviewTask); Optional findById(Long id); Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); + Page findByStatus(ReviewTaskStatus status, Pageable pageable); Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); void delete(ReviewTask reviewTask); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 0b127f28e..e4953ebff 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -61,6 +61,9 @@ public record SkillDetailDTO( String status, Long downloadCount, Integer starCount, + java.math.BigDecimal ratingAvg, + Integer ratingCount, + boolean hidden, String latestVersion, Long namespaceId ) {} @@ -120,6 +123,9 @@ public SkillDetailDTO getSkillDetail( skill.getStatus().name(), skill.getDownloadCount(), skill.getStarCount(), + skill.getRatingAvg(), + skill.getRatingCount(), + skill.isHidden(), latestVersion, skill.getNamespaceId() ); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java index fdd8f21aa..d2163c563 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java @@ -1,9 +1,15 @@ package com.iflytek.skillhub.domain.user; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; import java.util.Optional; public interface UserAccountRepository { Optional findById(String id); + List findByIdIn(List ids); Optional findByEmailIgnoreCase(String email); + Page search(String keyword, UserStatus status, Pageable pageable); UserAccount save(UserAccount user); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java index 570dd992c..5ff37a7e4 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java @@ -17,6 +17,8 @@ public interface ReviewTaskJpaRepository extends JpaRepository Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); + Page findByStatus(ReviewTaskStatus status, Pageable pageable); + Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java index 4f6e9eb73..d5a907bee 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java @@ -2,10 +2,31 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface UserAccountJpaRepository extends JpaRepository, UserAccountRepository { + + @Override + @Query(""" + SELECT u + FROM UserAccount u + WHERE (:status IS NULL OR u.status = :status) + AND ( + :keyword IS NULL + OR lower(u.displayName) LIKE lower(concat('%', :keyword, '%')) + OR lower(coalesce(u.email, '')) LIKE lower(concat('%', :keyword, '%')) + OR lower(u.id) LIKE lower(concat('%', :keyword, '%')) + ) + """) + Page search(@Param("keyword") String keyword, + @Param("status") UserStatus status, + Pageable pageable); } From 70bd171e31d57d928b648141e0c9bfc2c4f4bb14 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:36:39 +0800 Subject: [PATCH 018/313] Complete phase 3 and 4 frontend flows --- web/src/api/client.ts | 228 +- web/src/api/generated/schema.d.ts | 3957 ++++++++++++++++- web/src/api/types.ts | 102 +- web/src/app/router.tsx | 63 + web/src/features/admin/use-admin-users.ts | 64 +- web/src/features/admin/use-audit-log.ts | 24 +- web/src/features/auth/use-account-merge.ts | 8 +- .../features/promotion/use-promotion-list.ts | 43 + web/src/features/review/use-review-detail.ts | 22 +- web/src/features/review/use-review-list.ts | 31 +- web/src/features/social/rating-input.tsx | 11 +- web/src/features/social/star-button.tsx | 12 +- web/src/features/social/use-rating.ts | 15 +- web/src/features/social/use-star.ts | 11 +- web/src/features/token/token-list.tsx | 2 +- web/src/pages/admin/audit-log.tsx | 18 +- web/src/pages/admin/users.tsx | 37 +- web/src/pages/dashboard.tsx | 24 +- web/src/pages/dashboard/my-namespaces.tsx | 22 +- web/src/pages/dashboard/namespace-reviews.tsx | 65 + web/src/pages/dashboard/promotions.tsx | 86 + web/src/pages/dashboard/review-detail.tsx | 14 +- web/src/pages/dashboard/reviews.tsx | 4 +- web/src/pages/dashboard/stars.tsx | 42 + web/src/pages/dashboard/tokens.tsx | 13 + web/src/pages/register.tsx | 114 +- web/src/pages/settings/accounts.tsx | 22 +- web/src/pages/skill-detail.tsx | 77 +- web/src/shared/hooks/use-skill-queries.ts | 13 +- 29 files changed, 4825 insertions(+), 319 deletions(-) create mode 100644 web/src/features/promotion/use-promotion-list.ts create mode 100644 web/src/pages/dashboard/namespace-reviews.tsx create mode 100644 web/src/pages/dashboard/promotions.tsx create mode 100644 web/src/pages/dashboard/stars.tsx create mode 100644 web/src/pages/dashboard/tokens.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0fafbc7fd..6bcedc045 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -5,11 +5,17 @@ import type { ApiToken, CreateTokenRequest, CreateTokenResponse, + MergeConfirmRequest, LocalLoginRequest, LocalRegisterRequest, MergeInitiateRequest, MergeInitiateResponse, MergeVerifyRequest, + ReviewTask, + PromotionTask, + AdminUser, + AuditLogItem, + SkillSummary, OAuthProvider, User, } from './types' @@ -113,7 +119,13 @@ export async function fetchText(input: RequestInfo | URL, init?: RequestInit): P export async function getCurrentUser(): Promise { try { - return await unwrap(client.GET('/api/v1/auth/me') as never) + const user = await unwrap(client.GET('/api/v1/auth/me') as never) + return { + ...user, + userId: user.userId ?? '', + displayName: user.displayName ?? '', + platformRoles: user.platformRoles ?? [], + } } catch (error) { if (error instanceof Error && error.message === 'HTTP 401') { return null @@ -126,7 +138,15 @@ export const authApi = { getMe: getCurrentUser, async getProviders(): Promise { - return unwrap(client.GET('/api/v1/auth/providers') as never) + const providers = await unwrap(client.GET('/api/v1/auth/providers') as never) + return providers + .filter((provider) => provider.id && provider.name && provider.authorizationUrl) + .map((provider) => ({ + ...provider, + id: provider.id!, + name: provider.name!, + authorizationUrl: provider.authorizationUrl!, + })) }, async localLogin(request: LocalLoginRequest): Promise { @@ -160,10 +180,11 @@ export const authApi = { }, async logout(): Promise { - const { response, error } = await client.POST('/api/v1/auth/logout', { + const response = await fetch('/api/v1/auth/logout', { + method: 'POST', headers: withCsrf(), }) - if (error || (response.status !== 200 && response.status !== 204)) { + if (response.status !== 200 && response.status !== 204) { throw new Error(`HTTP ${response.status}`) } }, @@ -189,20 +210,50 @@ export const accountApi = { body: JSON.stringify(request), }) }, + + async confirmMerge(request: MergeConfirmRequest): Promise { + await fetchJson('/api/v1/account/merge/confirm', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, } export const tokenApi = { async getTokens(): Promise { - return unwrap(client.GET('/api/v1/tokens') as never) + const tokens = await unwrap(client.GET('/api/v1/tokens') as never) + return tokens + .filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt) + .map((token) => ({ + ...token, + id: token.id!, + name: token.name!, + tokenPrefix: token.tokenPrefix!, + createdAt: token.createdAt!, + })) }, async createToken(request: CreateTokenRequest): Promise { - return unwrap(client.POST('/api/v1/tokens', { + const token = await unwrap(client.POST('/api/v1/tokens', { headers: withCsrf({ 'Content-Type': 'application/json', }), body: request, }) as never) + if (!token.token || token.id === undefined || !token.name || !token.tokenPrefix || !token.createdAt) { + throw new Error('Invalid token creation response') + } + return { + ...token, + token: token.token, + id: token.id, + name: token.name, + tokenPrefix: token.tokenPrefix, + createdAt: token.createdAt, + } }, async deleteToken(tokenId: number): Promise { @@ -219,3 +270,168 @@ export const tokenApi = { } }, } + +export const reviewApi = { + async list(params: { status: string; namespaceId?: number; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + searchParams.set('status', params.status) + if (params.namespaceId !== undefined) { + searchParams.set('namespaceId', String(params.namespaceId)) + } + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: ReviewTask[]; total: number; page: number; size: number }>( + `/api/v1/reviews?${searchParams.toString()}`, + ) + }, + + async get(id: number): Promise { + return fetchJson(`/api/v1/reviews/${id}`) + }, + + async approve(id: number, comment?: string): Promise { + await fetchJson(`/api/v1/reviews/${id}/approve`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, + + async reject(id: number, comment: string): Promise { + await fetchJson(`/api/v1/reviews/${id}/reject`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, +} + +export const promotionApi = { + async list(params: { status?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + searchParams.set('status', params.status ?? 'PENDING') + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: PromotionTask[]; total: number; page: number; size: number }>( + `/api/v1/promotions?${searchParams.toString()}`, + ) + }, + + async get(id: number): Promise { + return fetchJson(`/api/v1/promotions/${id}`) + }, + + async approve(id: number, comment?: string): Promise { + await fetchJson(`/api/v1/promotions/${id}/approve`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, + + async reject(id: number, comment?: string): Promise { + await fetchJson(`/api/v1/promotions/${id}/reject`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, +} + +export const meApi = { + async getStars(): Promise { + return fetchJson('/api/v1/me/stars') + }, +} + +export const adminApi = { + async getUsers(params: { search?: string; status?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + if (params.search) searchParams.set('search', params.search) + if (params.status) searchParams.set('status', params.status) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: AdminUser[]; total: number; page: number; size: number }>( + `/api/v1/admin/users?${searchParams.toString()}`, + ) + }, + + async updateUserRole(userId: string, role: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/role`, { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ role }), + }) + }, + + async updateUserStatus(userId: string, status: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/status`, { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ status }), + }) + }, + + async approveUser(userId: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/approve`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + + async disableUser(userId: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/disable`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + + async enableUser(userId: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/enable`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + + async getAuditLogs(params: { action?: string; userId?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + if (params.action) searchParams.set('action', params.action) + if (params.userId) searchParams.set('userId', params.userId) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: AuditLogItem[]; total: number; page: number; size: number }>( + `/api/v1/admin/audit-logs?${searchParams.toString()}`, + ) + }, + + async hideSkill(skillId: number, reason?: string): Promise { + await fetchJson(`/api/v1/admin/skills/${skillId}/hide`, { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ reason }), + }) + }, + + async unhideSkill(skillId: number): Promise { + await fetchJson(`/api/v1/admin/skills/${skillId}/unhide`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + + async yankVersion(versionId: number, reason?: string): Promise { + await fetchJson(`/api/v1/admin/skills/versions/${versionId}/yank`, { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ reason }), + }) + }, +} diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 03c59c34f..8107fca80 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1,125 +1,3840 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + export interface paths { - '/api/v1/auth/me': { - get: { - responses: { - 200: { - content: { - 'application/json': components['schemas']['User'] - } - } - 401: { - content?: never - } - } - } - } - '/api/v1/auth/providers': { - get: { - responses: { - 200: { - content: { - 'application/json': components['schemas']['ApiResponse_OAuthProviderList'] - } - } - } - } - } - '/api/v1/auth/logout': { - post: { - responses: { - 200: { - content?: never - } - 204: { - content?: never - } - } - } - } - '/api/v1/tokens': { - get: { - responses: { - 200: { - content: { - 'application/json': components['schemas']['ApiResponse_ApiTokenList'] - } - } - } - } - post: { - requestBody: { - content: { - 'application/json': components['schemas']['CreateTokenRequest'] - } - } - responses: { - 200: { - content: { - 'application/json': components['schemas']['ApiResponse_CreateTokenResponse'] - } - } - } - } - } - '/api/v1/tokens/{id}': { - delete: { - parameters: { - path: { - id: number - } - } - responses: { - 204: { - content?: never - } - } - } - } + "/api/v1/skills/{skillId}/star": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["checkStarred"]; + put: operations["starSkill"]; + post?: never; + delete: operations["unstarSkill"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{skillId}/rating": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getUserRating"]; + put: operations["rateSkill"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags/{tagName}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["createOrMoveTag"]; + post?: never; + delete: operations["deleteTag"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getNamespace"]; + put: operations["updateNamespace"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/members/{userId}/role": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateMemberRole"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateUserStatus"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/role": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateUserRole"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list"]; + put?: never; + post: operations["create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["publish"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listReviews"]; + put?: never; + post: operations["submitReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}/withdraw": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["withdrawReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["rejectReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["approveReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPromotions"]; + put?: never; + post: operations["submitPromotion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions/{id}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["rejectPromotion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["approvePromotion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listNamespaces"]; + put?: never; + post: operations["createNamespace"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMembers"]; + put?: never; + post: operations["addMember"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/device/authorize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["authorizeDevice"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/cli/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["publish_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/cli/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["check"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/cli/auth/device/token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["pollToken"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/cli/auth/device/code": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["requestDeviceCode"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/local/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/local/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/local/change-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["changePassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["enableUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["disableUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["approveUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/skills/{skillId}/unhide": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["unhideSkill"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/skills/{skillId}/hide": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["hideSkill"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/skills/versions/{versionId}/yank": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["yankVersion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["verify"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/initiate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["initiate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/confirm": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["confirm"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/compat/v1/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["publish_2"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getSkillDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listVersions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions/{version}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getVersionDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions/{version}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listFiles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions/{version}/file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getFileContent"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions/{version}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadVersion"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listTags"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags/{tagName}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listFilesByTag"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags/{tagName}/file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getFileContentByTag"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags/{tagName}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadByTag"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resolveVersion"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadLatest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getReviewDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/pending": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPendingReviews"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/my-submissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMySubmissions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPromotionDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions/pending": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPendingPromotions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/me/stars": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyStars"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/me/skills": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMySkills"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/cli/whoami": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["whoami"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/cli/resolve/{namespace}/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resolve"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["providers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["me"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listUsers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/audit-logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listAuditLogs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/compat/v1/whoami": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["whoami_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/compat/v1/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["search_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/compat/v1/resolve/{canonicalSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resolve_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/compat/v1/download/{canonicalSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["download"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/.well-known/clawhub.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["clawhubConfig"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tokens/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["revoke"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/members/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["removeMember"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } - +export type webhooks = Record; export interface components { - schemas: { - User: { - userId: string - displayName: string - email: string - avatarUrl: string - oauthProvider: string - platformRoles: string[] - } - OAuthProvider: { - id: string - name: string - authorizationUrl: string - } - ApiToken: { - id: number - name: string - tokenPrefix: string - createdAt: string - expiresAt: string - lastUsedAt: string - } - CreateTokenRequest: { - name: string - scopes?: string[] - } - CreateTokenResponse: { - token: string - id: number - name: string - tokenPrefix: string - createdAt: string - expiresAt: string - } - ApiResponse_OAuthProviderList: { - data: components['schemas']['OAuthProvider'][] - } - ApiResponse_ApiTokenList: { - data: components['schemas']['ApiToken'][] - } - ApiResponse_CreateTokenResponse: { - data: components['schemas']['CreateTokenResponse'] - } - } + schemas: { + ApiResponseVoid: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: Record; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillRatingRequest: { + /** Format: int32 */ + score: number; + }; + TagRequest: { + tagName: string; + targetVersion: string; + }; + ApiResponseTagResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TagResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + TagResponse: { + /** Format: int64 */ + id?: number; + tagName?: string; + /** Format: int64 */ + versionId?: number; + /** Format: date-time */ + createdAt?: string; + }; + NamespaceRequest: { + slug: string; + displayName: string; + description?: string; + }; + ApiResponseNamespaceResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["NamespaceResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + NamespaceResponse: { + /** Format: int64 */ + id?: number; + slug?: string; + displayName?: string; + /** @enum {string} */ + status?: "ACTIVE" | "FROZEN" | "ARCHIVED"; + description?: string; + /** @enum {string} */ + type?: "GLOBAL" | "TEAM"; + avatarUrl?: string; + createdBy?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + UpdateMemberRoleRequest: { + /** @enum {string} */ + role: "OWNER" | "ADMIN" | "MEMBER"; + }; + ApiResponseMemberResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MemberResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MemberResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + namespaceId?: number; + userId?: string; + /** @enum {string} */ + role?: "OWNER" | "ADMIN" | "MEMBER"; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + AdminUserStatusUpdateRequest: { + status: string; + }; + AdminUserMutationResponse: { + userId?: string; + role?: string; + status?: string; + }; + ApiResponseAdminUserMutationResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AdminUserMutationResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AdminUserRoleUpdateRequest: { + role: string; + }; + TokenCreateRequest: { + name: string; + scopes?: string[]; + }; + ApiResponseTokenCreateResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TokenCreateResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + TokenCreateResponse: { + token?: string; + /** Format: int64 */ + id?: number; + name?: string; + tokenPrefix?: string; + createdAt?: string; + expiresAt?: string; + }; + ApiResponsePublishResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PublishResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PublishResponse: { + /** Format: int64 */ + skillId?: number; + namespace?: string; + slug?: string; + version?: string; + status?: string; + /** Format: int32 */ + fileCount?: number; + /** Format: int64 */ + totalSize?: number; + }; + ReviewTaskRequest: { + /** Format: int64 */ + skillVersionId?: number; + }; + ApiResponseReviewTaskResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ReviewTaskResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ReviewTaskResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + skillVersionId?: number; + namespace?: string; + skillSlug?: string; + version?: string; + status?: string; + submittedBy?: string; + submittedByName?: string; + reviewedBy?: string; + reviewedByName?: string; + reviewComment?: string; + /** Format: date-time */ + submittedAt?: string; + /** Format: date-time */ + reviewedAt?: string; + }; + ReviewActionRequest: { + comment?: string; + }; + PromotionRequestDto: { + /** Format: int64 */ + sourceSkillId?: number; + /** Format: int64 */ + sourceVersionId?: number; + /** Format: int64 */ + targetNamespaceId?: number; + }; + ApiResponsePromotionResponseDto: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PromotionResponseDto"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PromotionResponseDto: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + sourceSkillId?: number; + sourceNamespace?: string; + sourceSkillSlug?: string; + sourceVersion?: string; + targetNamespace?: string; + /** Format: int64 */ + targetSkillId?: number; + status?: string; + submittedBy?: string; + submittedByName?: string; + reviewedBy?: string; + reviewedByName?: string; + reviewComment?: string; + /** Format: date-time */ + submittedAt?: string; + /** Format: date-time */ + reviewedAt?: string; + }; + PromotionActionRequest: { + comment?: string; + }; + MemberRequest: { + userId: string; + /** @enum {string} */ + role: "OWNER" | "ADMIN" | "MEMBER"; + }; + AuthorizeRequest: { + userCode?: string; + }; + ApiResponseMessageResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MessageResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MessageResponse: { + message?: string; + }; + ApiResponseSkillCheckResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillCheckResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillCheckResponse: { + valid?: boolean; + errors?: string[]; + /** Format: int32 */ + fileCount?: number; + /** Format: int64 */ + totalSize?: number; + }; + TokenRequest: { + deviceCode?: string; + }; + ApiResponseDeviceTokenResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DeviceTokenResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DeviceTokenResponse: { + accessToken?: string; + tokenType?: string; + error?: string; + }; + ApiResponseDeviceCodeResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DeviceCodeResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DeviceCodeResponse: { + deviceCode?: string; + userCode?: string; + verificationUri?: string; + /** Format: int32 */ + expiresIn?: number; + /** Format: int32 */ + interval?: number; + }; + LocalRegisterRequest: { + username: string; + password: string; + email?: string; + }; + ApiResponseAuthMeResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AuthMeResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AuthMeResponse: { + userId?: string; + displayName?: string; + email?: string; + avatarUrl?: string; + oauthProvider?: string; + platformRoles?: string[]; + }; + LocalLoginRequest: { + username: string; + password: string; + }; + ChangePasswordRequest: { + currentPassword: string; + newPassword: string; + }; + AdminSkillMutationResponse: { + /** Format: int64 */ + skillId?: number; + /** Format: int64 */ + versionId?: number; + action?: string; + status?: string; + }; + ApiResponseAdminSkillMutationResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AdminSkillMutationResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AdminSkillActionRequest: { + reason?: string; + }; + MergeVerifyRequest: { + /** Format: int64 */ + mergeRequestId: number; + verificationToken: string; + }; + MergeInitiateRequest: { + secondaryIdentifier: string; + }; + ApiResponseMergeInitiateResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MergeInitiateResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MergeInitiateResponse: { + /** Format: int64 */ + mergeRequestId?: number; + secondaryUserId?: string; + verificationToken?: string; + expiresAt?: string; + }; + ConfirmMergeRequest: { + /** Format: int64 */ + mergeRequestId: number; + }; + ClawHubPublishResponse: { + canonicalSlug?: string; + version?: string; + status?: string; + }; + ApiResponseListTokenSummaryResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TokenSummaryResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + TokenSummaryResponse: { + /** Format: int64 */ + id?: number; + name?: string; + tokenPrefix?: string; + createdAt?: string; + expiresAt?: string; + lastUsedAt?: string; + }; + ApiResponseSearchResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SearchResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SearchResponse: { + items?: components["schemas"]["SkillSummaryResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + SkillSummaryResponse: { + /** Format: int64 */ + id?: number; + slug?: string; + displayName?: string; + summary?: string; + /** Format: int64 */ + downloadCount?: number; + /** Format: int32 */ + starCount?: number; + ratingAvg?: number; + /** Format: int32 */ + ratingCount?: number; + latestVersion?: string; + namespace?: string; + /** Format: date-time */ + updatedAt?: string; + }; + ApiResponseBoolean: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: boolean; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseSkillRatingStatusResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillRatingStatusResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillRatingStatusResponse: { + /** Format: int32 */ + score?: number; + rated?: boolean; + }; + ApiResponseSkillDetailResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillDetailResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillDetailResponse: { + /** Format: int64 */ + id?: number; + slug?: string; + displayName?: string; + summary?: string; + visibility?: string; + status?: string; + /** Format: int64 */ + downloadCount?: number; + /** Format: int32 */ + starCount?: number; + ratingAvg?: number; + /** Format: int32 */ + ratingCount?: number; + hidden?: boolean; + latestVersion?: string; + namespace?: string; + }; + ApiResponsePageResponseSkillVersionResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseSkillVersionResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseSkillVersionResponse: { + items?: components["schemas"]["SkillVersionResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + SkillVersionResponse: { + /** Format: int64 */ + id?: number; + version?: string; + status?: string; + changelog?: string; + /** Format: int32 */ + fileCount?: number; + /** Format: int64 */ + totalSize?: number; + /** Format: date-time */ + publishedAt?: string; + }; + ApiResponseSkillVersionDetailResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillVersionDetailResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillVersionDetailResponse: { + /** Format: int64 */ + id?: number; + version?: string; + status?: string; + changelog?: string; + /** Format: int32 */ + fileCount?: number; + /** Format: int64 */ + totalSize?: number; + /** Format: date-time */ + publishedAt?: string; + parsedMetadataJson?: string; + manifestJson?: string; + }; + ApiResponseListSkillFileResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillFileResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillFileResponse: { + /** Format: int64 */ + id?: number; + filePath?: string; + /** Format: int64 */ + fileSize?: number; + contentType?: string; + sha256?: string; + }; + ApiResponseListTagResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TagResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseResolveVersionResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ResolveVersionResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ResolveVersionResponse: { + /** Format: int64 */ + skillId?: number; + namespace?: string; + slug?: string; + version?: string; + /** Format: int64 */ + versionId?: number; + fingerprint?: string; + matched?: boolean; + downloadUrl?: string; + }; + ApiResponsePageResponseReviewTaskResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseReviewTaskResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseReviewTaskResponse: { + items?: components["schemas"]["ReviewTaskResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponsePageResponsePromotionResponseDto: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponsePromotionResponseDto"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponsePromotionResponseDto: { + items?: components["schemas"]["PromotionResponseDto"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + Pageable: { + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + sort?: string[]; + }; + ApiResponsePageResponseNamespaceResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseNamespaceResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseNamespaceResponse: { + items?: components["schemas"]["NamespaceResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponsePageResponseMemberResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseMemberResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseMemberResponse: { + items?: components["schemas"]["MemberResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponseListSkillSummaryResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillSummaryResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseCliWhoamiResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["CliWhoamiResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + CliWhoamiResponse: { + userId?: string; + displayName?: string; + email?: string; + avatarUrl?: string; + authType?: string; + platformRoles?: string[]; + }; + ApiResponseListAuthProviderResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AuthProviderResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AuthProviderResponse: { + id?: string; + name?: string; + authorizationUrl?: string; + }; + AdminUserSummaryResponse: { + userId?: string; + username?: string; + email?: string; + platformRoles?: string[]; + status?: string; + /** Format: date-time */ + createdAt?: string; + }; + ApiResponsePageResponseAdminUserSummaryResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseAdminUserSummaryResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseAdminUserSummaryResponse: { + items?: components["schemas"]["AdminUserSummaryResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponsePageResponseAuditLogItemResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseAuditLogItemResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AuditLogItemResponse: { + id?: string; + userId?: string; + action?: string; + resourceType?: string; + resourceId?: string; + /** Format: date-time */ + timestamp?: string; + ipAddress?: string; + }; + PageResponseAuditLogItemResponse: { + items?: components["schemas"]["AuditLogItemResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ClawHubWhoamiResponse: { + userId?: string; + displayName?: string; + email?: string; + }; + ClawHubSearchResponse: { + items?: components["schemas"]["ClawHubSkillItem"][]; + }; + ClawHubSkillItem: { + canonicalSlug?: string; + description?: string; + latestVersion?: string; + /** Format: int32 */ + starCount?: number; + }; + ClawHubResolveResponse: { + canonicalSlug?: string; + version?: string; + downloadUrl?: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + checkStarred: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseBoolean"]; + }; + }; + }; + }; + starSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + unstarSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + getUserRating: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillRatingStatusResponse"]; + }; + }; + }; + }; + rateSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillRatingRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + createOrMoveTag: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TagRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseTagResponse"]; + }; + }; + }; + }; + deleteTag: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + getNamespace: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseNamespaceResponse"]; + }; + }; + }; + }; + updateNamespace: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseNamespaceResponse"]; + }; + }; + }; + }; + updateMemberRole: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateMemberRoleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMemberResponse"]; + }; + }; + }; + }; + updateUserStatus: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserStatusUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + updateUserRole: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserRoleUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListTokenSummaryResponse"]; + }; + }; + }; + }; + create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TokenCreateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseTokenCreateResponse"]; + }; + }; + }; + }; + publish: { + parameters: { + query: { + visibility: string; + }; + header?: never; + path: { + namespace: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePublishResponse"]; + }; + }; + }; + }; + listReviews: { + parameters: { + query: { + status: string; + namespaceId?: number; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + }; + }; + }; + }; + submitReview: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReviewTaskRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + }; + }; + }; + }; + withdrawReview: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + rejectReview: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReviewActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + }; + }; + }; + }; + approveReview: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReviewActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + }; + }; + }; + }; + listPromotions: { + parameters: { + query?: { + status?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; + }; + }; + }; + }; + submitPromotion: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PromotionRequestDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + }; + }; + }; + }; + rejectPromotion: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PromotionActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + }; + }; + }; + }; + approvePromotion: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PromotionActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + }; + }; + }; + }; + listNamespaces: { + parameters: { + query: { + pageable: components["schemas"]["Pageable"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseNamespaceResponse"]; + }; + }; + }; + }; + createNamespace: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseNamespaceResponse"]; + }; + }; + }; + }; + listMembers: { + parameters: { + query: { + pageable: components["schemas"]["Pageable"]; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMemberResponse"]; + }; + }; + }; + }; + addMember: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MemberRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMemberResponse"]; + }; + }; + }; + }; + authorizeDevice: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthorizeRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + publish_1: { + parameters: { + query: { + namespace: string; + visibility: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePublishResponse"]; + }; + }; + }; + }; + check: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillCheckResponse"]; + }; + }; + }; + }; + pollToken: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseDeviceTokenResponse"]; + }; + }; + }; + }; + requestDeviceCode: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseDeviceCodeResponse"]; + }; + }; + }; + }; + register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LocalRegisterRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + }; + }; + }; + }; + login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LocalLoginRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + }; + }; + }; + }; + changePassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChangePasswordRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + enableUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + disableUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + approveUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + unhideSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + }; + }; + }; + }; + hideSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AdminSkillActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + }; + }; + }; + }; + yankVersion: { + parameters: { + query?: never; + header?: never; + path: { + versionId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AdminSkillActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + }; + }; + }; + }; + verify: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MergeVerifyRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + initiate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MergeInitiateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMergeInitiateResponse"]; + }; + }; + }; + }; + confirm: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmMergeRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + publish_2: { + parameters: { + query: { + namespace: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ClawHubPublishResponse"]; + }; + }; + }; + }; + search: { + parameters: { + query?: { + q?: string; + namespace?: string; + sort?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSearchResponse"]; + }; + }; + }; + }; + getSkillDetail: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillDetailResponse"]; + }; + }; + }; + }; + listVersions: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseSkillVersionResponse"]; + }; + }; + }; + }; + getVersionDetail: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillVersionDetailResponse"]; + }; + }; + }; + }; + listFiles: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; + }; + }; + }; + }; + getFileContent: { + parameters: { + query: { + path: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + downloadVersion: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + listTags: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListTagResponse"]; + }; + }; + }; + }; + listFilesByTag: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; + }; + }; + }; + }; + getFileContentByTag: { + parameters: { + query: { + path: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + downloadByTag: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + resolveVersion: { + parameters: { + query?: { + version?: string; + tag?: string; + hash?: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; + }; + }; + }; + }; + downloadLatest: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + getReviewDetail: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + }; + }; + }; + }; + listPendingReviews: { + parameters: { + query: { + namespaceId: number; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + }; + }; + }; + }; + listMySubmissions: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + }; + }; + }; + }; + getPromotionDetail: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + }; + }; + }; + }; + listPendingPromotions: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; + }; + }; + }; + }; + listMyStars: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSummaryResponse"]; + }; + }; + }; + }; + listMySkills: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSummaryResponse"]; + }; + }; + }; + }; + health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + whoami: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCliWhoamiResponse"]; + }; + }; + }; + }; + resolve: { + parameters: { + query?: { + version?: string; + tag?: string; + hash?: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; + }; + }; + }; + }; + providers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListAuthProviderResponse"]; + }; + }; + }; + }; + me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + }; + }; + }; + }; + listUsers: { + parameters: { + query?: { + search?: string; + status?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseAdminUserSummaryResponse"]; + }; + }; + }; + }; + listAuditLogs: { + parameters: { + query?: { + page?: number; + size?: number; + userId?: string; + action?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseAuditLogItemResponse"]; + }; + }; + }; + }; + whoami_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ClawHubWhoamiResponse"]; + }; + }; + }; + }; + search_1: { + parameters: { + query: { + q: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ClawHubSearchResponse"]; + }; + }; + }; + }; + resolve_1: { + parameters: { + query?: { + version?: string; + }; + header?: never; + path: { + canonicalSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ClawHubResolveResponse"]; + }; + }; + }; + }; + download: { + parameters: { + query?: { + version?: string; + }; + header?: never; + path: { + canonicalSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + clawhubConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": { + [key: string]: string; + }; + }; + }; + }; + }; + revoke: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + removeMember: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 9929ee2c2..3cd532b31 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1,10 +1,42 @@ import type { components } from './generated/schema' -export type User = components['schemas']['User'] -export type OAuthProvider = components['schemas']['OAuthProvider'] -export type ApiToken = components['schemas']['ApiToken'] -export type CreateTokenRequest = components['schemas']['CreateTokenRequest'] -export type CreateTokenResponse = components['schemas']['CreateTokenResponse'] +export type User = Omit & { + userId: string + displayName: string + email?: string + avatarUrl?: string + oauthProvider?: string + platformRoles: string[] +} + +export type OAuthProvider = Omit & { + id: string + name: string + authorizationUrl: string +} + +export type ApiToken = Omit & { + id: number + name: string + tokenPrefix: string + createdAt: string + expiresAt?: string + lastUsedAt?: string +} + +export type CreateTokenRequest = Omit & { + name: string + scopes?: string[] +} + +export type CreateTokenResponse = Omit & { + token: string + id: number + name: string + tokenPrefix: string + createdAt: string + expiresAt?: string +} export interface LocalLoginRequest { username: string @@ -36,6 +68,10 @@ export interface MergeVerifyRequest { verificationToken: string } +export interface MergeConfirmRequest { + mergeRequestId: number +} + // Namespace types export interface Namespace { id: number @@ -80,6 +116,9 @@ export interface SkillDetail { status: string downloadCount: number starCount: number + ratingAvg?: number + ratingCount: number + hidden: boolean latestVersion?: string namespace: string } @@ -135,3 +174,56 @@ export interface PublishResult { fileCount: number totalSize: number } + +export interface ReviewTask { + id: number + skillVersionId: number + namespace: string + skillSlug: string + version: string + status: 'PENDING' | 'APPROVED' | 'REJECTED' + submittedBy: string + submittedByName?: string + reviewedBy?: string + reviewedByName?: string + reviewComment?: string + submittedAt: string + reviewedAt?: string +} + +export interface PromotionTask { + id: number + sourceSkillId: number + sourceNamespace: string + sourceSkillSlug: string + sourceVersion: string + targetNamespace: string + targetSkillId?: number + status: 'PENDING' | 'APPROVED' | 'REJECTED' + submittedBy: string + submittedByName?: string + reviewedBy?: string + reviewedByName?: string + reviewComment?: string + submittedAt: string + reviewedAt?: string +} + +export interface AdminUser { + userId: string + username: string + email?: string + platformRoles: string[] + status: string + createdAt: string +} + +export interface AuditLogItem { + id: string + userId?: string + action: string + resourceType?: string + resourceId?: string + timestamp: string + ipAddress?: string +} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 93d445e00..788d8309c 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -27,8 +27,12 @@ const MySkillsPage = lazyRouteComponent(() => import('@/pages/dashboard/my-skill const PublishPage = lazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage') const MyNamespacesPage = lazyRouteComponent(() => import('@/pages/dashboard/my-namespaces'), 'MyNamespacesPage') const NamespaceMembersPage = lazyRouteComponent(() => import('@/pages/dashboard/namespace-members'), 'NamespaceMembersPage') +const NamespaceReviewsPage = lazyRouteComponent(() => import('@/pages/dashboard/namespace-reviews'), 'NamespaceReviewsPage') const ReviewsPage = lazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage') const ReviewDetailPage = lazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage') +const PromotionsPage = lazyRouteComponent(() => import('@/pages/dashboard/promotions'), 'PromotionsPage') +const MyStarsPage = lazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage') +const TokensPage = lazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage') const DeviceAuthPage = lazyRouteComponent(() => import('@/pages/device'), 'DeviceAuthPage') const SecuritySettingsPage = lazyRouteComponent(() => import('@/pages/settings/security'), 'SecuritySettingsPage') const AccountSettingsPage = lazyRouteComponent(() => import('@/pages/settings/accounts'), 'AccountSettingsPage') @@ -153,6 +157,19 @@ const dashboardNamespaceMembersRoute = createRoute({ component: NamespaceMembersPage, }) +const dashboardNamespaceReviewsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard/namespaces/$slug/reviews', + beforeLoad: async () => { + const user = await getCurrentUser() + if (!user) { + throw redirect({ to: '/login' }) + } + return { user } + }, + component: NamespaceReviewsPage, +}) + const dashboardReviewsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/dashboard/reviews', @@ -179,6 +196,48 @@ const dashboardReviewDetailRoute = createRoute({ component: ReviewDetailPage, }) +const dashboardPromotionsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard/promotions', + beforeLoad: async () => { + const user = await getCurrentUser() + if (!user) { + throw redirect({ to: '/login' }) + } + if (!user.platformRoles?.includes('SKILL_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) { + throw redirect({ to: '/dashboard' }) + } + return { user } + }, + component: PromotionsPage, +}) + +const dashboardStarsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard/stars', + beforeLoad: async () => { + const user = await getCurrentUser() + if (!user) { + throw redirect({ to: '/login' }) + } + return { user } + }, + component: MyStarsPage, +}) + +const dashboardTokensRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard/tokens', + beforeLoad: async () => { + const user = await getCurrentUser() + if (!user) { + throw redirect({ to: '/login' }) + } + return { user } + }, + component: TokensPage, +}) + const deviceRoute = createRoute({ getParentRoute: () => rootRoute, path: '/device', @@ -256,8 +315,12 @@ const routeTree = rootRoute.addChildren([ dashboardPublishRoute, dashboardNamespacesRoute, dashboardNamespaceMembersRoute, + dashboardNamespaceReviewsRoute, dashboardReviewsRoute, dashboardReviewDetailRoute, + dashboardPromotionsRoute, + dashboardStarsRoute, + dashboardTokensRoute, deviceRoute, settingsSecurityRoute, settingsAccountsRoute, diff --git a/web/src/features/admin/use-admin-users.ts b/web/src/features/admin/use-admin-users.ts index abc46b537..3f08b402e 100644 --- a/web/src/features/admin/use-admin-users.ts +++ b/web/src/features/admin/use-admin-users.ts @@ -1,14 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { fetchJson, getCsrfHeaders } from '@/api/client' - -export interface AdminUser { - id: string - username: string - email: string - status: 'ACTIVE' | 'DISABLED' - platformRoles: string[] - createdAt: string -} +import { adminApi } from '@/api/client' +import type { AdminUser } from '@/api/types' +export type { AdminUser } from '@/api/types' export interface AdminUsersParams { search?: string @@ -25,30 +18,15 @@ export interface PagedAdminUsers { } async function getAdminUsers(params: AdminUsersParams): Promise { - const searchParams = new URLSearchParams() - if (params.search) searchParams.set('search', params.search) - if (params.status) searchParams.set('status', params.status) - searchParams.set('page', String(params.page ?? 0)) - searchParams.set('size', String(params.size ?? 20)) - - const url = `/api/v1/admin/users?${searchParams.toString()}` - return fetchJson(url) + return adminApi.getUsers(params) } async function updateUserRole(userId: string, role: string): Promise { - await fetchJson(`/api/v1/admin/users/${userId}/role`, { - method: 'PUT', - headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ role }), - }) + await adminApi.updateUserRole(userId, role) } async function updateUserStatus(userId: string, status: 'ACTIVE' | 'DISABLED'): Promise { - await fetchJson(`/api/v1/admin/users/${userId}/status`, { - method: 'PUT', - headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ status }), - }) + await adminApi.updateUserStatus(userId, status) } export function useAdminUsers(params: AdminUsersParams) { @@ -79,3 +57,33 @@ export function useUpdateUserStatus() { }, }) } + +export function useApproveUser() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.approveUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} + +export function useDisableUser() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.disableUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} + +export function useEnableUser() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.enableUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} diff --git a/web/src/features/admin/use-audit-log.ts b/web/src/features/admin/use-audit-log.ts index 7a2a3649b..e398aca4f 100644 --- a/web/src/features/admin/use-audit-log.ts +++ b/web/src/features/admin/use-audit-log.ts @@ -1,15 +1,6 @@ import { useQuery } from '@tanstack/react-query' -import { fetchJson } from '@/api/client' - -export interface AuditLog { - id: number - action: string - userId: string - username?: string - details?: string - ipAddress?: string - timestamp: string -} +import { adminApi } from '@/api/client' +import type { AuditLogItem } from '@/api/types' export interface AuditLogParams { action?: string @@ -19,21 +10,14 @@ export interface AuditLogParams { } export interface PagedAuditLogs { - items: AuditLog[] + items: AuditLogItem[] total: number page: number size: number } async function getAuditLogs(params: AuditLogParams): Promise { - const searchParams = new URLSearchParams() - if (params.action) searchParams.set('action', params.action) - if (params.userId) searchParams.set('userId', params.userId) - searchParams.set('page', String(params.page ?? 0)) - searchParams.set('size', String(params.size ?? 20)) - - const url = `/api/v1/admin/audit-logs?${searchParams.toString()}` - return fetchJson(url) + return adminApi.getAuditLogs(params) } export function useAuditLog(params: AuditLogParams) { diff --git a/web/src/features/auth/use-account-merge.ts b/web/src/features/auth/use-account-merge.ts index a96b39525..242443848 100644 --- a/web/src/features/auth/use-account-merge.ts +++ b/web/src/features/auth/use-account-merge.ts @@ -1,6 +1,6 @@ import { useMutation } from '@tanstack/react-query' import { accountApi } from '@/api/client' -import type { MergeInitiateRequest, MergeVerifyRequest } from '@/api/types' +import type { MergeConfirmRequest, MergeInitiateRequest, MergeVerifyRequest } from '@/api/types' export function useInitiateAccountMerge() { return useMutation({ @@ -13,3 +13,9 @@ export function useVerifyAccountMerge() { mutationFn: (request: MergeVerifyRequest) => accountApi.verifyMerge(request), }) } + +export function useConfirmAccountMerge() { + return useMutation({ + mutationFn: (request: MergeConfirmRequest) => accountApi.confirmMerge(request), + }) +} diff --git a/web/src/features/promotion/use-promotion-list.ts b/web/src/features/promotion/use-promotion-list.ts new file mode 100644 index 000000000..f7521a7d3 --- /dev/null +++ b/web/src/features/promotion/use-promotion-list.ts @@ -0,0 +1,43 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { promotionApi } from '@/api/client' +import type { PromotionTask } from '@/api/types' + +export function usePromotionList(status = 'PENDING') { + return useQuery({ + queryKey: ['promotions', status], + queryFn: async () => { + const page = await promotionApi.list({ status }) + return page.items + }, + }) +} + +export function usePromotionDetail(id: number) { + return useQuery({ + queryKey: ['promotions', id], + queryFn: () => promotionApi.get(id), + enabled: !!id, + }) +} + +export function useApprovePromotion() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, comment }: { id: number; comment?: string }) => promotionApi.approve(id, comment), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['promotions'] }) + }, + }) +} + +export function useRejectPromotion() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, comment }: { id: number; comment?: string }) => promotionApi.reject(id, comment), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['promotions'] }) + }, + }) +} + +export type { PromotionTask } diff --git a/web/src/features/review/use-review-detail.ts b/web/src/features/review/use-review-detail.ts index aab7638f2..5a70738b1 100644 --- a/web/src/features/review/use-review-detail.ts +++ b/web/src/features/review/use-review-detail.ts @@ -1,29 +1,17 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { fetchJson, getCsrfHeaders } from '@/api/client' -import type { ReviewTask } from './use-review-list' +import { reviewApi } from '@/api/client' +import type { ReviewTask } from '@/api/types' async function getReviewDetail(taskId: number): Promise { - return fetchJson(`/api/v1/reviews/${taskId}`) + return reviewApi.get(taskId) } async function approveReview(taskId: number, comment?: string): Promise { - await fetchJson(`/api/v1/reviews/${taskId}/approve`, { - method: 'POST', - headers: getCsrfHeaders({ - 'Content-Type': 'application/json', - }), - body: JSON.stringify({ comment }), - }) + await reviewApi.approve(taskId, comment) } async function rejectReview(taskId: number, comment: string): Promise { - await fetchJson(`/api/v1/reviews/${taskId}/reject`, { - method: 'POST', - headers: getCsrfHeaders({ - 'Content-Type': 'application/json', - }), - body: JSON.stringify({ comment }), - }) + await reviewApi.reject(taskId, comment) } export function useReviewDetail(taskId: number) { diff --git a/web/src/features/review/use-review-list.ts b/web/src/features/review/use-review-list.ts index a0cef6fd4..c0e846fea 100644 --- a/web/src/features/review/use-review-list.ts +++ b/web/src/features/review/use-review-list.ts @@ -1,29 +1,16 @@ import { useQuery } from '@tanstack/react-query' -import { fetchJson } from '@/api/client' +import { reviewApi } from '@/api/client' +import type { ReviewTask } from '@/api/types' -export interface ReviewTask { - id: number - skillVersionId: number - skillName: string - skillSlug: string - namespace: string - version: string - status: 'PENDING' | 'APPROVED' | 'REJECTED' - submittedBy: string - submittedAt: string - reviewedBy?: string - reviewedAt?: string - comment?: string +async function getReviewList(status: string, namespaceId?: number): Promise { + const page = await reviewApi.list({ status, namespaceId }) + return page.items } -async function getReviewList(status?: string): Promise { - const url = status ? `/api/v1/reviews?status=${status}` : '/api/v1/reviews' - return fetchJson(url) -} - -export function useReviewList(status?: string) { +export function useReviewList(status: string, namespaceId?: number) { return useQuery({ - queryKey: ['reviews', status], - queryFn: () => getReviewList(status), + queryKey: ['reviews', status, namespaceId], + queryFn: () => getReviewList(status, namespaceId), + enabled: namespaceId === undefined || namespaceId > 0, }) } diff --git a/web/src/features/social/rating-input.tsx b/web/src/features/social/rating-input.tsx index e4245d653..4239a40b1 100644 --- a/web/src/features/social/rating-input.tsx +++ b/web/src/features/social/rating-input.tsx @@ -1,19 +1,26 @@ import { useState } from 'react' import { Star } from 'lucide-react' import { useUserRating, useRate } from './use-rating' +import { useAuth } from '@/features/auth/use-auth' interface RatingInputProps { skillId: number + onRequireLogin?: () => void } -export function RatingInput({ skillId }: RatingInputProps) { +export function RatingInput({ skillId, onRequireLogin }: RatingInputProps) { const { data: userRating, isLoading } = useUserRating(skillId) const rateMutation = useRate(skillId) + const { isAuthenticated } = useAuth() const [hoveredRating, setHoveredRating] = useState(null) - const currentRating = userRating?.rating || 0 + const currentRating = userRating?.rated ? userRating.score : 0 const handleRate = (rating: number) => { + if (!isAuthenticated) { + onRequireLogin?.() + return + } rateMutation.mutate(rating) } diff --git a/web/src/features/social/star-button.tsx b/web/src/features/social/star-button.tsx index 6424fddc1..e6767d0a0 100644 --- a/web/src/features/social/star-button.tsx +++ b/web/src/features/social/star-button.tsx @@ -1,16 +1,24 @@ import { Button } from '@/shared/ui/button' import { useStar, useToggleStar } from './use-star' import { Star } from 'lucide-react' +import { useAuth } from '@/features/auth/use-auth' interface StarButtonProps { skillId: number + starCount: number + onRequireLogin?: () => void } -export function StarButton({ skillId }: StarButtonProps) { +export function StarButton({ skillId, starCount, onRequireLogin }: StarButtonProps) { const { data: starStatus, isLoading } = useStar(skillId) const toggleMutation = useToggleStar(skillId) + const { isAuthenticated } = useAuth() const handleToggle = () => { + if (!isAuthenticated) { + onRequireLogin?.() + return + } if (starStatus) { toggleMutation.mutate(starStatus.starred) } @@ -28,7 +36,7 @@ export function StarButton({ skillId }: StarButtonProps) { disabled={toggleMutation.isPending} > - {starStatus.starred ? '已收藏' : '收藏'} ({starStatus.starCount}) + {starStatus.starred ? '已收藏' : '收藏'} ({starCount}) ) } diff --git a/web/src/features/social/use-rating.ts b/web/src/features/social/use-rating.ts index b97d33e22..403c905cc 100644 --- a/web/src/features/social/use-rating.ts +++ b/web/src/features/social/use-rating.ts @@ -2,12 +2,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { fetchJson, getCsrfHeaders } from '@/api/client' interface UserRating { - rating?: number - ratedAt?: string + score: number + rated: boolean } async function getUserRating(skillId: number): Promise { - return fetchJson(`/api/v1/skills/${skillId}/rating`) + try { + return await fetchJson(`/api/v1/skills/${skillId}/rating`) + } catch (error) { + if (error instanceof Error && error.message === 'HTTP 401') { + return { score: 0, rated: false } + } + throw error + } } async function rateSkill(skillId: number, rating: number): Promise { @@ -16,7 +23,7 @@ async function rateSkill(skillId: number, rating: number): Promise { headers: getCsrfHeaders({ 'Content-Type': 'application/json', }), - body: JSON.stringify({ rating }), + body: JSON.stringify({ score: rating }), }) } diff --git a/web/src/features/social/use-star.ts b/web/src/features/social/use-star.ts index 896101078..3e9aed972 100644 --- a/web/src/features/social/use-star.ts +++ b/web/src/features/social/use-star.ts @@ -3,11 +3,18 @@ import { fetchJson, getCsrfHeaders } from '@/api/client' interface StarStatus { starred: boolean - starCount: number } async function getStarStatus(skillId: number): Promise { - return fetchJson(`/api/v1/skills/${skillId}/star`) + try { + const starred = await fetchJson(`/api/v1/skills/${skillId}/star`) + return { starred } + } catch (error) { + if (error instanceof Error && error.message === 'HTTP 401') { + return { starred: false } + } + throw error + } } async function toggleStar(skillId: number, starred: boolean): Promise { diff --git a/web/src/features/token/token-list.tsx b/web/src/features/token/token-list.tsx index a73492bbe..1b2787d6f 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -33,7 +33,7 @@ export function TokenList() { } } - const formatDate = (dateString: string | null) => { + const formatDate = (dateString?: string | null) => { if (!dateString) return '-' return new Date(dateString).toLocaleString('zh-CN') } diff --git a/web/src/pages/admin/audit-log.tsx b/web/src/pages/admin/audit-log.tsx index cca6a177d..07fe92ef0 100644 --- a/web/src/pages/admin/audit-log.tsx +++ b/web/src/pages/admin/audit-log.tsx @@ -40,12 +40,12 @@ export function AuditLogPage() {
{formatDate(log.timestamp)} {log.action} - {log.userId} - {log.username || '-'} + {log.userId || '-'} + {log.resourceType || '-'} {log.ipAddress || '-'} - {log.details || '-'} + {log.resourceId || '-'} ))} diff --git a/web/src/pages/admin/users.tsx b/web/src/pages/admin/users.tsx index 099d67755..77dcee1c4 100644 --- a/web/src/pages/admin/users.tsx +++ b/web/src/pages/admin/users.tsx @@ -20,7 +20,7 @@ import { DialogTitle, } from '@/shared/ui/dialog' import { Label } from '@/shared/ui/label' -import { useAdminUsers, useUpdateUserRole, useUpdateUserStatus } from '@/features/admin/use-admin-users' +import { useAdminUsers, useApproveUser, useDisableUser, useEnableUser, useUpdateUserRole } from '@/features/admin/use-admin-users' import type { AdminUser } from '@/features/admin/use-admin-users' export function AdminUsersPage() { @@ -41,7 +41,9 @@ export function AdminUsersPage() { }) const updateRoleMutation = useUpdateUserRole() - const updateStatusMutation = useUpdateUserStatus() + const approveUserMutation = useApproveUser() + const disableUserMutation = useDisableUser() + const enableUserMutation = useEnableUser() const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString('zh-CN') @@ -62,7 +64,7 @@ export function AdminUsersPage() { const confirmRoleChange = async () => { if (!selectedUser) return try { - await updateRoleMutation.mutateAsync({ userId: selectedUser.id, role: newRole }) + await updateRoleMutation.mutateAsync({ userId: selectedUser.userId, role: newRole }) setRoleDialogOpen(false) setSelectedUser(null) } catch (error) { @@ -73,8 +75,11 @@ export function AdminUsersPage() { const confirmStatusChange = async () => { if (!selectedUser) return try { - const newStatus = actionType === 'ban' ? 'DISABLED' : 'ACTIVE' - await updateStatusMutation.mutateAsync({ userId: selectedUser.id, status: newStatus }) + if (actionType === 'ban') { + await disableUserMutation.mutateAsync(selectedUser.userId) + } else { + await enableUserMutation.mutateAsync(selectedUser.userId) + } setConfirmDialogOpen(false) setSelectedUser(null) } catch (error) { @@ -100,6 +105,7 @@ export function AdminUsersPage() {
@@ -131,18 +137,20 @@ export function AdminUsersPage() { {data.items.map((user) => ( - + {user.username} - {user.email} + {user.email || '-'} - {user.status === 'ACTIVE' ? '活跃' : '已禁用'} + {user.status === 'ACTIVE' ? '活跃' : user.status === 'PENDING' ? '待审批' : '已禁用'} {user.platformRoles.join(', ')} @@ -156,6 +164,15 @@ export function AdminUsersPage() { > 修改角色 + {user.status === 'PENDING' && ( + + )} {user.status === 'ACTIVE' ? ( - diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index b4358bfbd..768b413f2 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -1,3 +1,4 @@ +import { Link } from '@tanstack/react-router' import { useAuth } from '@/features/auth/use-auth' import { TokenList } from '@/features/token/token-list' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -41,7 +42,7 @@ export function DashboardPage() {
平台角色
- {user.platformRoles.map((role) => ( + {user.platformRoles.map((role: string) => ( +
+ +
收藏与评分
+ + 查看我的收藏 + +
+ +
访问凭证
+ + 打开 Token 页面 + +
+ +
审核与治理
+ + 查看提升审核 + +
+
+
) diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index d95d7e7b4..833cf0fb6 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -18,6 +18,11 @@ export function MyNamespacesPage() { navigate({ to: '/dashboard/namespaces/$slug/members', params: { slug } }) } + const handleReviewsClick = (slug: string, e: React.MouseEvent) => { + e.stopPropagation() + navigate({ to: '/dashboard/namespaces/$slug/reviews', params: { slug } }) + } + if (isLoading) { return (
@@ -66,15 +71,24 @@ export function MyNamespacesPage() {
@{namespace.slug}
- {namespace.type === 'TEAM' && ( +
+ {namespace.type === 'TEAM' && ( + + )} - )} +
))} diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx new file mode 100644 index 000000000..c420b6c61 --- /dev/null +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -0,0 +1,65 @@ +import { useParams } from '@tanstack/react-router' +import { Card } from '@/shared/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +import { useNamespaceDetail } from '@/shared/hooks/use-skill-queries' +import { useReviewList } from '@/features/review/use-review-list' + +function ReviewListSection({ namespaceId }: { namespaceId?: number }) { + const { data: pending } = useReviewList('PENDING', namespaceId) + const { data: approved } = useReviewList('APPROVED', namespaceId) + const { data: rejected } = useReviewList('REJECTED', namespaceId) + + const renderItems = (items?: typeof pending) => { + if (!items || items.length === 0) { + return 暂无审核记录 + } + return ( + + {items.map((review) => ( +
+
+
+
{review.namespace}/{review.skillSlug}
+
版本 {review.version}
+
+
{new Date(review.submittedAt).toLocaleString('zh-CN')}
+
+ {review.reviewComment ? ( +

{review.reviewComment}

+ ) : null} +
+ ))} +
+ ) + } + + return ( + + + 待审核 + 已通过 + 已拒绝 + + {renderItems(pending)} + {renderItems(approved)} + {renderItems(rejected)} + + ) +} + +export function NamespaceReviewsPage() { + const { slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews' }) + const { data: namespace } = useNamespaceDetail(slug) + + return ( +
+
+

命名空间审核

+

+ {namespace ? `${namespace.displayName} 的审核任务` : '加载命名空间信息中'} +

+
+ +
+ ) +} diff --git a/web/src/pages/dashboard/promotions.tsx b/web/src/pages/dashboard/promotions.tsx new file mode 100644 index 000000000..469047de6 --- /dev/null +++ b/web/src/pages/dashboard/promotions.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react' +import { useApprovePromotion, usePromotionList, useRejectPromotion } from '@/features/promotion/use-promotion-list' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' + +function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECTED' }) { + const { data: items, isLoading } = usePromotionList(status) + const approveMutation = useApprovePromotion() + const rejectMutation = useRejectPromotion() + const [commentById, setCommentById] = useState>({}) + + if (isLoading) { + return
+ } + + if (!items || items.length === 0) { + return 暂无提升申请 + } + + return ( +
+ {items.map((item) => ( + +
+
+
{item.sourceNamespace}/{item.sourceSkillSlug}
+
+ {item.sourceVersion} {'->'} @{item.targetNamespace} +
+
+
{new Date(item.submittedAt).toLocaleString('zh-CN')}
+
+ {status === 'PENDING' ? ( + <> + setCommentById((prev) => ({ ...prev, [item.id]: event.target.value }))} + /> +
+ + +
+ + ) : item.reviewComment ? ( +

{item.reviewComment}

+ ) : null} +
+ ))} +
+ ) +} + +export function PromotionsPage() { + return ( +
+
+

提升审核

+

审核团队技能提升到全局空间的申请

+
+ + + 待审核 + 已通过 + 已拒绝 + + + + + +
+ ) +} diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index 4631b8460..3d74e10a0 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -82,11 +82,7 @@ export function ReviewDetailPage() {
-
-
- -

{review.skillName}

-
+

{review.namespace}/{review.skillSlug}

@@ -115,7 +111,7 @@ export function ReviewDetailPage() {
-

{review.submittedBy}

+

{review.submittedByName || review.submittedBy}

@@ -125,7 +121,7 @@ export function ReviewDetailPage() { <>
-

{review.reviewedBy}

+

{review.reviewedByName || review.reviewedBy}

@@ -137,10 +133,10 @@ export function ReviewDetailPage() { )}
- {review.comment && ( + {review.reviewComment && (
-

{review.comment}

+

{review.reviewComment}

)} diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 11ef11443..edcd733d9 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -72,10 +72,10 @@ export function ReviewsPage() { {review.version} - {review.submittedBy} + {review.submittedByName || review.submittedBy} {formatDate(review.submittedAt)} {status !== 'PENDING' && ( - {review.reviewedBy || '—'} + {review.reviewedByName || review.reviewedBy || '—'} )} {status !== 'PENDING' && ( diff --git a/web/src/pages/dashboard/stars.tsx b/web/src/pages/dashboard/stars.tsx new file mode 100644 index 000000000..02c4628a9 --- /dev/null +++ b/web/src/pages/dashboard/stars.tsx @@ -0,0 +1,42 @@ +import { useNavigate } from '@tanstack/react-router' +import { SkillCard } from '@/features/skill/skill-card' +import { useMyStars } from '@/shared/hooks/use-skill-queries' +import { Card } from '@/shared/ui/card' + +export function MyStarsPage() { + const navigate = useNavigate() + const { data: skills, isLoading } = useMyStars() + + if (isLoading) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) + } + + return ( +
+
+

我的收藏

+

查看你标记过的技能

+
+ + {!skills || skills.length === 0 ? ( + 还没有收藏任何技能 + ) : ( +
+ {skills.map((skill) => ( + navigate({ to: '/@$namespace/$slug', params: { namespace: skill.namespace, slug: skill.slug } })} + /> + ))} +
+ )} +
+ ) +} diff --git a/web/src/pages/dashboard/tokens.tsx b/web/src/pages/dashboard/tokens.tsx new file mode 100644 index 000000000..dea5f9caf --- /dev/null +++ b/web/src/pages/dashboard/tokens.tsx @@ -0,0 +1,13 @@ +import { TokenList } from '@/features/token/token-list' + +export function TokensPage() { + return ( +
+
+

Token 管理

+

管理 CLI 和 API 使用的访问凭证

+
+ +
+ ) +} diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx index ec881f157..6bb2ba900 100644 --- a/web/src/pages/register.tsx +++ b/web/src/pages/register.tsx @@ -1,9 +1,11 @@ import { Link } from '@tanstack/react-router' import { useState } from 'react' +import { LoginButton } from '@/features/auth/login-button' import { useLocalRegister } from '@/features/auth/use-local-auth' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' export function RegisterPage() { const registerMutation = useLocalRegister() @@ -26,56 +28,72 @@ export function RegisterPage() { 创建账号 - 注册后会自动建立本地会话,可继续进入 Dashboard。 + 支持本地注册,也可以直接使用 OAuth 登录进入平台。 -
-
- - setUsername(event.target.value)} - placeholder="3-64 位字母、数字或下划线" - /> -
-
- - setEmail(event.target.value)} - placeholder="可选,用于后续账号识别" - /> -
-
- - setPassword(event.target.value)} - placeholder="至少 8 位,包含 3 种字符类型" - /> -
- {registerMutation.error ? ( -

{registerMutation.error.message}

- ) : null} - -

- 已有账号? - {' '} - - 返回登录 - -

-
+ + + 本地账号 + OAuth + + + +
+
+ + setUsername(event.target.value)} + placeholder="3-64 位字母、数字或下划线" + /> +
+
+ + setEmail(event.target.value)} + placeholder="可选,用于后续账号识别" + /> +
+
+ + setPassword(event.target.value)} + placeholder="至少 8 位,包含 3 种字符类型" + /> +
+ {registerMutation.error ? ( +

{registerMutation.error.message}

+ ) : null} + +

+ 已有账号? + {' '} + + 返回登录 + +

+
+
+ + +

+ 直接使用现有 OAuth 账户进入平台,无需再创建本地密码。 +

+ +
+
diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx index d143fa327..41f711ce4 100644 --- a/web/src/pages/settings/accounts.tsx +++ b/web/src/pages/settings/accounts.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' +import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' @@ -12,6 +12,7 @@ export function AccountSettingsPage() { const initiateMutation = useInitiateAccountMerge() const verifyMutation = useVerifyAccountMerge() + const confirmMutation = useConfirmAccountMerge() async function handleInitiate(event: React.FormEvent) { event.preventDefault() @@ -34,12 +35,22 @@ export function AccountSettingsPage() { mergeRequestId: Number(mergeRequestId), verificationToken, }) - setStatusMessage('账号合并已完成') + setStatusMessage('验证成功,确认后将执行正式合并') } catch (error) { setStatusMessage(error instanceof Error ? error.message : '验证合并失败') } } + async function handleConfirm() { + setStatusMessage('') + try { + await confirmMutation.mutateAsync({ mergeRequestId: Number(mergeRequestId) }) + setStatusMessage('账号合并已完成') + } catch (error) { + setStatusMessage(error instanceof Error ? error.message : '确认合并失败') + } + } + return (
@@ -68,7 +79,7 @@ export function AccountSettingsPage() { 验证并完成合并 - 发起后会返回一次性 token;在当前阶段直接复制该 token 完成验证。 + 先完成 token 验证,再单独确认执行数据迁移。
@@ -92,6 +103,11 @@ export function AccountSettingsPage() { {verifyMutation.isPending ? '验证中...' : '完成合并'}
+
+ +
{statusMessage ?

{statusMessage}

: null}
diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index f49139e81..dd2011888 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -1,7 +1,12 @@ -import { useParams } from '@tanstack/react-router' +import { useParams, useNavigate } from '@tanstack/react-router' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' import { FileTree } from '@/features/skill/file-tree' import { InstallCommand } from '@/features/skill/install-command' +import { RatingInput } from '@/features/social/rating-input' +import { StarButton } from '@/features/social/star-button' +import { useAuth } from '@/features/auth/use-auth' +import { adminApi } from '@/api/client' import { NamespaceBadge } from '@/shared/components/namespace-badge' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs' import { Button } from '@/shared/ui/button' @@ -14,13 +19,42 @@ import { } from '@/shared/hooks/use-skill-queries' export function SkillDetailPage() { + const navigate = useNavigate() + const queryClient = useQueryClient() const { namespace, slug } = useParams({ from: '/@$namespace/$slug' }) + const { user, hasRole } = useAuth() const { data: skill, isLoading: isLoadingSkill } = useSkillDetail(namespace, slug) const { data: versions } = useSkillVersions(namespace, slug) const latestVersion = versions?.[0] const { data: files } = useSkillFiles(namespace, slug, latestVersion?.version) const { data: readme } = useSkillReadme(namespace, slug, latestVersion?.version) + const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') + + const refreshSkill = () => { + queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] }) + queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug, 'versions'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + } + + const hideMutation = useMutation({ + mutationFn: () => adminApi.hideSkill(skill!.id), + onSuccess: refreshSkill, + }) + + const unhideMutation = useMutation({ + mutationFn: () => adminApi.unhideSkill(skill!.id), + onSuccess: refreshSkill, + }) + + const yankMutation = useMutation({ + mutationFn: () => adminApi.yankVersion(latestVersion!.id), + onSuccess: refreshSkill, + }) + + const requireLogin = () => { + navigate({ to: '/login' }) + } if (isLoadingSkill) { return ( @@ -138,10 +172,29 @@ export function SkillDetailPage() {
+
+
评分
+
+ {skill.ratingCount > 0 && skill.ratingAvg !== undefined ? `${skill.ratingAvg.toFixed(1)} / 5` : '暂无'} +
+
+ +
+
命名空间
+ +
+ +
+ + + {!user && ( +

登录后可以收藏和评分

+ )} +
{skill.latestVersion && ( @@ -161,6 +214,28 @@ export function SkillDetailPage() { 下载 + + {governanceVisible && ( + +
治理操作
+
+ {!skill.hidden ? ( + + ) : ( + + )} + {latestVersion && ( + + )} +
+
+ )}
) diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 70b321477..a5124c6b1 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import type { SkillSummary, SkillDetail, SkillVersion, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types' -import { fetchJson, fetchText, getCsrfHeaders } from '@/api/client' +import { fetchJson, fetchText, getCsrfHeaders, meApi } from '@/api/client' async function searchSkills(params: SearchParams): Promise> { const queryParams = new URLSearchParams() @@ -38,6 +38,10 @@ async function getMySkills(): Promise { return fetchJson('/api/v1/me/skills') } +async function getMyStars(): Promise { + return meApi.getStars() +} + async function getMyNamespaces(): Promise { const page = await fetchJson>('/api/v1/namespaces') return page.items @@ -111,6 +115,13 @@ export function useMySkills() { }) } +export function useMyStars() { + return useQuery({ + queryKey: ['skills', 'stars'], + queryFn: getMyStars, + }) +} + export function useMyNamespaces() { return useQuery({ queryKey: ['namespaces', 'my'], From 3c36f2dc8c308844fbe66ddea022eba6d65e65e4 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:37:22 +0800 Subject: [PATCH 019/313] fix(web): prevent landing page from matching nested routes --- web/src/app/layout.tsx | 7 ++----- web/src/app/router.tsx | 8 -------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index f2478a8b3..ff1e4814a 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react' import { Outlet, Link, useRouterState } from '@tanstack/react-router' import { useAuth } from '@/features/auth/use-auth' +import { LandingPage } from '@/pages/landing' export function Layout() { const { user, isLoading } = useAuth() @@ -8,11 +9,7 @@ export function Layout() { const isLanding = pathname === '/' if (isLanding) { - return ( - - - - ) + return } return ( diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 788d8309c..c6576ba84 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -15,7 +15,6 @@ function lazyRouteComponent>( return LazyComponent } -const LandingPage = lazyRouteComponent(() => import('@/pages/landing'), 'LandingPage') const HomePage = lazyRouteComponent(() => import('@/pages/home'), 'HomePage') const LoginPage = lazyRouteComponent(() => import('@/pages/login'), 'LoginPage') const RegisterPage = lazyRouteComponent(() => import('@/pages/register'), 'RegisterPage') @@ -43,12 +42,6 @@ const rootRoute = createRootRoute({ component: Layout, }) -const homeRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: LandingPage, -}) - const skillsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/skills', @@ -303,7 +296,6 @@ const adminAuditLogRoute = createRoute({ }) const routeTree = rootRoute.addChildren([ - homeRoute, skillsRoute, loginRoute, registerRoute, From da164d63d800d27e15cedb3dd86434ea685333eb Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:41:02 +0800 Subject: [PATCH 020/313] fix(web): normalize protected route matching --- web/src/app/router.tsx | 163 ++++++++++++----------------------------- 1 file changed, 45 insertions(+), 118 deletions(-) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index c6576ba84..63ec7c542 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -42,27 +42,35 @@ const rootRoute = createRootRoute({ component: Layout, }) +async function requireAuth() { + const user = await getCurrentUser() + if (!user) { + throw redirect({ to: '/login' }) + } + return { user } +} + const skillsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/skills', + path: 'skills', component: HomePage, }) const loginRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/login', + path: 'login', component: LoginPage, }) const registerRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/register', + path: 'register', component: RegisterPage, }) const searchRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/search', + path: 'search', component: SearchPage, validateSearch: (search: Record) => { return { @@ -75,128 +83,77 @@ const searchRoute = createRoute({ const namespaceRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/@$namespace', + path: '@$namespace', component: NamespacePage, }) const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/@$namespace/$slug', + path: '@$namespace/$slug', component: SkillDetailPage, }) const dashboardRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard', + beforeLoad: requireAuth, component: DashboardPage, }) const dashboardSkillsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/skills', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/skills', + beforeLoad: requireAuth, component: MySkillsPage, }) const dashboardPublishRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/publish', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/publish', + beforeLoad: requireAuth, component: PublishPage, }) const dashboardNamespacesRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/namespaces', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/namespaces', + beforeLoad: requireAuth, component: MyNamespacesPage, }) const dashboardNamespaceMembersRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/namespaces/$slug/members', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/namespaces/$slug/members', + beforeLoad: requireAuth, component: NamespaceMembersPage, }) const dashboardNamespaceReviewsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/namespaces/$slug/reviews', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/namespaces/$slug/reviews', + beforeLoad: requireAuth, component: NamespaceReviewsPage, }) const dashboardReviewsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/reviews', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/reviews', + beforeLoad: requireAuth, component: ReviewsPage, }) const dashboardReviewDetailRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/reviews/$id', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/reviews/$id', + beforeLoad: requireAuth, component: ReviewDetailPage, }) const dashboardPromotionsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/promotions', + path: 'dashboard/promotions', beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } + const { user } = await requireAuth() if (!user.platformRoles?.includes('SKILL_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) { throw redirect({ to: '/dashboard' }) } @@ -207,70 +164,43 @@ const dashboardPromotionsRoute = createRoute({ const dashboardStarsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/stars', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/stars', + beforeLoad: requireAuth, component: MyStarsPage, }) const dashboardTokensRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/tokens', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/tokens', + beforeLoad: requireAuth, component: TokensPage, }) const deviceRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/device', + path: 'device', component: DeviceAuthPage, }) const settingsSecurityRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/settings/security', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'settings/security', + beforeLoad: requireAuth, component: SecuritySettingsPage, }) const settingsAccountsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/settings/accounts', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'settings/accounts', + beforeLoad: requireAuth, component: AccountSettingsPage, }) const adminUsersRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/admin/users', + path: 'admin/users', beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } + const { user } = await requireAuth() if (!user.platformRoles?.includes('USER_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) { throw redirect({ to: '/dashboard' }) } @@ -281,12 +211,9 @@ const adminUsersRoute = createRoute({ const adminAuditLogRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/admin/audit-log', + path: 'admin/audit-log', beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } + const { user } = await requireAuth() if (!user.platformRoles?.includes('AUDITOR') && !user.platformRoles?.includes('SUPER_ADMIN')) { throw redirect({ to: '/dashboard' }) } From 23e7de4f8da7fe70154804b5164b74ac1849b075 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:43:14 +0800 Subject: [PATCH 021/313] fix(web): redirect unauthenticated users back after login --- web/src/app/layout.tsx | 1 + web/src/app/router.tsx | 29 +++++++++++++++++++++-------- web/src/pages/login.tsx | 14 +++++++++++--- web/src/pages/register.tsx | 14 +++++++++++--- web/src/pages/skill-detail.tsx | 10 ++++++++-- 5 files changed, 52 insertions(+), 16 deletions(-) diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index ff1e4814a..e541adcfa 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -71,6 +71,7 @@ export function Layout() { ) : ( diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 63ec7c542..50750aed8 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -42,10 +42,17 @@ const rootRoute = createRootRoute({ component: Layout, }) -async function requireAuth() { +function buildReturnTo(location: { pathname: string; searchStr?: string; hash?: string }) { + return `${location.pathname}${location.searchStr ?? ''}${location.hash ?? ''}` +} + +async function requireAuth({ location }: { location: { pathname: string; searchStr?: string; hash?: string } }) { const user = await getCurrentUser() if (!user) { - throw redirect({ to: '/login' }) + throw redirect({ + to: '/login', + search: { returnTo: buildReturnTo(location) }, + }) } return { user } } @@ -59,12 +66,18 @@ const skillsRoute = createRoute({ const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', + validateSearch: (search: Record) => ({ + returnTo: typeof search.returnTo === 'string' ? search.returnTo : '', + }), component: LoginPage, }) const registerRoute = createRoute({ getParentRoute: () => rootRoute, path: 'register', + validateSearch: (search: Record) => ({ + returnTo: typeof search.returnTo === 'string' ? search.returnTo : '', + }), component: RegisterPage, }) @@ -152,8 +165,8 @@ const dashboardReviewDetailRoute = createRoute({ const dashboardPromotionsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/promotions', - beforeLoad: async () => { - const { user } = await requireAuth() + beforeLoad: async (ctx) => { + const { user } = await requireAuth(ctx) if (!user.platformRoles?.includes('SKILL_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) { throw redirect({ to: '/dashboard' }) } @@ -199,8 +212,8 @@ const settingsAccountsRoute = createRoute({ const adminUsersRoute = createRoute({ getParentRoute: () => rootRoute, path: 'admin/users', - beforeLoad: async () => { - const { user } = await requireAuth() + beforeLoad: async (ctx) => { + const { user } = await requireAuth(ctx) if (!user.platformRoles?.includes('USER_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) { throw redirect({ to: '/dashboard' }) } @@ -212,8 +225,8 @@ const adminUsersRoute = createRoute({ const adminAuditLogRoute = createRoute({ getParentRoute: () => rootRoute, path: 'admin/audit-log', - beforeLoad: async () => { - const { user } = await requireAuth() + beforeLoad: async (ctx) => { + const { user } = await requireAuth(ctx) if (!user.platformRoles?.includes('AUDITOR') && !user.platformRoles?.includes('SUPER_ADMIN')) { throw redirect({ to: '/dashboard' }) } diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 8c639e1cd..f4fab058c 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -1,4 +1,4 @@ -import { Link } from '@tanstack/react-router' +import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { LoginButton } from '@/features/auth/login-button' import { useLocalLogin } from '@/features/auth/use-local-auth' @@ -7,15 +7,19 @@ import { Input } from '@/shared/ui/input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' export function LoginPage() { + const navigate = useNavigate() + const search = useSearch({ from: '/login' }) const loginMutation = useLocalLogin() const [username, setUsername] = useState('') const [password, setPassword] = useState('') + const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard' + async function handleSubmit(event: React.FormEvent) { event.preventDefault() try { await loginMutation.mutateAsync({ username, password }) - window.location.href = '/dashboard' + await navigate({ to: returnTo }) } catch { // mutation state drives the error UI } @@ -73,7 +77,11 @@ export function LoginPage() {

还没有账号? {' '} - + 立即注册

diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx index 6bb2ba900..c6e79f621 100644 --- a/web/src/pages/register.tsx +++ b/web/src/pages/register.tsx @@ -1,4 +1,4 @@ -import { Link } from '@tanstack/react-router' +import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { LoginButton } from '@/features/auth/login-button' import { useLocalRegister } from '@/features/auth/use-local-auth' @@ -8,16 +8,20 @@ import { Input } from '@/shared/ui/input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' export function RegisterPage() { + const navigate = useNavigate() + const search = useSearch({ from: '/register' }) const registerMutation = useLocalRegister() const [username, setUsername] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard' + async function handleSubmit(event: React.FormEvent) { event.preventDefault() try { await registerMutation.mutateAsync({ username, email, password }) - window.location.href = '/dashboard' + await navigate({ to: returnTo }) } catch { // mutation state drives the error UI } @@ -80,7 +84,11 @@ export function RegisterPage() {

已有账号? {' '} - + 返回登录

diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index dd2011888..1dc4b9953 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -1,4 +1,4 @@ -import { useParams, useNavigate } from '@tanstack/react-router' +import { useParams, useNavigate, useRouterState } from '@tanstack/react-router' import { useMutation, useQueryClient } from '@tanstack/react-query' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' import { FileTree } from '@/features/skill/file-tree' @@ -20,6 +20,7 @@ import { export function SkillDetailPage() { const navigate = useNavigate() + const location = useRouterState({ select: (s) => s.location }) const queryClient = useQueryClient() const { namespace, slug } = useParams({ from: '/@$namespace/$slug' }) const { user, hasRole } = useAuth() @@ -53,7 +54,12 @@ export function SkillDetailPage() { }) const requireLogin = () => { - navigate({ to: '/login' }) + navigate({ + to: '/login', + search: { + returnTo: `${location.pathname}${location.searchStr}${location.hash}`, + }, + }) } if (isLoadingSkill) { From c404069ea9fd4f632145928b2c08aaecd46c7e80 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:48:44 +0800 Subject: [PATCH 022/313] fix(ci): install pnpm before setup-node cache --- .github/workflows/validate-openapi.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/validate-openapi.yml b/.github/workflows/validate-openapi.yml index c912e0461..02eb44202 100644 --- a/.github/workflows/validate-openapi.yml +++ b/.github/workflows/validate-openapi.yml @@ -23,6 +23,11 @@ jobs: java-version: "21" cache: maven + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + - name: Set up Node.js uses: actions/setup-node@v4 with: @@ -30,11 +35,6 @@ jobs: cache: pnpm cache-dependency-path: web/pnpm-lock.yaml - - name: Set up pnpm - uses: pnpm/action-setup@v4 - with: - version: 10 - - name: Install frontend dependencies working-directory: web run: pnpm install --frozen-lockfile From 26a47a672bb315a89106a2811684d3f7e928768b Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:57:33 +0800 Subject: [PATCH 023/313] fix(auth): preserve return target across oauth login --- .../skillhub/controller/AuthController.java | 37 +++++++++-- .../controller/AuthControllerTest.java | 34 ++++++++++- .../skillhub/auth/config/SecurityConfig.java | 5 ++ .../auth/oauth/OAuth2LoginFailureHandler.java | 22 +++++++ .../auth/oauth/OAuth2LoginSuccessHandler.java | 18 +++++- .../auth/oauth/OAuthLoginRedirectSupport.java | 24 ++++++++ ...HubOAuth2AuthorizationRequestResolver.java | 44 +++++++++++++ ...Auth2AuthorizationRequestResolverTest.java | 57 +++++++++++++++++ .../auth/oauth/OAuth2LoginHandlersTest.java | 61 +++++++++++++++++++ web/src/api/client.ts | 7 ++- web/src/features/auth/login-button.tsx | 10 ++- web/src/pages/login.tsx | 2 +- web/src/pages/register.tsx | 2 +- 13 files changed, 309 insertions(+), 14 deletions(-) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java index ec8dfca9a..ae803aeb2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -5,20 +5,30 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.AuthMeResponse; import com.iflytek.skillhub.dto.AuthProviderResponse; +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import com.iflytek.skillhub.exception.UnauthorizedException; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; import java.util.List; @RestController @RequestMapping("/api/v1/auth") public class AuthController extends BaseApiController { - public AuthController(ApiResponseFactory responseFactory) { + private final OAuth2ClientProperties oAuth2ClientProperties; + + public AuthController(ApiResponseFactory responseFactory, + OAuth2ClientProperties oAuth2ClientProperties) { super(responseFactory); + this.oAuth2ClientProperties = oAuth2ClientProperties; } @GetMapping("/me") @@ -31,8 +41,27 @@ public ApiResponse me(@AuthenticationPrincipal PlatformPrincipal } @GetMapping("/providers") - public ApiResponse> providers() { - var github = new AuthProviderResponse("github", "GitHub", "/oauth2/authorization/github"); - return ok("response.success.read", List.of(github)); + public ApiResponse> providers( + @RequestParam(name = "returnTo", required = false) String returnTo) { + String sanitizedReturnTo = com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo); + List providers = new ArrayList<>(oAuth2ClientProperties.getRegistration().entrySet().stream() + .sorted(Comparator.comparing(entry -> entry.getKey())) + .map(entry -> new AuthProviderResponse( + entry.getKey(), + entry.getValue().getClientName() != null && !entry.getValue().getClientName().isBlank() + ? entry.getValue().getClientName() + : entry.getKey(), + buildAuthorizationUrl(entry.getKey(), sanitizedReturnTo) + )) + .toList()); + return ok("response.success.read", providers); + } + + private String buildAuthorizationUrl(String registrationId, String returnTo) { + String baseUrl = "/oauth2/authorization/" + registrationId; + if (returnTo == null) { + return baseUrl; + } + return baseUrl + "?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index 53d710bb7..3946cbdc2 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -10,11 +10,13 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import static org.hamcrest.Matchers.hasItems; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -25,6 +27,20 @@ @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.security.oauth2.client.registration.github.client-name=GitHub", + "spring.security.oauth2.client.registration.gitee.client-id=placeholder", + "spring.security.oauth2.client.registration.gitee.client-secret=placeholder", + "spring.security.oauth2.client.registration.gitee.provider=gitee", + "spring.security.oauth2.client.registration.gitee.authorization-grant-type=authorization_code", + "spring.security.oauth2.client.registration.gitee.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}", + "spring.security.oauth2.client.registration.gitee.scope=user_info", + "spring.security.oauth2.client.registration.gitee.client-name=Gitee", + "spring.security.oauth2.client.provider.gitee.authorization-uri=https://gitee.com/oauth/authorize", + "spring.security.oauth2.client.provider.gitee.token-uri=https://gitee.com/oauth/token", + "spring.security.oauth2.client.provider.gitee.user-info-uri=https://gitee.com/api/v5/user", + "spring.security.oauth2.client.provider.gitee.user-name-attribute=id" +}) class AuthControllerTest { @Autowired @@ -79,9 +95,23 @@ void providersShouldExposeGithubLoginEntry() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.msg").isNotEmpty()) - .andExpect(jsonPath("$.data[0].id").value("github")) - .andExpect(jsonPath("$.data[0].authorizationUrl").value("/oauth2/authorization/github")) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee"))) + .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( + "/oauth2/authorization/github", + "/oauth2/authorization/gitee" + ))) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } + + @Test + void providersShouldAppendReturnToWhenRequested() throws Exception { + mockMvc.perform(get("/api/v1/auth/providers").param("returnTo", "/dashboard/publish")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( + "/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish", + "/oauth2/authorization/gitee?returnTo=%2Fdashboard%2Fpublish" + ))); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 660e4c3c1..fbaceb91f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -3,6 +3,7 @@ import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService; import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler; import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; +import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; import com.iflytek.skillhub.auth.mock.MockAuthFilter; import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter; import org.springframework.beans.factory.ObjectProvider; @@ -30,6 +31,7 @@ public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; + private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver; private final OAuth2LoginSuccessHandler successHandler; private final OAuth2LoginFailureHandler failureHandler; private final ApiTokenAuthenticationFilter apiTokenAuthenticationFilter; @@ -38,6 +40,7 @@ public class SecurityConfig { private final ObjectProvider mockAuthFilterProvider; public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, + SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver, OAuth2LoginSuccessHandler successHandler, OAuth2LoginFailureHandler failureHandler, ApiTokenAuthenticationFilter apiTokenAuthenticationFilter, @@ -45,6 +48,7 @@ public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, AccessDeniedHandler apiAccessDeniedHandler, ObjectProvider mockAuthFilterProvider) { this.customOAuth2UserService = customOAuth2UserService; + this.authorizationRequestResolver = authorizationRequestResolver; this.successHandler = successHandler; this.failureHandler = failureHandler; this.apiTokenAuthenticationFilter = apiTokenAuthenticationFilter; @@ -101,6 +105,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 + .authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver)) .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) .successHandler(successHandler) .failureHandler(failureHandler) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java index 71b90d017..4f18a23a9 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java @@ -3,11 +3,14 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; @Component public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { @@ -16,6 +19,7 @@ public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHan public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + String returnTo = consumeReturnTo(request.getSession(false)); if (exception instanceof AccountPendingException) { getRedirectStrategy().sendRedirect(request, response, "/pending-approval"); return; @@ -30,6 +34,24 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo return; } + if (returnTo != null) { + getRedirectStrategy().sendRedirect( + request, + response, + "/login?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8) + ); + return; + } + super.onAuthenticationFailure(request, response, exception); } + + private String consumeReturnTo(HttpSession session) { + if (session == null) { + return null; + } + Object value = session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE); + session.removeAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE); + return value instanceof String str ? OAuthLoginRedirectSupport.sanitizeReturnTo(str) : null; + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java index 083c99667..9328d9058 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java @@ -4,6 +4,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; @@ -15,7 +16,7 @@ public class OAuth2LoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { public OAuth2LoginSuccessHandler() { - setDefaultTargetUrl("/"); + setDefaultTargetUrl(OAuthLoginRedirectSupport.DEFAULT_TARGET_URL); } @Override @@ -27,6 +28,21 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo request.getSession().setAttribute("platformPrincipal", principal); } } + String returnTo = consumeReturnTo(request.getSession(false)); + if (returnTo != null) { + getRedirectStrategy().sendRedirect(request, response, returnTo); + clearAuthenticationAttributes(request); + return; + } super.onAuthenticationSuccess(request, response, authentication); } + + private String consumeReturnTo(HttpSession session) { + if (session == null) { + return null; + } + Object value = session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE); + session.removeAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE); + return value instanceof String str ? OAuthLoginRedirectSupport.sanitizeReturnTo(str) : null; + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java new file mode 100644 index 000000000..6d1fd77f6 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.auth.oauth; + +public final class OAuthLoginRedirectSupport { + + public static final String SESSION_RETURN_TO_ATTRIBUTE = "skillhub.oauth.returnTo"; + public static final String DEFAULT_TARGET_URL = "/dashboard"; + + private OAuthLoginRedirectSupport() { + } + + public static String sanitizeReturnTo(String candidate) { + if (candidate == null || candidate.isBlank()) { + return null; + } + String trimmed = candidate.trim(); + if (!trimmed.startsWith("/") || trimmed.startsWith("//")) { + return null; + } + if (trimmed.contains("\r") || trimmed.contains("\n")) { + return null; + } + return trimmed; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java new file mode 100644 index 000000000..6b42f5934 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.auth.oauth; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; + +@Component +public class SkillHubOAuth2AuthorizationRequestResolver + implements org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver { + + private final DefaultOAuth2AuthorizationRequestResolver delegate; + + public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository) { + this.delegate = new DefaultOAuth2AuthorizationRequestResolver( + clientRegistrationRepository, + "/oauth2/authorization" + ); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request); + rememberReturnTo(request); + return authorizationRequest; + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { + OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId); + rememberReturnTo(request); + return authorizationRequest; + } + + private void rememberReturnTo(HttpServletRequest request) { + String returnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(request.getParameter("returnTo")); + if (returnTo == null) { + request.getSession().removeAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE); + return; + } + request.getSession().setAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, returnTo); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java new file mode 100644 index 000000000..9e8a0a4dc --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java @@ -0,0 +1,57 @@ +package com.iflytek.skillhub.auth.oauth; + +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; + +import static org.assertj.core.api.Assertions.assertThat; + +class OAuth2AuthorizationRequestResolverTest { + + private SkillHubOAuth2AuthorizationRequestResolver resolver; + + @BeforeEach + void setUp() { + ClientRegistration github = ClientRegistration.withRegistrationId("github") + .clientId("client") + .clientSecret("secret") + .authorizationUri("https://example.test/oauth/authorize") + .tokenUri("https://example.test/oauth/token") + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .userInfoUri("https://example.test/user") + .userNameAttributeName("id") + .authorizationGrantType(org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE) + .scope("read:user") + .clientName("GitHub") + .build(); + resolver = new SkillHubOAuth2AuthorizationRequestResolver(new InMemoryClientRegistrationRepository(github)); + } + + @Test + void resolve_storesSanitizedReturnToInSession() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github"); + request.setParameter("returnTo", "/dashboard/publish?draft=1"); + + resolver.resolve(request, "github"); + + HttpSession session = request.getSession(false); + assertThat(session).isNotNull(); + assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)) + .isEqualTo("/dashboard/publish?draft=1"); + } + + @Test + void resolve_ignoresUnsafeReturnTo() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github"); + request.setParameter("returnTo", "https://evil.example"); + + resolver.resolve(request, "github"); + + HttpSession session = request.getSession(false); + assertThat(session).isNotNull(); + assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java new file mode 100644 index 000000000..e6e87bb82 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java @@ -0,0 +1,61 @@ +package com.iflytek.skillhub.auth.oauth; + +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class OAuth2LoginHandlersTest { + + @Test + void successHandler_redirectsToStoredReturnTo() throws Exception { + OAuth2LoginSuccessHandler handler = new OAuth2LoginSuccessHandler(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + HttpSession session = request.getSession(true); + session.setAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, "/dashboard/publish"); + + var principal = new com.iflytek.skillhub.auth.rbac.PlatformPrincipal( + "user-1", "User", "user@example.com", null, "github", Set.of() + ); + Authentication authentication = new UsernamePasswordAuthenticationToken( + new DefaultOAuth2User(List.of(), Map.of("platformPrincipal", principal, "login", "user"), "login"), + null, + List.of() + ); + + handler.onAuthenticationSuccess(request, response, authentication); + + assertThat(response.getRedirectedUrl()).isEqualTo("/dashboard/publish"); + assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); + } + + @Test + void failureHandler_redirectsBackToLoginWithReturnTo() throws Exception { + OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + HttpSession session = request.getSession(true); + session.setAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, "/settings/accounts"); + + handler.onAuthenticationFailure( + request, + response, + new OAuth2AuthenticationException(new OAuth2Error("invalid_request")) + ); + + assertThat(response.getRedirectedUrl()).isEqualTo("/login?returnTo=%2Fsettings%2Faccounts"); + assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6bcedc045..4d328692b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -137,8 +137,11 @@ export async function getCurrentUser(): Promise { export const authApi = { getMe: getCurrentUser, - async getProviders(): Promise { - const providers = await unwrap(client.GET('/api/v1/auth/providers') as never) + async getProviders(returnTo?: string): Promise { + const params = returnTo + ? { query: { returnTo } } + : undefined + const providers = await unwrap(client.GET('/api/v1/auth/providers', params as never) as never) return providers .filter((provider) => provider.id && provider.name && provider.authorizationUrl) .map((provider) => ({ diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index 1ddb4812b..a6f4c52e6 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -3,10 +3,14 @@ import { authApi } from '@/api/client' import { Button } from '@/shared/ui/button' import type { OAuthProvider } from '@/api/types' -export function LoginButton() { +interface LoginButtonProps { + returnTo?: string +} + +export function LoginButton({ returnTo }: LoginButtonProps) { const { data, isLoading } = useQuery({ - queryKey: ['auth', 'providers'], - queryFn: authApi.getProviders, + queryKey: ['auth', 'providers', returnTo ?? ''], + queryFn: () => authApi.getProviders(returnTo), }) const providers = data ?? [] diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index f4fab058c..2d766b1bd 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -92,7 +92,7 @@ export function LoginPage() {

使用 GitHub 登录时,认证完成后会自动返回当前站点。

- +
diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx index c6e79f621..e56645e89 100644 --- a/web/src/pages/register.tsx +++ b/web/src/pages/register.tsx @@ -99,7 +99,7 @@ export function RegisterPage() {

直接使用现有 OAuth 账户进入平台,无需再创建本地密码。

- + From 4d9602fc33c0b21f39ff46ca680dc49480d036f2 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 12:47:34 +0800 Subject: [PATCH 024/313] =?UTF-8?q?fix(publish):=20=E6=94=BE=E5=AE=BD?= =?UTF-8?q?=E6=8A=80=E8=83=BD=E5=8C=85=E4=B8=8A=E4=BC=A0=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - application.yml 补齐缺失的文件扩展名白名单(.js,.ts,.png,.jpg,.svg) - version 为空时自动生成时间戳版本号,不再强制报错 --- server/skillhub-app/src/main/resources/application.yml | 2 +- .../skillhub/domain/skill/service/SkillPublishService.java | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 450d090be..6c8468d3b 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -70,7 +70,7 @@ skillhub: max-file-count: 100 max-single-file-size: 1048576 # 1MB max-package-size: 104857600 # 100MB - allowed-file-extensions: .py,.json,.yaml,.yml,.txt,.md,.sh + allowed-file-extensions: .md,.txt,.json,.yaml,.yml,.js,.ts,.py,.sh,.png,.jpg,.svg management: endpoints: 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 580f9c223..6540007e1 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 @@ -108,7 +108,9 @@ public PublishResult publishFromEntries( String skillMdContent = new String(skillMd.content()); SkillMetadata metadata = skillMetadataParser.parse(skillMdContent); if (metadata.version() == null || metadata.version().isBlank()) { - throw new DomainBadRequestException("error.skill.metadata.requiredField.missing", "version"); + String autoVersion = java.time.LocalDateTime.now() + .format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd.HHmmss")); + metadata = new SkillMetadata(metadata.name(), metadata.description(), autoVersion, metadata.body(), metadata.frontmatter()); } String skillSlug = SlugValidator.slugify(metadata.name()); From cbad1891b6ad7b27cb3f418b6dc385058967d34a Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 12:56:23 +0800 Subject: [PATCH 025/313] =?UTF-8?q?fix(api):=20=E7=BB=9F=E4=B8=80=E6=89=80?= =?UTF-8?q?=E6=9C=89=E5=88=86=E9=A1=B5=E6=8E=A5=E5=8F=A3=E4=B8=BA1-based?= =?UTF-8?q?=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端传 page=1 表示第一页,后端原来是 0-based 导致首页数据丢失。 涉及: SkillSearch, SkillController, ReviewController, PromotionController, UserManagement, AuditLog --- .../controller/admin/AuditLogController.java | 4 ++-- .../admin/UserManagementController.java | 4 ++-- .../controller/portal/PromotionController.java | 8 ++++---- .../controller/portal/ReviewController.java | 15 ++++++++------- .../controller/portal/SkillController.java | 4 ++-- .../controller/portal/SkillSearchController.java | 5 +++-- 6 files changed, 21 insertions(+), 19 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java index 32ec00795..23a309e5b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java @@ -24,11 +24,11 @@ public AuditLogController(ApiResponseFactory responseFactory, @GetMapping @PreAuthorize("hasAnyRole('AUDITOR', 'SUPER_ADMIN')") public ApiResponse> listAuditLogs( - @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String userId, @RequestParam(required = false) String action) { - var logs = auditLogQueryService.list(page, size, userId, action) + var logs = auditLogQueryService.list(Math.max(0, page - 1), size, userId, action) .map(log -> new AuditLogItemResponse( String.valueOf(log.getId()), log.getActorUserId(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java index 1d6363b40..0954f84fb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java @@ -32,9 +32,9 @@ public UserManagementController(ApiResponseFactory responseFactory, public ApiResponse> listUsers( @RequestParam(required = false) String search, @RequestParam(required = false) String status, - @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { - return ok("response.success.read", adminUserManagementService.listUsers(search, status, page, size)); + return ok("response.success.read", adminUserManagementService.listUsers(search, status, Math.max(0, page - 1), size)); } @PutMapping("/{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 f7e541cfe..efd64f923 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 @@ -106,7 +106,7 @@ public ApiResponse rejectPromotion( @GetMapping public ApiResponse> listPromotions( @RequestParam(defaultValue = "PENDING") String status, - @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) { Set platformRoles = rbacService.getUserRoleCodes(userId); @@ -115,13 +115,13 @@ public ApiResponse> listPromotions( throw new DomainForbiddenException("promotion.no_permission"); } ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); - Page requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + Page requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(Math.max(0, page - 1), size)); return ok("response.success.read", PageResponse.from(requests.map(this::toResponse))); } @GetMapping("/pending") public ApiResponse> listPendingPromotions( - @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) { Set platformRoles = rbacService.getUserRoleCodes(userId); @@ -130,7 +130,7 @@ public ApiResponse> listPendingPromotions( throw new DomainForbiddenException("promotion.no_permission"); } Page requests = promotionRequestRepository.findByStatus( - ReviewTaskStatus.PENDING, PageRequest.of(page, size)); + ReviewTaskStatus.PENDING, PageRequest.of(Math.max(0, page - 1), size)); return ok("response.success.read", PageResponse.from(requests.map(this::toResponse))); } 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 6e766132b..6c0e547f7 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 @@ -123,12 +123,13 @@ public ApiResponse withdrawReview( public ApiResponse> listReviews( @RequestParam String status, @RequestParam(required = false) Long namespaceId, - @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); + int zeroBasedPage = Math.max(0, page - 1); Page tasks; if (namespaceId != null) { @@ -138,9 +139,9 @@ public ApiResponse> listReviews( if (!reviewService.canReviewNamespace(probe, userId, namespace.getType(), namespaceRoles, rbacService.getUserRoleCodes(userId))) { throw new DomainForbiddenException("review.no_permission"); } - tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, PageRequest.of(page, size)); + tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, PageRequest.of(zeroBasedPage, size)); } else { - tasks = reviewTaskRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + tasks = reviewTaskRepository.findByStatus(reviewStatus, PageRequest.of(zeroBasedPage, size)); } java.util.List visibleItems = tasks.getContent().stream() @@ -154,7 +155,7 @@ public ApiResponse> listReviews( @GetMapping("/pending") public ApiResponse> listPendingReviews( @RequestParam Long namespaceId, - @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { @@ -166,17 +167,17 @@ public ApiResponse> listPendingReviews( throw new DomainForbiddenException("review.no_permission"); } Page tasks = reviewTaskRepository.findByNamespaceIdAndStatus( - namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); + namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(Math.max(0, page - 1), size)); return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); } @GetMapping("/my-submissions") public ApiResponse> listMySubmissions( - @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) { Page tasks = reviewTaskRepository.findBySubmittedByAndStatus( - userId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); + userId, ReviewTaskStatus.PENDING, PageRequest.of(Math.max(0, page - 1), size)); return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index e265ce579..da86b5c5e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -78,7 +78,7 @@ public ApiResponse getSkillDetail( public ApiResponse> listVersions( @PathVariable String namespace, @PathVariable String slug, - @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { @@ -88,7 +88,7 @@ public ApiResponse> listVersions( slug, userId, userNsRoles != null ? userNsRoles : Map.of(), - PageRequest.of(page, size)); + PageRequest.of(Math.max(0, page - 1), size)); PageResponse response = PageResponse.from(versions.map(v -> new SkillVersionResponse( v.getId(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java index 505229ee1..15ca7afa8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java @@ -28,16 +28,17 @@ public ApiResponse search( @RequestParam(required = false) String q, @RequestParam(required = false) String namespace, @RequestParam(defaultValue = "newest") String sort, - @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + int zeroBasedPage = Math.max(0, page - 1); SkillSearchAppService.SearchResponse response = skillSearchAppService.search( q, namespace, sort, - page, + zeroBasedPage, size, userId, userNsRoles From 19ea64e3a5a5a4ddd051db1b9001027024e7e2f8 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 12:56:29 +0800 Subject: [PATCH 026/313] =?UTF-8?q?Revert=20"fix(api):=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=89=80=E6=9C=89=E5=88=86=E9=A1=B5=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E4=B8=BA1-based=20page"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b64cbeb89c0a796b7142453e057e6ccf3b12c455. --- .../controller/admin/AuditLogController.java | 4 ++-- .../admin/UserManagementController.java | 4 ++-- .../controller/portal/PromotionController.java | 8 ++++---- .../controller/portal/ReviewController.java | 15 +++++++-------- .../controller/portal/SkillController.java | 4 ++-- .../controller/portal/SkillSearchController.java | 5 ++--- 6 files changed, 19 insertions(+), 21 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java index 23a309e5b..32ec00795 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java @@ -24,11 +24,11 @@ public AuditLogController(ApiResponseFactory responseFactory, @GetMapping @PreAuthorize("hasAnyRole('AUDITOR', 'SUPER_ADMIN')") public ApiResponse> listAuditLogs( - @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String userId, @RequestParam(required = false) String action) { - var logs = auditLogQueryService.list(Math.max(0, page - 1), size, userId, action) + var logs = auditLogQueryService.list(page, size, userId, action) .map(log -> new AuditLogItemResponse( String.valueOf(log.getId()), log.getActorUserId(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java index 0954f84fb..1d6363b40 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java @@ -32,9 +32,9 @@ public UserManagementController(ApiResponseFactory responseFactory, public ApiResponse> listUsers( @RequestParam(required = false) String search, @RequestParam(required = false) String status, - @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { - return ok("response.success.read", adminUserManagementService.listUsers(search, status, Math.max(0, page - 1), size)); + return ok("response.success.read", adminUserManagementService.listUsers(search, status, page, size)); } @PutMapping("/{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 efd64f923..f7e541cfe 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 @@ -106,7 +106,7 @@ public ApiResponse rejectPromotion( @GetMapping public ApiResponse> listPromotions( @RequestParam(defaultValue = "PENDING") String status, - @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) { Set platformRoles = rbacService.getUserRoleCodes(userId); @@ -115,13 +115,13 @@ public ApiResponse> listPromotions( throw new DomainForbiddenException("promotion.no_permission"); } ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); - Page requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(Math.max(0, page - 1), size)); + Page requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); return ok("response.success.read", PageResponse.from(requests.map(this::toResponse))); } @GetMapping("/pending") public ApiResponse> listPendingPromotions( - @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) { Set platformRoles = rbacService.getUserRoleCodes(userId); @@ -130,7 +130,7 @@ public ApiResponse> listPendingPromotions( throw new DomainForbiddenException("promotion.no_permission"); } Page requests = promotionRequestRepository.findByStatus( - ReviewTaskStatus.PENDING, PageRequest.of(Math.max(0, page - 1), size)); + ReviewTaskStatus.PENDING, PageRequest.of(page, size)); return ok("response.success.read", PageResponse.from(requests.map(this::toResponse))); } 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 6c0e547f7..6e766132b 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 @@ -123,13 +123,12 @@ public ApiResponse withdrawReview( public ApiResponse> listReviews( @RequestParam String status, @RequestParam(required = false) Long namespaceId, - @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); - int zeroBasedPage = Math.max(0, page - 1); Page tasks; if (namespaceId != null) { @@ -139,9 +138,9 @@ public ApiResponse> listReviews( if (!reviewService.canReviewNamespace(probe, userId, namespace.getType(), namespaceRoles, rbacService.getUserRoleCodes(userId))) { throw new DomainForbiddenException("review.no_permission"); } - tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, PageRequest.of(zeroBasedPage, size)); + tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, PageRequest.of(page, size)); } else { - tasks = reviewTaskRepository.findByStatus(reviewStatus, PageRequest.of(zeroBasedPage, size)); + tasks = reviewTaskRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); } java.util.List visibleItems = tasks.getContent().stream() @@ -155,7 +154,7 @@ public ApiResponse> listReviews( @GetMapping("/pending") public ApiResponse> listPendingReviews( @RequestParam Long namespaceId, - @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { @@ -167,17 +166,17 @@ public ApiResponse> listPendingReviews( throw new DomainForbiddenException("review.no_permission"); } Page tasks = reviewTaskRepository.findByNamespaceIdAndStatus( - namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(Math.max(0, page - 1), size)); + namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); } @GetMapping("/my-submissions") public ApiResponse> listMySubmissions( - @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) { Page tasks = reviewTaskRepository.findBySubmittedByAndStatus( - userId, ReviewTaskStatus.PENDING, PageRequest.of(Math.max(0, page - 1), size)); + userId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index da86b5c5e..e265ce579 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -78,7 +78,7 @@ public ApiResponse getSkillDetail( public ApiResponse> listVersions( @PathVariable String namespace, @PathVariable String slug, - @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { @@ -88,7 +88,7 @@ public ApiResponse> listVersions( slug, userId, userNsRoles != null ? userNsRoles : Map.of(), - PageRequest.of(Math.max(0, page - 1), size)); + PageRequest.of(page, size)); PageResponse response = PageResponse.from(versions.map(v -> new SkillVersionResponse( v.getId(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java index 15ca7afa8..505229ee1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java @@ -28,17 +28,16 @@ public ApiResponse search( @RequestParam(required = false) String q, @RequestParam(required = false) String namespace, @RequestParam(defaultValue = "newest") String sort, - @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - int zeroBasedPage = Math.max(0, page - 1); SkillSearchAppService.SearchResponse response = skillSearchAppService.search( q, namespace, sort, - zeroBasedPage, + page, size, userId, userNsRoles From 6d9eca6ab66dd61f0ddfa1cc724093723a2ba6a1 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 12:58:28 +0800 Subject: [PATCH 027/313] =?UTF-8?q?fix(web):=20=E7=BB=9F=E4=B8=80=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=88=86=E9=A1=B5=E4=B8=BA0-based=20page=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与后端 Spring Data 的 0-based 分页对齐,修复首页数据不显示的问题。 涉及: router, search, home, landing, pagination 组件 --- web/src/app/router.tsx | 2 +- web/src/pages/home.tsx | 8 ++++---- web/src/pages/landing.tsx | 2 +- web/src/pages/search.tsx | 6 +++--- web/src/shared/components/pagination.tsx | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 50750aed8..0ec2a676c 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -89,7 +89,7 @@ const searchRoute = createRoute({ return { q: (search.q as string) || '', sort: (search.sort as string) || 'relevance', - page: Number(search.page) || 1, + page: Number(search.page) || 0, } }, }) diff --git a/web/src/pages/home.tsx b/web/src/pages/home.tsx index 76da1bea7..0ff7df6fd 100644 --- a/web/src/pages/home.tsx +++ b/web/src/pages/home.tsx @@ -19,7 +19,7 @@ export function HomePage() { }) const handleSearch = (query: string) => { - navigate({ to: '/search', search: { q: query, sort: 'relevance', page: 1 } }) + navigate({ to: '/search', search: { q: query, sort: 'relevance', page: 0 } }) } const handleSkillClick = (namespace: string, slug: string) => { @@ -47,7 +47,7 @@ export function HomePage() {
-
@@ -95,7 +95,7 @@ export function HomePage() {
diff --git a/web/src/pages/landing.tsx b/web/src/pages/landing.tsx index b2b7ca6ab..b1c1cf24b 100644 --- a/web/src/pages/landing.tsx +++ b/web/src/pages/landing.tsx @@ -267,7 +267,7 @@ export function LandingPage() { diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 76d145ea5..dcddd874f 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -13,7 +13,7 @@ export function SearchPage() { const q = searchParams.q || '' const sort = searchParams.sort || 'relevance' - const page = searchParams.page || 1 + const page = searchParams.page ?? 0 const { data, isLoading } = useSearchSkills({ q, @@ -23,11 +23,11 @@ export function SearchPage() { }) const handleSearch = (query: string) => { - navigate({ to: '/search', search: { q: query, sort, page: 1 } }) + navigate({ to: '/search', search: { q: query, sort, page: 0 } }) } const handleSortChange = (newSort: string) => { - navigate({ to: '/search', search: { q, sort: newSort, page: 1 } }) + navigate({ to: '/search', search: { q, sort: newSort, page: 0 } }) } const handlePageChange = (newPage: number) => { diff --git a/web/src/shared/components/pagination.tsx b/web/src/shared/components/pagination.tsx index a0c819967..23e52fad1 100644 --- a/web/src/shared/components/pagination.tsx +++ b/web/src/shared/components/pagination.tsx @@ -13,14 +13,14 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps) variant="outline" size="sm" onClick={() => onPageChange(page - 1)} - disabled={page <= 1} + disabled={page <= 0} className="min-w-[90px]" > 上一页
- {page} + {page + 1} / {totalPages} @@ -29,7 +29,7 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps) variant="outline" size="sm" onClick={() => onPageChange(page + 1)} - disabled={page >= totalPages} + disabled={page >= totalPages - 1} className="min-w-[90px]" > 下一页 From 6f8bd2f7feb5715ed956098a9c885155361eb275 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 13:06:04 +0800 Subject: [PATCH 028/313] =?UTF-8?q?fix(web):=20=E4=BF=AE=E5=A4=8D=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=AF=BC=E8=88=AA=E5=8F=82=E6=95=B0=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TanStack Router 的 to 路径不会自动替换 $param,需要用模板字符串手动拼接。 修复所有 navigate 调用,避免 URL 中出现字面量 @$namespace。 涉及: home, search, namespace, my-skills, my-namespaces, stars, reviews --- web/src/pages/dashboard/my-namespaces.tsx | 6 +++--- web/src/pages/dashboard/my-skills.tsx | 2 +- web/src/pages/dashboard/reviews.tsx | 2 +- web/src/pages/dashboard/stars.tsx | 2 +- web/src/pages/home.tsx | 2 +- web/src/pages/namespace.tsx | 2 +- web/src/pages/search.tsx | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index 833cf0fb6..78673ff34 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -10,17 +10,17 @@ export function MyNamespacesPage() { const { data: namespaces, isLoading } = useMyNamespaces() const handleNamespaceClick = (slug: string) => { - navigate({ to: '/@$namespace', params: { namespace: slug } }) + navigate({ to: `/@${slug}` }) } const handleMembersClick = (slug: string, e: React.MouseEvent) => { e.stopPropagation() - navigate({ to: '/dashboard/namespaces/$slug/members', params: { slug } }) + navigate({ to: `/dashboard/namespaces/${slug}/members` }) } const handleReviewsClick = (slug: string, e: React.MouseEvent) => { e.stopPropagation() - navigate({ to: '/dashboard/namespaces/$slug/reviews', params: { slug } }) + navigate({ to: `/dashboard/namespaces/${slug}/reviews` }) } if (isLoading) { diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 39e3ac4b2..19b8f3aa4 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -9,7 +9,7 @@ export function MySkillsPage() { const { data: skills, isLoading } = useMySkills() const handleSkillClick = (namespace: string, slug: string) => { - navigate({ to: '/@$namespace/$slug', params: { namespace, slug } }) + navigate({ to: `/@${namespace}/${slug}` }) } if (isLoading) { diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index edcd733d9..aa62b711e 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -22,7 +22,7 @@ export function ReviewsPage() { } const handleRowClick = (reviewId: number) => { - navigate({ to: '/dashboard/reviews/$id', params: { id: String(reviewId) } }) + navigate({ to: `/dashboard/reviews/${reviewId}` }) } const renderReviewTable = (reviews: typeof pendingReviews, isLoading: boolean, status: string) => { diff --git a/web/src/pages/dashboard/stars.tsx b/web/src/pages/dashboard/stars.tsx index 02c4628a9..589401585 100644 --- a/web/src/pages/dashboard/stars.tsx +++ b/web/src/pages/dashboard/stars.tsx @@ -32,7 +32,7 @@ export function MyStarsPage() { navigate({ to: '/@$namespace/$slug', params: { namespace: skill.namespace, slug: skill.slug } })} + onClick={() => navigate({ to: `/@${skill.namespace}/${skill.slug}` })} /> ))}
diff --git a/web/src/pages/home.tsx b/web/src/pages/home.tsx index 0ff7df6fd..83bacfdee 100644 --- a/web/src/pages/home.tsx +++ b/web/src/pages/home.tsx @@ -23,7 +23,7 @@ export function HomePage() { } const handleSkillClick = (namespace: string, slug: string) => { - navigate({ to: '/@$namespace/$slug', params: { namespace, slug } }) + navigate({ to: `/@${namespace}/${slug}` }) } return ( diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index afc05fa5b..62f47eaa4 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -16,7 +16,7 @@ export function NamespacePage() { }) const handleSkillClick = (slug: string) => { - navigate({ to: '/@$namespace/$slug', params: { namespace, slug } }) + navigate({ to: `/@${namespace}/${slug}` }) } if (isLoadingNamespace) { diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index dcddd874f..f0303baa4 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -35,7 +35,7 @@ export function SearchPage() { } const handleSkillClick = (namespace: string, slug: string) => { - navigate({ to: '/@$namespace/$slug', params: { namespace, slug } }) + navigate({ to: `/@${namespace}/${slug}` }) } const totalPages = data ? Math.ceil(data.total / data.size) : 0 From 44af500f1c6e07443fbf805b45e58c77e410c5de Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 13:16:35 +0800 Subject: [PATCH 029/313] fix(web): ensure all page navigation uses 0-based index Fixed remaining instances in landing.tsx and layout.tsx where page was still set to 1 instead of 0. --- web/src/app/layout.tsx | 2 +- web/src/pages/landing.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index e541adcfa..560a4c383 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -123,7 +123,7 @@ export function Layout() {
  • 搜索技能 diff --git a/web/src/pages/landing.tsx b/web/src/pages/landing.tsx index b1c1cf24b..dc5078b24 100644 --- a/web/src/pages/landing.tsx +++ b/web/src/pages/landing.tsx @@ -106,7 +106,7 @@ export function LandingPage() { }, []) const handleSearch = (query: string) => { - navigate({ to: '/search', search: { q: query, sort: 'relevance', page: 1 } }) + navigate({ to: '/search', search: { q: query, sort: 'relevance', page: 0 } }) } const features = [ @@ -188,7 +188,7 @@ export function LandingPage() { From 836d972dd29a0f7129b6897b16b38e8decf186f0 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Fri, 13 Mar 2026 13:20:58 +0800 Subject: [PATCH 030/313] fix --- .gitignore | 1 + ...41\346\237\245\346\212\245\345\221\212.md" | 363 ---------------- ...30\345\214\226\346\212\245\345\221\212.md" | 405 ------------------ 3 files changed, 1 insertion(+), 768 deletions(-) delete mode 100644 "docs/review/\344\273\243\347\240\201\345\256\241\346\237\245\346\212\245\345\221\212.md" delete mode 100644 "docs/review/\345\220\216\346\234\237\344\274\230\345\214\226\346\212\245\345\221\212.md" diff --git a/.gitignore b/.gitignore index 2129c847d..ed7732636 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ __pycache__/ # Superpowers (AI planning artifacts) docs/superpowers/ +docs/review/ diff --git "a/docs/review/\344\273\243\347\240\201\345\256\241\346\237\245\346\212\245\345\221\212.md" "b/docs/review/\344\273\243\347\240\201\345\256\241\346\237\245\346\212\245\345\221\212.md" deleted file mode 100644 index 0bb937181..000000000 --- "a/docs/review/\344\273\243\347\240\201\345\256\241\346\237\245\346\212\245\345\221\212.md" +++ /dev/null @@ -1,363 +0,0 @@ -# SkillHub 代码审查报告 - -## 1. 审查结论 - -当前版本不建议直接作为后续多人并行开发与生产化落地的基线。 - -结论等级:`有条件不通过` - -阻塞原因主要集中在以下几类: - -1. 认证与鉴权存在高风险缺陷,部分接口可被越权使用,设备码登录实现还存在可伪造令牌问题。 -2. 发布状态机与设计文档不一致,`latest` 语义、搜索事件、下载链路会被未审核版本污染。 -3. 上传与存储链路缺少路径安全和流式限制,存在路径穿越、内存耗尽、脏对象遗留等风险。 -4. 自动化质量门禁未闭环,`skillhub-app` 集成测试无法启动,前端 lint 也未通过。 - -## 2. 审查范围 - -本次重点审查了以下内容: - -- 设计文档: - - `docs/01-system-architecture.md` - - `docs/03-authentication-design.md` - - `docs/05-business-flows.md` - - `docs/06-api-design.md` - - `docs/07-skill-protocol.md` - - `docs/09-deployment.md` -- 后端核心代码: - - 认证与安全:`skillhub-auth` - - 发布、查询、下载、评审、提升:`skillhub-domain` + `skillhub-app` - - 搜索:`skillhub-search` - - 存储:`skillhub-storage` -- 前端核心代码: - - `web/src/features/skill/markdown-renderer.tsx` - -本次执行的关键校验: - -- `pnpm run typecheck`:通过 -- `pnpm run lint`:失败 -- `pnpm run build`:通过,但主包体积告警 -- `mvn -q -DskipTests compile`:通过 -- `mvn test`:失败,`skillhub-app` 24 个测试里 19 个错误 - -## 3. 关键发现 - -### [P0] 设备码登录当前会返回可预测的伪令牌,并且存在并发重复签发风险 - -问题位置: - -- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java:85-111` -- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java:64-75` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java:21-29` - -问题说明: - -- `pollToken()` 在设备码授权成功后直接返回 `token_ + deviceCode`,这不是签名令牌,也不是随机不透明令牌,而是由外部输入可推导得到的占位值。 -- `/api/v1/cli/auth/device/**` 被 `permitAll()` 放开,意味着设备流轮询端点是匿名可访问的,这本身没问题,但前提是返回的必须是正式、不可伪造、可审计、可吊销的令牌。 -- `pollToken()` 采用“先读状态,再写 USED”的非原子流程。两个并发轮询请求可以同时读到 `AUTHORIZED`,从而重复获取访问令牌。 - -影响: - -- 这是认证链路上的核心安全缺陷,风险等级为阻塞上线。 -- 设备授权一旦成功,令牌可被预测,且一次授权可能被多次兑换。 - -修改建议: - -1. 设备流成功后必须调用正式的 token 签发服务,返回随机 opaque token 或签名 JWT。 -2. 设备码消费必须改成 Redis Lua / CAS 方式,保证“仅成功兑换一次”。 -3. 为设备码申请、授权、轮询增加限流和审计。 -4. 补充成功兑换、重复兑换、并发轮询、过期轮询的集成测试。 - -### [P1] 评审与提升流程缺失关键权限校验,已形成越权入口 - -问题位置: - -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java:56-64` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java:50-68` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java:54-61` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java:47-78` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java:102-126` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java:86-105` - -问题说明: - -- `submitReview()` 只根据 `skillVersionId` 和当前用户提交,不校验提交人是否为该 skill 的 owner、namespace 管理员或被允许的发布者。 -- `submitPromotion()` 只校验源版本已发布、目标 namespace 是 GLOBAL,不校验申请人是否有权提升该 skill。 -- `listPendingReviews()`、`getReviewDetail()`、`getPromotionDetail()` 缺少明确授权判断,当前只要已登录即可读取相关数据,存在流程信息泄露。 - -与设计文档冲突: - -- `docs/05-business-flows.md:104-119` 明确要求“owner 或 namespace admin”才能发起提升。 -- `docs/06-api-design.md` 中 review / promotion 相关接口的权限边界也比当前实现更严格。 - -影响: - -- 任意登录用户理论上可以替别人的草稿发起评审。 -- 任意登录用户理论上可以为别人的 skill 发起提升申请。 -- 待审核数据与审批详情对无关用户暴露。 - -修改建议: - -1. 在 `ReviewService.submitReview()` 前补 owner / namespace ADMIN/OWNER 校验。 -2. 在 `PromotionService.submitPromotion()` 前补 source skill 所属 namespace 的提交权限校验。 -3. `listPendingReviews()`、`getReviewDetail()`、`getPromotionDetail()` 必须按 namespace 或平台角色做访问控制,未授权时返回 `403`,不能返回空列表掩盖问题。 - -### [P1] 发布流程在审核前就覆盖 `latestVersionId` 并发出 `SkillPublishedEvent`,会污染下载、解析、搜索和标签语义 - -问题位置: - -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java:143-156` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java:217-225` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java:68-75` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java:106-124` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java:344-351` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java:44-47` -- `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java:26-39` - -问题说明: - -- `publishFromEntries()` 创建的新版本状态是 `PENDING_REVIEW`,但随后立即: - - 更新 `skill.latestVersionId` - - 发出 `SkillPublishedEvent` -- `downloadLatest()`、`resolveLatestVersion()`、`latest` 保留标签、搜索结果里的 latest version 都依赖 `latestVersionId`。 -- 当一个已发布 skill 再上传新版本时,`latestVersionId` 会被指向一个尚未发布的版本,导致“最新版本”不可下载、不可解析或显示错误。 - -与设计文档冲突: - -- `docs/05-business-flows.md:30-45` 的 Phase 2 定义是“发布后直接 `PUBLISHED` 才更新 latest”。 -- `docs/05-business-flows.md:49-55` 的 Phase 3 定义是“`DRAFT -> PENDING_REVIEW -> PUBLISHED`”,审核通过后才进入发布态。 -- 当前代码实际做成了“创建后直接 `PENDING_REVIEW`,但又按已发布版本处理”,状态语义前后矛盾。 - -影响: - -- `latest` 下载链路会在有待审版本时报错。 -- 搜索索引会被过早重建。 -- 详情页、我的技能页、标签查询都会看到未发布版本。 - -修改建议: - -1. 冻结状态机语义: - - 要么 Phase 2:发布即 `PUBLISHED` - - 要么 Phase 3:上传只创建草稿 / 待审,审核通过后才更新 latest -2. `latestVersionId` 必须只表示“最新已发布版本”。 -3. `SkillPublishedEvent` 只能在版本真正进入 `PUBLISHED` 后发出。 -4. 若需要展示“最新草稿”,单独引入 `latestDraftVersionId` 或查询逻辑,不要复用 published 语义字段。 - -### [P1] 上传链路存在路径穿越与压缩包资源耗尽风险 - -问题位置: - -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java:64-83` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java:28-81` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java:166-189` -- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java:21-31` -- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java:61` - -问题说明: - -- Controller 侧对 zip entry 使用 `readAllBytes()`,在验证之前就把每个解压后文件整体读入内存。 -- 包校验器没有校验路径规范化,也没有禁止 `../`、绝对路径、反斜杠路径、重复路径。 -- `LocalFileStorageService.resolve()` 直接 `basePath.resolve(key)`,没有 `normalize()` 和 `startsWith(basePath)` 校验。 -- 攻击者可以构造恶意 zip 路径,例如 `../../outside.txt`,通过 `skills/{skillId}/{versionId}/{filePath}` 最终逃逸本地存储根目录。 - -影响: - -- 本地存储模式下存在任意文件写入风险。 -- 恶意压缩包可造成内存压力甚至进程 OOM。 - -修改建议: - -1. 在解压阶段引入流式校验,限制总解压大小、单文件大小、文件数量。 -2. 对 entry path 做统一规范化,只允许: - - 相对路径 - - `/` 分隔 - - 禁止 `..`、禁止绝对路径、禁止空段 -3. `LocalFileStorageService` 必须在 `normalize()` 后校验目标路径仍位于 `basePath` 内。 -4. 对重复路径在校验阶段直接返回 `400`,不能等数据库唯一键报错。 - -### [P1] `GET /api/v1/skills/**` 过度放开,导致匿名 `500` 与私有元数据泄露 - -问题位置: - -- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java:75` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java:40-45` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java:37-46` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java:73-94` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java:29-40` - -问题说明: - -- `SecurityConfig` 直接放开了所有 `GET /api/v1/skills/**`。 -- 但其中至少两类 GET 并不适合匿名访问: - - `GET /api/v1/skills/{skillId}/star` - - `GET /api/v1/skills/{skillId}/rating` - 这两个接口直接解引用 `principal.userId()`,匿名访问会触发空指针,落到全局异常后返回 `500`。 -- 另外: - - `listVersions()` 没有 visibility 校验 - - `listTags()` 没有 visibility 校验 - 这会把 `PRIVATE` / `NAMESPACE_ONLY` skill 的版本信息、标签信息暴露给匿名或无关用户。 - -影响: - -- 安全上属于“鉴权边界与路由策略不一致”。 -- 行为上会出现匿名访问 500,破坏 API 契约。 - -修改建议: - -1. 不要用 `GET /api/v1/skills/**` 一刀切放开,改成白名单到具体公共接口。 -2. 对“需要当前用户上下文”的 GET 接口显式要求认证。 -3. `listVersions()`、`listTags()` 必须补充与详情接口一致的 visibility 校验。 - -### [P1] `skillhub-app` 集成测试当前无法启动,测试信号失真 - -问题位置: - -- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java:19-25` -- `server/skillhub-app/src/test/resources/application-test.yml:1-33` -- `server/skillhub-app/target/surefire-reports/com.iflytek.skillhub.controller.AuthControllerTest.txt` - -问题说明: - -- `mvn test` 中,`skillhub-storage`、`skillhub-domain`、`skillhub-auth` 的测试通过,但 `skillhub-app` 的 Spring 上下文无法启动。 -- 直接原因是 `DeviceAuthService` 依赖 `RedisTemplate`,测试环境没有对应 bean。 -- 代码里也没有看到 `verificationUri` 的配置注入实现,当前构造器还要求额外的 `String` 参数,后续即使补齐 Redis bean,也大概率还会继续失败。 - -影响: - -- 目前 controller 层测试的 19 个错误都被同一个装配问题掩盖,真实回归无法被发现。 -- CI 无法提供有效的回归保护。 - -修改建议: - -1. 为设备码服务补全正式配置类: - - `RedisTemplate` - - `@ConfigurationProperties` 形式的 `verificationUri` -2. 测试环境提供 stub / mock bean,确保 app context 可启动。 -3. 优先修复后重新运行 controller 集成测试,再评估剩余问题。 - -### [P2] 存储配置键名与部署文档漂移,S3/MinIO 路径当前并不能真正启用 - -问题位置: - -- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/StorageProperties.java:7-15` -- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java:12-14` -- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java:19-21` -- `server/skillhub-app/src/main/resources/application.yml:55-58` -- `docker-compose.prod.yml:51-59` -- `docs/09-deployment.md` - -问题说明: - -- 代码使用的配置键是 `skillhub.storage.provider`。 -- `application.yml` 写的是 `skillhub.storage.type`。 -- `docker-compose.prod.yml` 也没有把 S3/MinIO 相关配置注入到后端容器。 - -影响: - -- 即使文档和运维层面按 MinIO/S3 部署,也无法通过当前配置切换到 `S3StorageService`。 -- 实际运行会默认为本地文件存储,和部署文档不一致。 - -修改建议: - -1. 统一配置键,只保留一种: - - 推荐 `skillhub.storage.provider` -2. 修正文档、`application.yml`、`docker-compose.prod.yml`、`StorageProperties` 的一致性。 -3. 增加一个启动时自检日志,打印当前启用的 storage provider。 - -### [P2] 上传大小与文件白名单配置未真正落地,当前仍是硬编码 - -问题位置: - -- `server/skillhub-app/src/main/resources/application.yml:47-64` -- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java:12-20` - -问题说明: - -- 配置文件声明: - - `multipart` 允许到 `100MB` - - `skillhub.publish.max-package-size` 为 `100MB` - - `allowed-file-extensions` 可配置 -- 但实际校验器仍然硬编码: - - 总包 `10MB` - - 固定扩展名集合 - -影响: - -- 文档、配置、运行时行为三者不一致。 -- 运维或产品侧修改配置后不会生效,问题定位成本高。 - -修改建议: - -1. 将 `SkillPackageValidator` 改为读取 `@ConfigurationProperties`。 -2. 保持“网关上限”“multipart 上限”“业务校验上限”三层配置含义清晰并一致。 - -### [P2] 资源不存在时多处直接 `orElseThrow()`,会把正常 404 场景放大成 500 - -问题位置: - -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java:101-111` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java:60-62` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java:97` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java:123-132` -- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java:30-33` - -问题说明: - -- 多个 controller 直接使用裸 `orElseThrow()`。 -- 当资源不存在时会抛出 `NoSuchElementException`,最后被全局异常处理器转成 `500`。 -- `SkillRatingController` 还存在 `score` 缺失时的空指针风险。 - -影响: - -- API 契约不稳定,客户端会把普通业务错误当成服务故障。 - -修改建议: - -1. 统一抛出领域层 `NotFound/BadRequest` 异常。 -2. 给评分、评审、提升请求增加 `@Valid` 和显式字段校验。 - -### [P2] 前端质量门禁未闭环,lint 未通过,产物主包偏大 - -问题位置: - -- `web/src/features/skill/markdown-renderer.tsx:15-16` - -问题说明: - -- 当前 `eslint` 失败: - - `@ts-ignore` 应改为 `@ts-expect-error` - - `node` 参数未使用 -- `pnpm run build` 虽能通过,但主 JS chunk 约 `751.25 kB`,明显偏大。 - -影响: - -- CI 无法建立严格前端门禁。 -- 首屏加载与缓存更新成本偏高。 - -修改建议: - -1. 先修复 lint 报错,恢复前端静态检查门禁。 -2. 后续按页面级或功能级拆包,优先处理 markdown/highlight、管理页、上传页等非首屏模块。 - -## 4. 与设计文档的主要偏差 - -当前代码与文档的主要偏差有: - -1. `docs/05-business-flows.md` 对 Phase 2 / Phase 3 的发布状态定义,与 `SkillPublishService` 现状不一致。 -2. 文档里定义了幂等、审计、孤儿对象清理、异步事件兜底,但代码里尚未真正实现。 -3. 文档要求的权限边界比当前 review / promotion / skill GET 路由更严格,当前实现明显偏松。 -4. 部署文档强调 MinIO / S3 可切换,但配置层并未打通。 - -## 5. 现阶段是否可进入开发 - -可以继续开发,但不建议直接进入“多人并行开发 + 联调 + 准生产验证”阶段。 - -建议先完成以下最小修复集: - -1. 修复设备码认证实现与 app 测试装配。 -2. 收紧 review / promotion / skills GET 的鉴权边界。 -3. 修正发布状态机,保证 `latestVersionId` 只指向已发布版本。 -4. 修复 zip 路径校验和本地存储路径归一化。 -5. 打通前后端质量门禁:`mvn test`、`pnpm lint`、`pnpm build` 全绿。 - -在这五项完成之前,后续功能开发会持续叠加在一个不稳定基线上,返工概率较高。 diff --git "a/docs/review/\345\220\216\346\234\237\344\274\230\345\214\226\346\212\245\345\221\212.md" "b/docs/review/\345\220\216\346\234\237\344\274\230\345\214\226\346\212\245\345\221\212.md" deleted file mode 100644 index b8a076947..000000000 --- "a/docs/review/\345\220\216\346\234\237\344\274\230\345\214\226\346\212\245\345\221\212.md" +++ /dev/null @@ -1,405 +0,0 @@ -# SkillHub 后期优化报告 - -## 1. 总体判断 - -SkillHub 当前已经具备“能跑起来的模块化单体”雏形,分层方向是对的,但距离“可持续迭代、可对外开放、可灰度发布”的工程化状态还有明显差距。 - -后续优化应分成两类: - -1. `阻塞型修复`:先把安全、鉴权、状态机、测试基线修好。 -2. `架构型收敛`:把文档、配置、协议、模块边界收敛成一个长期稳定的实现。 - -下面的建议默认以“后续要继续实际代码开发”为前提,而不是只做文档美化。 - -## 2. 优先级建议 - -### T0:1 周内必须完成 - -1. 修复设备码登录: - - 正式 token 签发 - - 原子消费 - - Redis bean / 配置注入补齐 -2. 修复 review / promotion / skills GET 的权限边界。 -3. 修复发布状态机: - - `latestVersionId` - - `SkillPublishedEvent` - - `latest` 标签 - - 搜索重建触发时机 -4. 修复上传路径安全和解压限制。 -5. 恢复质量门禁: - - `mvn test` - - `pnpm lint` - - `pnpm build` - -### T1:2 到 3 周内完成 - -1. 冻结配置协议与部署方式: - - storage provider - - S3 / MinIO - - publish 限制配置 -2. 完成评审、提升、下载、搜索的权限策略统一。 -3. 落地幂等与审计最小版。 -4. 收敛 API 路径与 DTO 契约。 - -### T2:1 到 2 个迭代完成 - -1. 引入更稳健的异步一致性方案。 -2. 做前端拆包、性能与体验优化。 -3. 增强发布安全能力和安装完整性校验。 -4. 做更强的产品化能力,例如可视化审核、统计分析、推荐等。 - -## 3. 架构优化建议 - -### 3.1 冻结发布状态机 - -当前最需要收敛的是“发布”和“审核”到底是什么关系。 - -建议明确采用下面其中一种,不要混合: - -方案 A:Phase 2 简化模型 - -- 上传成功即 `PUBLISHED` -- 不存在 `PENDING_REVIEW` -- `latestVersionId` 直接指向新版本 - -方案 B:Phase 3 审核模型 - -- 上传只创建 `DRAFT` -- 显式提交后进入 `PENDING_REVIEW` -- 审核通过后才进入 `PUBLISHED` -- 只有 `PUBLISHED` 版本能影响: - - `latestVersionId` - - `latest` 保留标签 - - 搜索索引 - - 下载入口 - -如果选择方案 B,建议新增下面两个边界对象: - -- `PublishedVersionPointer` - - 只负责“当前可安装版本” -- `DraftVersionView` - - 只负责“作者或审核人能看到的最新草稿/待审版本” - -不要再用一个 `latestVersionId` 同时表达“最新上传版本”和“最新已发布版本”。 - -### 3.2 建立统一授权层 - -当前权限判断分散在: - -- `SecurityConfig` -- Controller -- Domain Service -- `VisibilityChecker` -- `ReviewPermissionChecker` - -建议统一成三层: - -1. `Authentication` - - 只负责身份识别 -2. `Resource Access Policy` - - 只负责“谁能看” -3. `Action Authorization Policy` - - 只负责“谁能做什么” - -推荐拆出统一的 policy 组件,例如: - -- `SkillReadPolicy` -- `SkillPublishPolicy` -- `ReviewPolicy` -- `PromotionPolicy` -- `NamespacePolicy` - -Controller 不再自己拼权限,只把上下文交给 policy。 - -### 3.3 重构上传入口为单独的 Package Ingestion 模块 - -建议把当前“解压 + 校验 + 存储 + bundle 构建 + 元数据提取”从 `SkillPublishController` / `SkillPublishService` 中抽离,形成独立应用服务: - -- `SkillPackageIngestionService` - -职责建议如下: - -- 解析 zip stream -- 路径校验 -- 大小限制 -- 重复路径校验 -- 内容类型判断 -- 哈希计算 -- 生成标准化 manifest -- 输出领域对象 `ValidatedSkillPackage` - -这样好处是: - -- Web / CLI 共享同一套上传逻辑 -- 更容易做流式处理 -- 更容易接入病毒扫描、签名验证、内容审核 - -### 3.4 引入存储补偿与异步一致性机制 - -当前对象存储与数据库事务是“两套事务”,必须承认这一点。 - -建议至少补齐两层机制: - -1. 同步补偿 - - 上传过程中一旦数据库失败,立即尝试删除已写入对象 -2. 异步兜底 - - 定时扫描 orphan object - - 定时补建搜索索引 - -如果未来继续扩展,建议走标准化方案: - -- `transactional outbox` -- 后台 worker -- 幂等消费 - -### 3.5 配置体系收敛成强类型配置 - -当前配置散落在: - -- `application.yml` -- `docker-compose*.yml` -- `@ConditionalOnProperty` -- 硬编码常量 - -建议做一次统一收敛: - -- `SkillhubStorageProperties` -- `SkillhubPublishProperties` -- `SkillhubAuthProperties` -- `SkillhubRateLimitProperties` - -并要求: - -1. 所有运行时行为只从配置类读取。 -2. 业务代码禁止继续写死阈值。 -3. 启动时打印关键配置摘要,方便排障。 - -## 4. 设计优化建议 - -### 4.1 API 路径风格统一 - -当前同时存在两种风格: - -- 面向用户的 namespace/slug 坐标 -- 面向内部的数字 ID 路径 - -建议明确区分: - -- 外部公开 API:统一用 `namespace/slug/version/tag` -- 内部管理或后台 API:可以保留数字 ID - -对外协议越稳定,前端、CLI、第三方集成越容易维护。 - -### 4.2 文档与代码冻结同一份状态机和权限矩阵 - -建议补一份真正可执行的“冻结文档”,只包含这些内容: - -1. 版本状态流转表 -2. skill 可见性矩阵 -3. review / promotion 权限矩阵 -4. token scope 矩阵 -5. 公共 GET 白名单 - -这份文档应该成为: - -- 后端开发实现基准 -- 前端联调基准 -- 测试用例来源 - -### 4.3 Token 设计升级为“角色 + scope”双约束 - -当前 API Token 更像“换一种方式拿到完整用户权限”。 - -建议升级成: - -- `identity` -- `platformRoles` -- `scopes` -- `resourceConstraints` - -例如: - -- `skill:read` -- `skill:publish` -- `review:read` -- `review:approve` -- `namespace:team-a` - -并要求 Filter 只解析身份,具体权限由下游 policy 再次判断。 - -### 4.4 设备码登录设计应独立成完整子协议 - -建议把设备流单独文档化并独立实现,最少包括: - -1. device code 生命周期 -2. user code 生命周期 -3. 轮询频率限制 -4. 授权成功后的单次兑换 -5. access token / refresh token -6. 撤销与过期处理 -7. 审计字段 - -不要把它作为“先写个占位版本”长期保留在主干里。 - -## 5. 功能优化建议 - -### 5.1 发布体验 - -建议在当前基础上补齐: - -1. 上传预检接口 - - 只做包校验,不落库 -2. 发布结果详情页 - - 显示版本状态、校验结果、审计信息 -3. 版本差异展示 - - 新旧文件列表 diff -4. bundle 指纹展示 - - 便于 CLI 做一致性校验 - -### 5.2 审核体验 - -建议增加: - -1. review 队列按 namespace / 状态 / 时间过滤 -2. promotion 队列显示来源 skill 与目标 namespace -3. 审核意见模板 -4. 审核历史轨迹 - -### 5.3 安装与下载体验 - -建议补齐: - -1. 下载返回指纹 -2. CLI 安装后校验 hash -3. “latest” 与自定义 tag 的解析提示 -4. 私有 skill 访问失败时更清晰的错误码 - -### 5.4 搜索体验 - -建议按阶段演进: - -1. 先把权限过滤、排序、分页做稳定 -2. 再补关键词高亮、标签过滤、namespace 过滤 -3. 最后再考虑向量搜索或推荐 - -先把 correctness 做扎实,比过早上复杂搜索引擎更重要。 - -## 6. 创新优化建议 - -### 6.1 增加发布安全链 - -可以在上传后增加可插拔安全扫描链: - -- 文件白名单校验 -- 敏感内容扫描 -- 恶意脚本特征扫描 -- 许可证扫描 -- 依赖清单提取 - -这部分可以通过 `PrePublishValidator` 真正扩展,而不是继续 `NoOp`。 - -### 6.2 增加制品签名与可验证安装 - -建议为 bundle 增加: - -- 版本指纹 -- 服务端签名 -- CLI 验签 - -这样 SkillHub 才更像一个可被信任的官方分发源,而不只是文件托管站。 - -### 6.3 增加“来源关系”与“派生关系”图谱 - -当前 promotion 已经有 `sourceSkillId` 雏形,后面可以扩展成: - -- fork / promote / mirror / upstream - -这能支撑: - -- 技能来源追踪 -- 派生链可视化 -- 全局版与团队版差异说明 - -## 7. 迭代优化建议 - -### 7.1 推荐实施顺序 - -第一阶段:安全与正确性收口 - -- 设备码登录 -- review / promotion 鉴权 -- skills GET 白名单 -- 上传路径安全 -- latest 语义修复 - -第二阶段:工程化基线收口 - -- 测试全绿 -- 配置统一 -- 存储 provider 打通 -- 文档与代码对齐 -- 前端 lint 与拆包 - -第三阶段:产品化能力增强 - -- 审核后台 -- 审计日志 -- 幂等与回放 -- 更完整的 CLI 体验 - -第四阶段:平台化能力增强 - -- 签名安装 -- 安全扫描 -- 推荐与搜索增强 -- 可观测性与运营报表 - -### 7.2 每阶段验收标准 - -建议引入明确验收门槛: - -第一阶段验收: - -- 不再存在 P0 / P1 安全问题 -- `mvn test` 通过 -- `pnpm lint && pnpm build` 通过 - -第二阶段验收: - -- 配置切换本地/S3 存储可实测 -- 发布/审核/下载链路与文档一致 -- 私有 skill 权限边界有自动化测试 - -第三阶段验收: - -- review / promotion / audit 有可视化页面 -- CLI 对外可稳定联调 - -## 8. 建议立即补的文档 - -为了让后续开发真正顺利,建议马上追加 4 份冻结文档: - -1. `权限矩阵冻结文档` -2. `发布状态机冻结文档` -3. `上传与存储安全规范` -4. `API Token / Device Flow 协议冻结文档` - -这 4 份文档会比继续补泛化架构图更有实际开发价值。 - -## 9. 最终建议 - -SkillHub 目前最值得保留的是: - -- 模块化单体拆分方向 -- 领域服务初步分层 -- review / promotion / search / storage 的边界意识 - -最需要马上收口的是: - -- 权限模型 -- 发布状态机 -- 上传安全 -- 配置一致性 -- 测试基线 - -只要先把这五件事修稳,后面的架构优化和产品创新都能顺着推进;如果不先修,越往后叠功能,返工就越贵。 From 7280f07cbcb5374d1952708132080a0a9aaea900 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 13:21:05 +0800 Subject: [PATCH 031/313] feat(web): implement i18n and user menu - Added i18next for internationalization support - Created language switcher component with zh/en support - Refactored user navigation into dropdown menu - Moved Dashboard, Security, and Account settings into user menu - Added translation files for Chinese and English - Updated layout to use i18n for all text content --- web/package.json | 4 + web/pnpm-lock.yaml | 728 ++++++++++++++++++ web/src/app/layout.tsx | 66 +- web/src/i18n/config.ts | 25 + web/src/i18n/locales/en.json | 63 ++ web/src/i18n/locales/zh.json | 63 ++ web/src/main.tsx | 1 + .../shared/components/language-switcher.tsx | 46 ++ web/src/shared/components/user-menu.tsx | 95 +++ web/src/shared/ui/dropdown-menu.tsx | 64 ++ web/src/types/json.d.ts | 4 + 11 files changed, 1111 insertions(+), 48 deletions(-) create mode 100644 web/src/i18n/config.ts create mode 100644 web/src/i18n/locales/en.json create mode 100644 web/src/i18n/locales/zh.json create mode 100644 web/src/shared/components/language-switcher.tsx create mode 100644 web/src/shared/components/user-menu.tsx create mode 100644 web/src/shared/ui/dropdown-menu.tsx create mode 100644 web/src/types/json.d.ts diff --git a/web/package.json b/web/package.json index e29bcc223..06995700f 100644 --- a/web/package.json +++ b/web/package.json @@ -13,15 +13,19 @@ "generate-api": "openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts" }, "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.16", "@tanstack/react-query": "^5.64.0", "@tanstack/react-router": "^1.95.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "i18next": "^25.8.18", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.344.0", "openapi-fetch": "^0.13.8", "react": "^19.0.0", "react-dom": "^19.0.0", "react-dropzone": "^15.0.0", + "react-i18next": "^16.5.8", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", "rehype-sanitize": "^6.0.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a74658e0d..55e77585a 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-query': specifier: ^5.64.0 version: 5.90.21(react@19.2.4) @@ -20,6 +23,12 @@ importers: clsx: specifier: ^2.1.0 version: 2.1.1 + i18next: + specifier: ^25.8.18 + version: 25.8.18(typescript@5.9.3) + i18next-browser-languagedetector: + specifier: ^8.2.1 + version: 8.2.1 lucide-react: specifier: ^0.344.0 version: 0.344.0(react@19.2.4) @@ -35,6 +44,9 @@ importers: react-dropzone: specifier: ^15.0.0 version: 15.0.0(react@19.2.4) + react-i18next: + specifier: ^16.5.8 + version: 16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -174,6 +186,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -360,6 +376,21 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -401,6 +432,272 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@redocly/ajv@8.11.2': resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} @@ -737,6 +1034,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -883,6 +1184,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -1037,6 +1341,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1087,6 +1395,9 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -1094,6 +1405,17 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + i18next-browser-languagedetector@8.2.1: + resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + + i18next@25.8.18: + resolution: {integrity: sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1578,6 +1900,22 @@ packages: peerDependencies: react: '>= 16.8 || 18.0.0' + react-i18next@16.5.8: + resolution: {integrity: sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==} + peerDependencies: + i18next: '>= 25.6.2' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -1591,6 +1929,36 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -1825,6 +2193,26 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -1879,6 +2267,10 @@ packages: yaml: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2019,6 +2411,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.28.6': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -2143,6 +2537,23 @@ snapshots: '@eslint/js@8.57.1': {} + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.11': {} + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -2186,6 +2597,246 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/rect@1.1.1': {} + '@redocly/ajv@8.11.2': dependencies: fast-deep-equal: 3.1.3 @@ -2507,6 +3158,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + array-union@2.1.0: {} attr-accept@2.2.5: {} @@ -2632,6 +3287,8 @@ snapshots: dequal@2.0.3: {} + detect-node-es@1.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -2823,6 +3480,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-nonce@1.0.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2904,6 +3563,10 @@ snapshots: highlight.js@11.11.1: {} + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + html-url-attributes@3.0.1: {} https-proxy-agent@7.0.6(supports-color@10.2.2): @@ -2913,6 +3576,16 @@ snapshots: transitivePeerDependencies: - supports-color + i18next-browser-languagedetector@8.2.1: + dependencies: + '@babel/runtime': 7.28.6 + + i18next@25.8.18(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + optionalDependencies: + typescript: 5.9.3 + ignore@5.3.2: {} import-fresh@3.3.1: @@ -3556,6 +4229,17 @@ snapshots: prop-types: 15.8.1 react: 19.2.4 + react-i18next@16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + html-parse-stringify: 3.0.1 + i18next: 25.8.18(typescript@5.9.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.9.3 + react-is@16.13.1: {} react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): @@ -3578,6 +4262,33 @@ snapshots: react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + react@19.2.4: {} read-cache@1.0.0: @@ -3872,6 +4583,21 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + use-sync-external-store@1.6.0(react@19.2.4): dependencies: react: 19.2.4 @@ -3900,6 +4626,8 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 + void-elements@3.1.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 560a4c383..50e7610cc 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,9 +1,13 @@ import { Suspense } from 'react' import { Outlet, Link, useRouterState } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' import { LandingPage } from '@/pages/landing' +import { LanguageSwitcher } from '@/shared/components/language-switcher' +import { UserMenu } from '@/shared/components/user-menu' export function Layout() { + const { t } = useTranslation() const { user, isLoading } = useAuth() const pathname = useRouterState({ select: (s) => s.location.pathname }) const isLanding = pathname === '/' @@ -31,43 +35,9 @@ export function Layout() { @@ -113,11 +83,11 @@ export function Layout() {
  • -

    快速链接

    +

    {t('nav.home')}

    • - 首页 + {t('nav.home')}
    • @@ -126,33 +96,33 @@ export function Layout() { search={{ q: '', sort: 'relevance', page: 0 }} className="text-sm text-muted-foreground hover:text-primary transition-colors" > - 搜索技能 + {t('nav.search')}
    • - Dashboard + {t('nav.dashboard')}
    -

    资源

    +

    {t('footer.resources')}

    @@ -161,14 +131,14 @@ export function Layout() {

    - © 2024 SkillHub. All rights reserved. + {t('footer.copyright')}

    diff --git a/web/src/i18n/config.ts b/web/src/i18n/config.ts new file mode 100644 index 000000000..75a0a566e --- /dev/null +++ b/web/src/i18n/config.ts @@ -0,0 +1,25 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import LanguageDetector from 'i18next-browser-languagedetector' +import en from './locales/en.json' +import zh from './locales/zh.json' + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + en: { translation: en }, + zh: { translation: zh }, + }, + fallbackLng: 'zh', + interpolation: { + escapeValue: false, + }, + detection: { + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + }, + }) + +export default i18n diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json new file mode 100644 index 000000000..1818bcd4d --- /dev/null +++ b/web/src/i18n/locales/en.json @@ -0,0 +1,63 @@ +{ + "nav": { + "home": "Home", + "search": "Search Skills", + "dashboard": "Dashboard", + "login": "Login" + }, + "landing": { + "hero": { + "title": "Discover & Share AI Skills", + "subtitle": "Build powerful AI agents with community-driven skills", + "searchPlaceholder": "Search skills...", + "exploreSkills": "Explore Skills" + }, + "features": { + "secure": { + "title": "Secure & Private", + "description": "Enterprise-grade security for your AI workflows" + }, + "community": { + "title": "Community Driven", + "description": "Share and discover skills from developers worldwide" + }, + "integration": { + "title": "Easy Integration", + "description": "Seamlessly integrate with your existing tools" + } + } + }, + "search": { + "title": "Search Skills", + "placeholder": "Search skills...", + "sort": { + "relevance": "Relevance", + "downloads": "Downloads", + "stars": "Stars", + "newest": "Newest" + }, + "noResults": "No skills found", + "results": "{{count}} skills found" + }, + "user": { + "menu": { + "dashboard": "Dashboard", + "mySkills": "My Skills", + "myNamespaces": "My Namespaces", + "stars": "Starred", + "reviews": "Reviews", + "security": "Security Settings", + "accounts": "Account Merge", + "logout": "Logout" + } + }, + "footer": { + "resources": "Resources", + "docs": "Documentation", + "api": "API", + "community": "Community", + "privacy": "Privacy Policy", + "terms": "Terms of Service", + "copyright": "© 2024 SkillHub. All rights reserved." + } +} diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json new file mode 100644 index 000000000..c12de188a --- /dev/null +++ b/web/src/i18n/locales/zh.json @@ -0,0 +1,63 @@ +{ + "nav": { + "home": "首页", + "search": "搜索技能", + "dashboard": "控制台", + "login": "登录" + }, + "landing": { + "hero": { + "title": "发现与分享 AI 技能", + "subtitle": "使用社区驱动的技能构建强大的 AI 代理", + "searchPlaceholder": "搜索技能...", + "exploreSkills": "探索技能" + }, + "features": { + "secure": { + "title": "安全私密", + "description": "企业级安全保护您的 AI 工作流" + }, + "community": { + "title": "社区驱动", + "description": "与全球开发者分享和发现技能" + }, + "integration": { + "title": "轻松集成", + "description": "无缝集成到您现有的工具中" + } + } + }, + "search": { + "title": "搜索技能", + "placeholder": "搜索技能...", + "sort": { + "relevance": "相关性", + "downloads": "下载量", + "stars": "星标数", + "newest": "最新" + }, + "noResults": "未找到技能", + "results": "找到 {{count}} 个技能" + }, + "user": { + "menu": { + "dashboard": "控制台", + "mySkills": "我的技能", + "myNamespaces": "我的命名空间", + "stars": "我的星标", + "reviews": "我的评论", + "security": "安全设置", + "accounts": "账号合并", + "logout": "退出登录" + } + }, + "footer": { + "resources": "资源", + "docs": "文档", + "api": "API", + "community": "社区", + "privacy": "隐私政策", + "terms": "服务条款", + "copyright": "© 2024 SkillHub. 保留所有权利。" + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 9438cdccc..b5d7863f5 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { App } from './app/providers' +import './i18n/config' import './index.css' ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/web/src/shared/components/language-switcher.tsx b/web/src/shared/components/language-switcher.tsx new file mode 100644 index 000000000..fdda5aaac --- /dev/null +++ b/web/src/shared/components/language-switcher.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next' +import { Button } from '@/shared/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/shared/ui/dropdown-menu' +import { Globe } from 'lucide-react' + +export function LanguageSwitcher() { + const { i18n } = useTranslation() + + const languages = [ + { code: 'zh', name: '中文' }, + { code: 'en', name: 'English' }, + ] + + const currentLanguage = languages.find((lang) => lang.code === i18n.language) || languages[0] + + const changeLanguage = (langCode: string) => { + i18n.changeLanguage(langCode) + } + + return ( + + + + + + {languages.map((lang) => ( + changeLanguage(lang.code)} + className={i18n.language === lang.code ? 'bg-accent' : ''} + > + {lang.name} + + ))} + + + ) +} diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx new file mode 100644 index 000000000..1042fe978 --- /dev/null +++ b/web/src/shared/components/user-menu.tsx @@ -0,0 +1,95 @@ +import { useTranslation } from 'react-i18next' +import { Link, useNavigate } from '@tanstack/react-router' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/shared/ui/dropdown-menu' + +interface User { + displayName: string + avatarUrl?: string +} + +interface UserMenuProps { + user: User +} + +export function UserMenu({ user }: UserMenuProps) { + const { t } = useTranslation() + const navigate = useNavigate() + + const handleLogout = async () => { + try { + await fetch('/api/auth/logout', { method: 'POST' }) + navigate({ to: '/' }) + window.location.reload() + } catch (error) { + console.error('Logout failed:', error) + } + } + + return ( + + + + + + + + {t('user.menu.dashboard')} + + + + + {t('user.menu.mySkills')} + + + + + {t('user.menu.myNamespaces')} + + + + + {t('user.menu.stars')} + + + + + {t('user.menu.reviews')} + + + + + + {t('user.menu.security')} + + + + + {t('user.menu.accounts')} + + + + + {t('user.menu.logout')} + + + + ) +} diff --git a/web/src/shared/ui/dropdown-menu.tsx b/web/src/shared/ui/dropdown-menu.tsx new file mode 100644 index 000000000..475d1dcb3 --- /dev/null +++ b/web/src/shared/ui/dropdown-menu.tsx @@ -0,0 +1,64 @@ +import * as React from 'react' +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { cn } from '@/shared/lib/utils' + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} diff --git a/web/src/types/json.d.ts b/web/src/types/json.d.ts new file mode 100644 index 000000000..656866645 --- /dev/null +++ b/web/src/types/json.d.ts @@ -0,0 +1,4 @@ +declare module '*.json' { + const value: any + export default value +} From 0cfbdf6296e7cc741a276ff34a34daced9269f88 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 13:25:37 +0800 Subject: [PATCH 032/313] fix(web): correct route path parameters for namespace and skill detail - Changed route paths from @$namespace to /$namespace to match TanStack Router syntax - Added @ prefix stripping in API calls to match backend expectations - Fixed useParams from parameter to match new route paths - URL format remains /@namespace/slug but route captures @namespace as parameter --- web/src/app/router.tsx | 4 ++-- web/src/pages/namespace.tsx | 2 +- web/src/pages/skill-detail.tsx | 2 +- web/src/shared/hooks/use-skill-queries.ts | 26 ++++++++++++++++------- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 0ec2a676c..6b6933189 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -96,13 +96,13 @@ const searchRoute = createRoute({ const namespaceRoute = createRoute({ getParentRoute: () => rootRoute, - path: '@$namespace', + path: '/$namespace', component: NamespacePage, }) const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, - path: '@$namespace/$slug', + path: '/$namespace/$slug', component: SkillDetailPage, }) diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index 62f47eaa4..e78e3957e 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -7,7 +7,7 @@ import { useNamespaceDetail, useSearchSkills } from '@/shared/hooks/use-skill-qu export function NamespacePage() { const navigate = useNavigate() - const { namespace } = useParams({ from: '/@$namespace' }) + const { namespace } = useParams({ from: '/$namespace' }) const { data: namespaceData, isLoading: isLoadingNamespace } = useNamespaceDetail(namespace) const { data: skillsData, isLoading: isLoadingSkills } = useSearchSkills({ diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 1dc4b9953..a574082f6 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -22,7 +22,7 @@ export function SkillDetailPage() { const navigate = useNavigate() const location = useRouterState({ select: (s) => s.location }) const queryClient = useQueryClient() - const { namespace, slug } = useParams({ from: '/@$namespace/$slug' }) + const { namespace, slug } = useParams({ from: '/$namespace/$slug' }) const { user, hasRole } = useAuth() const { data: skill, isLoading: isLoadingSkill } = useSkillDetail(namespace, slug) diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index a5124c6b1..e3b776921 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -5,7 +5,10 @@ import { fetchJson, fetchText, getCsrfHeaders, meApi } from '@/api/client' async function searchSkills(params: SearchParams): Promise> { const queryParams = new URLSearchParams() if (params.q) queryParams.append('q', params.q) - if (params.namespace) queryParams.append('namespace', params.namespace) + if (params.namespace) { + const cleanNamespace = params.namespace.startsWith('@') ? params.namespace.slice(1) : params.namespace + queryParams.append('namespace', cleanNamespace) + } if (params.sort) queryParams.append('sort', params.sort) if (params.page !== undefined) queryParams.append('page', String(params.page)) if (params.size !== undefined) queryParams.append('size', String(params.size)) @@ -14,21 +17,25 @@ async function searchSkills(params: SearchParams): Promise { - return fetchJson(`/api/v1/skills/${namespace}/${slug}`) + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + return fetchJson(`/api/v1/skills/${cleanNamespace}/${slug}`) } async function getSkillVersions(namespace: string, slug: string): Promise { - const page = await fetchJson>(`/api/v1/skills/${namespace}/${slug}/versions`) + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + const page = await fetchJson>(`/api/v1/skills/${cleanNamespace}/${slug}/versions`) return page.items } async function getSkillFiles(namespace: string, slug: string, version: string): Promise { - return fetchJson(`/api/v1/skills/${namespace}/${slug}/versions/${version}/files`) + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + return fetchJson(`/api/v1/skills/${cleanNamespace}/${slug}/versions/${version}/files`) } async function getSkillReadme(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace try { - return await fetchText(`/api/v1/skills/${namespace}/${slug}/versions/${version}/file?path=SKILL.md`) + return await fetchText(`/api/v1/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=SKILL.md`) } catch { return '' } @@ -48,20 +55,23 @@ async function getMyNamespaces(): Promise { } async function getNamespaceDetail(slug: string): Promise { - return fetchJson(`/api/v1/namespaces/${slug}`) + const cleanSlug = slug.startsWith('@') ? slug.slice(1) : slug + return fetchJson(`/api/v1/namespaces/${cleanSlug}`) } async function getNamespaceMembers(slug: string): Promise { - const page = await fetchJson>(`/api/v1/namespaces/${slug}/members`) + const cleanSlug = slug.startsWith('@') ? slug.slice(1) : slug + const page = await fetchJson>(`/api/v1/namespaces/${cleanSlug}/members`) return page.items } async function publishSkill(params: { namespace: string; file: File; visibility: string }): Promise { + const cleanNamespace = params.namespace.startsWith('@') ? params.namespace.slice(1) : params.namespace const formData = new FormData() formData.append('file', params.file) formData.append('visibility', params.visibility) - return fetchJson(`/api/v1/skills/${params.namespace}/publish`, { + return fetchJson(`/api/v1/skills/${cleanNamespace}/publish`, { method: 'POST', headers: getCsrfHeaders(), body: formData, From 773ae7ad556acb257d8d20f59ff6154bc97e7bd7 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 13:26:19 +0800 Subject: [PATCH 033/313] fix(web): correct terminology for reviews menu item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed "我的评论" to "审核管理" to accurately reflect that this is the review management page, not comments page. --- web/src/i18n/locales/en.json | 2 +- web/src/i18n/locales/zh.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1818bcd4d..ce1383a33 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -45,7 +45,7 @@ "mySkills": "My Skills", "myNamespaces": "My Namespaces", "stars": "Starred", - "reviews": "Reviews", + "reviews": "Review Management", "security": "Security Settings", "accounts": "Account Merge", "logout": "Logout" diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index c12de188a..2a77aee3c 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -45,7 +45,7 @@ "mySkills": "我的技能", "myNamespaces": "我的命名空间", "stars": "我的星标", - "reviews": "我的评论", + "reviews": "审核管理", "security": "安全设置", "accounts": "账号合并", "logout": "退出登录" From f93e686b0b3f12a817f6b41c0c13347fff7da5b3 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 13:29:02 +0800 Subject: [PATCH 034/313] feat(web): add role-based menu item visibility - Added platformRoles to User interface in UserMenu - Implemented role checks for menu items: - Reviews: SKILL_ADMIN, NAMESPACE_ADMIN, SUPER_ADMIN - Promotions: SKILL_ADMIN, SUPER_ADMIN - User Management: USER_ADMIN, SUPER_ADMIN - Audit Log: AUDITOR, SUPER_ADMIN - Added translations for new menu items - Menu items now only show for users with appropriate permissions --- web/src/i18n/locales/en.json | 3 ++ web/src/i18n/locales/zh.json | 3 ++ web/src/shared/components/user-menu.tsx | 41 ++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ce1383a33..922c51a70 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -46,6 +46,9 @@ "myNamespaces": "My Namespaces", "stars": "Starred", "reviews": "Review Management", + "promotions": "Promotion Management", + "users": "User Management", + "auditLog": "Audit Log", "security": "Security Settings", "accounts": "Account Merge", "logout": "Logout" diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 2a77aee3c..985093311 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -46,6 +46,9 @@ "myNamespaces": "我的命名空间", "stars": "我的星标", "reviews": "审核管理", + "promotions": "推广管理", + "users": "用户管理", + "auditLog": "审计日志", "security": "安全设置", "accounts": "账号合并", "logout": "退出登录" diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index 1042fe978..05fbbb4c0 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -11,6 +11,7 @@ import { interface User { displayName: string avatarUrl?: string + platformRoles?: string[] } interface UserMenuProps { @@ -21,6 +22,12 @@ export function UserMenu({ user }: UserMenuProps) { const { t } = useTranslation() const navigate = useNavigate() + const hasRole = (role: string) => user.platformRoles?.includes(role) ?? false + const isReviewer = hasRole('SKILL_ADMIN') || hasRole('NAMESPACE_ADMIN') || hasRole('SUPER_ADMIN') + const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') + const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN') + const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN') + const handleLogout = async () => { try { await fetch('/api/auth/logout', { method: 'POST' }) @@ -69,11 +76,35 @@ export function UserMenu({ user }: UserMenuProps) { {t('user.menu.stars')} - - - {t('user.menu.reviews')} - - + {isReviewer && ( + + + {t('user.menu.reviews')} + + + )} + {isSkillAdmin && ( + + + {t('user.menu.promotions')} + + + )} + {(isUserAdmin || isAuditor) && } + {isUserAdmin && ( + + + {t('user.menu.users')} + + + )} + {isAuditor && ( + + + {t('user.menu.auditLog')} + + + )} From 3e9a657eb1869c814959c48b067585436e7cb072 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 13:31:12 +0800 Subject: [PATCH 035/313] fix(web): fix skill detail page issues - Fixed double @ in namespace display (@@global -> @global) - Added download button functionality to download skill package - Download button is disabled when no version is available - Cleaned namespace parameter before constructing download URL --- web/src/pages/skill-detail.tsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index a574082f6..f1dbf032f 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -53,6 +53,15 @@ export function SkillDetailPage() { onSuccess: refreshSkill, }) + const handleDownload = () => { + if (!latestVersion) { + return + } + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + const downloadUrl = `/api/v1/skills/${cleanNamespace}/${slug}/versions/${latestVersion.version}/download` + window.open(downloadUrl, '_blank') + } + const requireLogin = () => { navigate({ to: '/login', @@ -87,7 +96,7 @@ export function SkillDetailPage() {
    - +

    {skill.displayName}

    {skill.summary && ( @@ -189,7 +198,7 @@ export function SkillDetailPage() {
    命名空间
    - +
    @@ -214,7 +223,13 @@ export function SkillDetailPage() { )} -
    )} + + setDeleteDialog({ ...deleteDialog, open })} + title="删除 Token" + description={`确定要删除 Token "${deleteDialog.name}" 吗?此操作无法撤销。`} + confirmText="删除" + variant="destructive" + onConfirm={confirmDelete} + />
    ) } diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 2481861a7..d591c81fe 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -6,6 +6,7 @@ import { Select } from '@/shared/ui/select' import { Label } from '@/shared/ui/label' import { Card } from '@/shared/ui/card' import { useMyNamespaces, usePublishSkill } from '@/shared/hooks/use-skill-queries' +import { toast } from '@/shared/lib/toast' export function PublishPage() { const navigate = useNavigate() @@ -18,7 +19,7 @@ export function PublishPage() { const handlePublish = async () => { if (!selectedFile || !namespaceSlug) { - alert('请选择命名空间和文件') + toast.error('请选择命名空间和文件') return } @@ -28,10 +29,10 @@ export function PublishPage() { file: selectedFile, visibility, }) - alert(`发布成功: ${result.namespace}/${result.slug}@${result.version}`) + toast.success('发布成功', `${result.namespace}/${result.slug}@${result.version} 已提交审核,等待管理员批准后即可使用`) navigate({ to: '/dashboard/skills' }) } catch (error) { - alert('发布失败: ' + (error instanceof Error ? error.message : '未知错误')) + toast.error('发布失败', error instanceof Error ? error.message : '未知错误') } } @@ -42,6 +43,21 @@ export function PublishPage() {

    上传技能包到 SkillHub

    + {/* 审核提示 */} + +
    + + + +
    +

    发布审核说明

    +

    + 技能包提交后需要经过管理员审核才能正式发布。审核通过后,您的技能将对所有用户可见。 +

    +
    +
    +
    + {/* Namespace Selector */}
    diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index 3d74e10a0..e03ae3bf1 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -4,6 +4,8 @@ import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { Textarea } from '@/shared/ui/textarea' import { Label } from '@/shared/ui/label' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { toast } from '@/shared/lib/toast' import { useReviewDetail, useApproveReview, useRejectReview } from '@/features/review/use-review-detail' export function ReviewDetailPage() { @@ -17,39 +19,45 @@ export function ReviewDetailPage() { const [comment, setComment] = useState('') const [showRejectForm, setShowRejectForm] = useState(false) + const [approveDialog, setApproveDialog] = useState(false) + const [rejectDialog, setRejectDialog] = useState(false) const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString('zh-CN') } - const handleApprove = () => { - if (window.confirm('确定要通过这个审核吗?')) { - approveMutation.mutate( - { taskId, comment: comment || undefined }, - { - onSuccess: () => { - navigate({ to: '/dashboard/reviews' }) - }, - } - ) - } + const handleApprove = async () => { + approveMutation.mutate( + { taskId, comment: comment || undefined }, + { + onSuccess: () => { + toast.success('审核已通过') + navigate({ to: '/dashboard/reviews' }) + }, + onError: () => { + toast.error('审核失败') + }, + } + ) } - const handleReject = () => { + const handleReject = async () => { if (!comment.trim()) { - alert('拒绝审核时必须填写原因') + toast.error('拒绝审核时必须填写原因') return } - if (window.confirm('确定要拒绝这个审核吗?')) { - rejectMutation.mutate( - { taskId, comment }, - { - onSuccess: () => { - navigate({ to: '/dashboard/reviews' }) - }, - } - ) - } + rejectMutation.mutate( + { taskId, comment }, + { + onSuccess: () => { + toast.success('审核已拒绝') + navigate({ to: '/dashboard/reviews' }) + }, + onError: () => { + toast.error('拒绝失败') + }, + } + ) } if (isLoading) { diff --git a/web/src/shared/components/confirm-dialog.tsx b/web/src/shared/components/confirm-dialog.tsx new file mode 100644 index 000000000..3bd57f7dc --- /dev/null +++ b/web/src/shared/components/confirm-dialog.tsx @@ -0,0 +1,56 @@ +import { ReactNode } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { Button } from '@/shared/ui/button' + +interface ConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string | ReactNode + confirmText?: string + cancelText?: string + variant?: 'default' | 'destructive' + onConfirm: () => void | Promise +} + +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmText = '确认', + cancelText = '取消', + variant = 'default', + onConfirm, +}: ConfirmDialogProps) { + const handleConfirm = async () => { + await onConfirm() + onOpenChange(false) + } + + return ( + + + + {title} + {description && {description}} + + + + + + + + ) +} diff --git a/web/src/shared/components/toaster.tsx b/web/src/shared/components/toaster.tsx new file mode 100644 index 000000000..68fe8e356 --- /dev/null +++ b/web/src/shared/components/toaster.tsx @@ -0,0 +1,22 @@ +import { Toaster as Sonner } from 'sonner' + +export function Toaster() { + return ( + + ) +} diff --git a/web/src/shared/lib/toast.ts b/web/src/shared/lib/toast.ts new file mode 100644 index 000000000..a5b9607bd --- /dev/null +++ b/web/src/shared/lib/toast.ts @@ -0,0 +1,26 @@ +import { toast as sonnerToast } from 'sonner' + +export const toast = { + success: (message: string, description?: string) => { + sonnerToast.success(message, { description }) + }, + error: (message: string, description?: string) => { + sonnerToast.error(message, { description }) + }, + warning: (message: string, description?: string) => { + sonnerToast.warning(message, { description }) + }, + info: (message: string, description?: string) => { + sonnerToast.info(message, { description }) + }, + promise: ( + promise: Promise, + options: { + loading: string + success: string | ((data: T) => string) + error: string | ((error: Error) => string) + } + ) => { + return sonnerToast.promise(promise, options) + }, +} From 877775a82e5d75072af1170df14a996a870dc9c0 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 13:42:57 +0800 Subject: [PATCH 037/313] fix(web): fix i18n language switching and add translations - Fixed language switcher to handle language codes with region (zh-CN -> zh) - Added translations for publish page, toast, and dialog components - Added i18n keys for all new UI text - Created TODO.md to track pending frontend improvements Pending tasks: - Complete i18n for all remaining components - Implement unified API error handling - Replace remaining alert/confirm calls --- web/TODO.md | 71 +++++++++++++++++++ web/src/i18n/locales/en.json | 35 +++++++++ web/src/i18n/locales/zh.json | 35 +++++++++ .../shared/components/language-switcher.tsx | 6 +- 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 web/TODO.md diff --git a/web/TODO.md b/web/TODO.md new file mode 100644 index 000000000..8351aa622 --- /dev/null +++ b/web/TODO.md @@ -0,0 +1,71 @@ +# SkillHub 前端待办事项 + +## 高优先级 + +### 1. i18n 语言切换 Bug +- **问题**: 切换到 English 没有反应 +- **位置**: `src/shared/components/language-switcher.tsx` +- **需要检查**: + - i18next 配置是否正确 + - 语言切换事件是否正确触发 + - 翻译文件是否正确加载 + +### 2. 完善 i18n 支持 +- **需要添加翻译的组件**: + - Toast 通知消息 + - ConfirmDialog 对话框 + - 发布页面的审核提示 + - 审核详情页面 + - Token 管理页面 + - 所有新增的 UI 文本 + +### 3. 统一异常处理 +- **需求**: 为所有 API 调用添加统一的错误处理 +- **实现方案**: + - 创建 API 拦截器 + - 自动显示错误 toast + - 处理 401/403 等特殊状态码 + - 统一错误消息格式 + +### 4. 完善 Toast 通知 +- **需要替换的地方**: + - 所有剩余的 `alert()` 调用 + - 所有剩余的 `window.confirm()` 调用 + - 添加加载状态的 toast + - 添加 promise toast 用于异步操作 + +## 中优先级 + +### 5. 审核详情页对话框 +- **位置**: `src/pages/dashboard/review-detail.tsx` +- **需要**: 完成 ConfirmDialog 的集成 +- **状态**: 部分完成,需要添加对话框到 JSX + +### 6. 错误边界 +- **需求**: 添加 React Error Boundary +- **功能**: 捕获组件错误并显示友好提示 + +### 7. 加载状态优化 +- **需求**: 统一加载状态的显示 +- **实现**: 创建全局加载组件 + +## 低优先级 + +### 8. 性能优化 +- 代码分割 +- 懒加载 +- 图片优化 + +### 9. 可访问性 +- ARIA 标签 +- 键盘导航 +- 屏幕阅读器支持 + +## 已完成 + +- ✅ 创建 Toast 通知系统 +- ✅ 创建 ConfirmDialog 组件 +- ✅ 发布页面添加审核提示 +- ✅ Token 列表使用 SPA 对话框 +- ✅ 发布页面使用 Toast 通知 +- ✅ 为用户添加 SKILL_ADMIN 角色 diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 922c51a70..7fc858380 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -62,5 +62,40 @@ "privacy": "Privacy Policy", "terms": "Terms of Service", "copyright": "© 2024 SkillHub. All rights reserved." + }, + "publish": { + "title": "Publish Skill", + "subtitle": "Upload skill package to SkillHub", + "reviewNotice": { + "title": "Review Notice", + "description": "Submitted skill packages require admin review before publication. Once approved, your skill will be visible to all users." + }, + "namespace": "Namespace", + "selectNamespace": "Select namespace", + "visibility": "Visibility", + "visibilityOptions": { + "public": "Public", + "namespaceOnly": "Namespace Only", + "private": "Private" + }, + "file": "Skill Package File", + "publishing": "Publishing...", + "confirm": "Confirm Publish", + "success": "Published Successfully", + "successDescription": "{{skill}} has been submitted for review and will be available after admin approval", + "error": "Publish Failed", + "selectRequired": "Please select namespace and file" + }, + "toast": { + "success": "Success", + "error": "Error", + "warning": "Warning", + "info": "Info" + }, + "dialog": { + "confirm": "Confirm", + "cancel": "Cancel", + "delete": "Delete", + "close": "Close" } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 985093311..60b0a9e2f 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -62,5 +62,40 @@ "privacy": "隐私政策", "terms": "服务条款", "copyright": "© 2024 SkillHub. 保留所有权利。" + }, + "publish": { + "title": "发布技能", + "subtitle": "上传技能包到 SkillHub", + "reviewNotice": { + "title": "发布审核说明", + "description": "技能包提交后需要经过管理员审核才能正式发布。审核通过后,您的技能将对所有用户可见。" + }, + "namespace": "命名空间", + "selectNamespace": "选择命名空间", + "visibility": "可见性", + "visibilityOptions": { + "public": "公开", + "namespaceOnly": "仅命名空间", + "private": "私有" + }, + "file": "技能包文件", + "publishing": "发布中...", + "confirm": "确认发布", + "success": "发布成功", + "successDescription": "{{skill}} 已提交审核,等待管理员批准后即可使用", + "error": "发布失败", + "selectRequired": "请选择命名空间和文件" + }, + "toast": { + "success": "成功", + "error": "错误", + "warning": "警告", + "info": "信息" + }, + "dialog": { + "confirm": "确认", + "cancel": "取消", + "delete": "删除", + "close": "关闭" } } diff --git a/web/src/shared/components/language-switcher.tsx b/web/src/shared/components/language-switcher.tsx index fdda5aaac..7d21ee051 100644 --- a/web/src/shared/components/language-switcher.tsx +++ b/web/src/shared/components/language-switcher.tsx @@ -16,7 +16,9 @@ export function LanguageSwitcher() { { code: 'en', name: 'English' }, ] - const currentLanguage = languages.find((lang) => lang.code === i18n.language) || languages[0] + // 获取当前语言的主要代码(去掉地区代码) + const currentLangCode = i18n.language?.split('-')[0] || 'zh' + const currentLanguage = languages.find((lang) => lang.code === currentLangCode) || languages[0] const changeLanguage = (langCode: string) => { i18n.changeLanguage(langCode) @@ -35,7 +37,7 @@ export function LanguageSwitcher() { changeLanguage(lang.code)} - className={i18n.language === lang.code ? 'bg-accent' : ''} + className={currentLangCode === lang.code ? 'bg-accent' : ''} > {lang.name} From 885d572095f055830aa780984412b13229c52d7f Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 13:55:59 +0800 Subject: [PATCH 038/313] feat(web): add unified API error handling and complete i18n coverage - Add ApiError class with status code for structured error handling - Add global QueryCache/MutationCache error handlers with auto-toast - Auto-redirect to login on 401, show appropriate messages for 403/404/5xx - Add i18n to review-detail.tsx, token-list.tsx, confirm-dialog.tsx - Add review, token, apiError translation keys to zh.json and en.json --- web/src/api/client.ts | 27 ++++-- web/src/app/providers.tsx | 26 +++++- web/src/features/token/token-list.tsx | 36 ++++---- web/src/i18n/locales/en.json | 61 ++++++++++++++ web/src/i18n/locales/zh.json | 61 ++++++++++++++ web/src/pages/dashboard/publish.tsx | 47 +++++------ web/src/pages/dashboard/review-detail.tsx | 87 +++++++++++++------- web/src/shared/components/confirm-dialog.tsx | 12 ++- web/src/shared/lib/api-error.ts | 46 +++++++++++ 9 files changed, 317 insertions(+), 86 deletions(-) create mode 100644 web/src/shared/lib/api-error.ts diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 4d328692b..2d90cd0eb 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -19,6 +19,9 @@ import type { OAuthProvider, User, } from './types' +import { ApiError } from '@/shared/lib/api-error' + +export { ApiError } const client = createClient({ baseUrl: '' }) @@ -57,17 +60,17 @@ function hasDataProperty(value: unknown): value is { data: T } { async function unwrap(promise: Promise<{ data?: T; error?: unknown; response: Response }>): Promise { const { data, error, response } = await promise if (response.status === 401) { - throw new Error('HTTP 401') + throw new ApiError('HTTP 401', 401) } if (error) { - throw new Error(`HTTP ${response.status}`) + throw new ApiError(`HTTP ${response.status}`, response.status) } if (data === undefined) { - throw new Error(`HTTP ${response.status}`) + throw new ApiError(`HTTP ${response.status}`, response.status) } if (isApiEnvelope(data)) { if (data.code !== 0) { - throw new Error(data.msg || `HTTP ${response.status}`) + throw new ApiError(data.msg || `HTTP ${response.status}`, response.status, data.msg) } return data.data } @@ -90,20 +93,26 @@ type ApiEnvelope = { } export async function fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise { - const response = await fetch(input, init) + let response: Response + try { + response = await fetch(input, init) + } catch { + throw new ApiError('Network error', 0) + } + let json: ApiEnvelope | null = null try { json = (await response.json()) as ApiEnvelope } catch { if (!response.ok) { - throw new Error(`HTTP ${response.status}`) + throw new ApiError(`HTTP ${response.status}`, response.status) } - throw new Error('Invalid JSON response') + throw new ApiError('Invalid JSON response', response.status) } if (!response.ok || json.code !== 0) { - throw new Error(json.msg || `HTTP ${response.status}`) + throw new ApiError(json.msg || `HTTP ${response.status}`, response.status, json.msg) } return json.data @@ -127,7 +136,7 @@ export async function getCurrentUser(): Promise { platformRoles: user.platformRoles ?? [], } } catch (error) { - if (error instanceof Error && error.message === 'HTTP 401') { + if (error instanceof ApiError && error.status === 401) { return null } throw error diff --git a/web/src/app/providers.tsx b/web/src/app/providers.tsx index 39f611d03..53adaa5ef 100644 --- a/web/src/app/providers.tsx +++ b/web/src/app/providers.tsx @@ -1,16 +1,36 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query' import { RouterProvider } from '@tanstack/react-router' import { Toaster } from '@/shared/components/toaster' +import { handleApiError } from '@/shared/lib/api-error' import { router } from './router' const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 5 * 60 * 1000, // 5 分钟 - retry: 1, + staleTime: 5 * 60 * 1000, + retry: (failureCount, error) => { + // Don't retry on 401/403/404 + if (error instanceof Error && /HTTP (401|403|404)/.test(error.message)) { + return false + } + return failureCount < 1 + }, refetchOnWindowFocus: false, }, }, + queryCache: new QueryCache({ + onError: (error) => { + handleApiError(error) + }, + }), + mutationCache: new MutationCache({ + onError: (error, _variables, _context, mutation) => { + // Only auto-handle if the mutation doesn't have its own onError + if (!mutation.options.onError) { + handleApiError(error) + } + }, + }), }) export function App() { diff --git a/web/src/features/token/token-list.tsx b/web/src/features/token/token-list.tsx index 6e64d921a..9e9a53fb6 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' import { tokenApi } from '@/api/client' import { Button } from '@/shared/ui/button' import { @@ -16,6 +17,7 @@ import { toast } from '@/shared/lib/toast' import type { ApiToken } from '@/api/types' export function TokenList() { + const { t } = useTranslation() const queryClient = useQueryClient() const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; tokenId?: number; name?: string }>({ open: false, @@ -30,10 +32,10 @@ export function TokenList() { mutationFn: (tokenId: number) => tokenApi.deleteToken(tokenId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tokens'] }) - toast.success('Token 已删除') + toast.success(t('token.deleteSuccess')) }, onError: () => { - toast.error('删除失败') + toast.error(t('token.deleteFailed')) }, }) @@ -53,34 +55,34 @@ export function TokenList() { } if (isLoading) { - return
    加载中...
    + return
    {t('token.loading')}
    } return (
    -

    API Tokens

    +

    {t('token.title')}

    - +
    {!tokens || tokens.length === 0 ? (
    -

    还没有创建任何 Token

    -

    点击上方按钮创建第一个 Token

    +

    {t('token.empty')}

    +

    {t('token.emptyHint')}

    ) : (
    - 名称 - Token 前缀 - 创建时间 - 最后使用 - 过期时间 - 操作 + {t('token.name')} + {t('token.prefix')} + {t('token.createdAt')} + {t('token.lastUsed')} + {t('token.expiresAt')} + {t('token.actions')} @@ -102,7 +104,7 @@ export function TokenList() { onClick={() => handleDelete(token.id, token.name)} disabled={deleteMutation.isPending} > - 删除 + {t('token.delete')} @@ -115,9 +117,9 @@ export function TokenList() { setDeleteDialog({ ...deleteDialog, open })} - title="删除 Token" - description={`确定要删除 Token "${deleteDialog.name}" 吗?此操作无法撤销。`} - confirmText="删除" + title={t('token.deleteTitle')} + description={t('token.deleteDescription', { name: deleteDialog.name })} + confirmText={t('dialog.delete')} variant="destructive" onConfirm={confirmDelete} /> diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 7fc858380..8bf860033 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -97,5 +97,66 @@ "cancel": "Cancel", "delete": "Delete", "close": "Close" + }, + "review": { + "detail": "Review Detail", + "id": "Review ID", + "backToList": "Back to List", + "namespace": "Namespace / Slug", + "version": "Version", + "status": "Status", + "statusPending": "Pending", + "statusApproved": "Approved", + "statusRejected": "Rejected", + "submitter": "Submitted By", + "submitTime": "Submitted At", + "reviewer": "Reviewed By", + "reviewTime": "Reviewed At", + "reviewComment": "Review Comment", + "actions": "Review Actions", + "commentLabel": "Comment (optional)", + "commentPlaceholder": "Enter review comment...", + "approve": "Approve", + "reject": "Reject", + "confirmReject": "Confirm Reject", + "cancelReject": "Cancel", + "rejectReasonRequired": "A reason is required when rejecting", + "approveTitle": "Approve Review", + "approveDescription": "Are you sure you want to approve this review?", + "approveConfirm": "Approve", + "rejectTitle": "Reject Review", + "rejectDescription": "Are you sure you want to reject this review?", + "rejectConfirm": "Reject", + "approveSuccess": "Review approved", + "approveFailed": "Approval failed", + "rejectSuccess": "Review rejected", + "rejectFailed": "Rejection failed", + "notFound": "Review task not found" + }, + "token": { + "title": "API Tokens", + "createNew": "Create Token", + "empty": "No tokens created yet", + "emptyHint": "Click the button above to create your first token", + "name": "Name", + "prefix": "Token Prefix", + "createdAt": "Created At", + "lastUsed": "Last Used", + "expiresAt": "Expires At", + "actions": "Actions", + "delete": "Delete", + "deleteTitle": "Delete Token", + "deleteDescription": "Are you sure you want to delete token \"{{name}}\"? This action cannot be undone.", + "deleteSuccess": "Token deleted", + "deleteFailed": "Delete failed", + "loading": "Loading..." + }, + "apiError": { + "unauthorized": "Session expired, please log in again", + "forbidden": "You don't have permission for this action", + "notFound": "The requested resource was not found", + "serverError": "Server error, please try again later", + "networkError": "Network connection failed, please check your network", + "unknown": "Operation failed" } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 60b0a9e2f..9425b890e 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -97,5 +97,66 @@ "cancel": "取消", "delete": "删除", "close": "关闭" + }, + "review": { + "detail": "审核详情", + "id": "审核 ID", + "backToList": "返回列表", + "namespace": "命名空间/标识", + "version": "版本", + "status": "状态", + "statusPending": "待审核", + "statusApproved": "已通过", + "statusRejected": "已拒绝", + "submitter": "提交者", + "submitTime": "提交时间", + "reviewer": "审核者", + "reviewTime": "审核时间", + "reviewComment": "审核意见", + "actions": "审核操作", + "commentLabel": "审核意见(可选)", + "commentPlaceholder": "填写审核意见...", + "approve": "通过审核", + "reject": "拒绝审核", + "confirmReject": "确认拒绝", + "cancelReject": "取消", + "rejectReasonRequired": "拒绝审核时必须填写原因", + "approveTitle": "通过审核", + "approveDescription": "确定要通过这个审核吗?", + "approveConfirm": "通过", + "rejectTitle": "拒绝审核", + "rejectDescription": "确定要拒绝这个审核吗?", + "rejectConfirm": "拒绝", + "approveSuccess": "审核已通过", + "approveFailed": "审核失败", + "rejectSuccess": "审核已拒绝", + "rejectFailed": "拒绝失败", + "notFound": "审核任务不存在" + }, + "token": { + "title": "API Tokens", + "createNew": "创建新 Token", + "empty": "还没有创建任何 Token", + "emptyHint": "点击上方按钮创建第一个 Token", + "name": "名称", + "prefix": "Token 前缀", + "createdAt": "创建时间", + "lastUsed": "最后使用", + "expiresAt": "过期时间", + "actions": "操作", + "delete": "删除", + "deleteTitle": "删除 Token", + "deleteDescription": "确定要删除 Token \"{{name}}\" 吗?此操作无法撤销。", + "deleteSuccess": "Token 已删除", + "deleteFailed": "删除失败", + "loading": "加载中..." + }, + "apiError": { + "unauthorized": "登录已过期,请重新登录", + "forbidden": "没有权限执行此操作", + "notFound": "请求的资源不存在", + "serverError": "服务器错误,请稍后重试", + "networkError": "网络连接失败,请检查网络", + "unknown": "操作失败" } } diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index d591c81fe..faa3cf880 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' import { UploadZone } from '@/features/publish/upload-zone' import { Button } from '@/shared/ui/button' import { Select } from '@/shared/ui/select' @@ -9,6 +10,7 @@ import { useMyNamespaces, usePublishSkill } from '@/shared/hooks/use-skill-queri import { toast } from '@/shared/lib/toast' export function PublishPage() { + const { t } = useTranslation() const navigate = useNavigate() const [selectedFile, setSelectedFile] = useState(null) const [namespaceSlug, setNamespaceSlug] = useState('') @@ -19,7 +21,7 @@ export function PublishPage() { const handlePublish = async () => { if (!selectedFile || !namespaceSlug) { - toast.error('请选择命名空间和文件') + toast.error(t('publish.selectRequired')) return } @@ -29,39 +31,40 @@ export function PublishPage() { file: selectedFile, visibility, }) - toast.success('发布成功', `${result.namespace}/${result.slug}@${result.version} 已提交审核,等待管理员批准后即可使用`) + toast.success( + t('publish.success'), + t('publish.successDescription', { + skill: `${result.namespace}/${result.slug}@${result.version}`, + }) + ) navigate({ to: '/dashboard/skills' }) } catch (error) { - toast.error('发布失败', error instanceof Error ? error.message : '未知错误') + toast.error(t('publish.error'), error instanceof Error ? error.message : '') } } return (
    -

    发布技能

    -

    上传技能包到 SkillHub

    +

    {t('publish.title')}

    +

    {t('publish.subtitle')}

    - {/* 审核提示 */}
    -

    发布审核说明

    -

    - 技能包提交后需要经过管理员审核才能正式发布。审核通过后,您的技能将对所有用户可见。 -

    +

    {t('publish.reviewNotice.title')}

    +

    {t('publish.reviewNotice.description')}

    - {/* Namespace Selector */}
    - + {isLoadingNamespaces ? (
    ) : ( @@ -70,7 +73,7 @@ export function PublishPage() { value={namespaceSlug} onChange={(e) => setNamespaceSlug(e.target.value)} > - + {namespaces?.map((ns) => (
    - {/* Visibility Selector */}
    - +
    - {/* Upload Zone */}
    - + - {/* Publish Button */} + {publishMutation.isPending ? t('publish.publishing') : t('publish.confirm')} +
    ) -} +} \ No newline at end of file diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index e03ae3bf1..2a2031a2f 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { useNavigate, useParams } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { Textarea } from '@/shared/ui/textarea' @@ -11,6 +12,7 @@ import { useReviewDetail, useApproveReview, useRejectReview } from '@/features/r export function ReviewDetailPage() { const { id } = useParams({ from: '/dashboard/reviews/$id' }) const navigate = useNavigate() + const { t } = useTranslation() const taskId = Number(id) const { data: review, isLoading } = useReviewDetail(taskId) @@ -31,11 +33,11 @@ export function ReviewDetailPage() { { taskId, comment: comment || undefined }, { onSuccess: () => { - toast.success('审核已通过') + toast.success(t('review.approveSuccess')) navigate({ to: '/dashboard/reviews' }) }, onError: () => { - toast.error('审核失败') + toast.error(t('review.approveFailed')) }, } ) @@ -43,18 +45,18 @@ export function ReviewDetailPage() { const handleReject = async () => { if (!comment.trim()) { - toast.error('拒绝审核时必须填写原因') + toast.error(t('review.rejectReasonRequired')) return } rejectMutation.mutate( { taskId, comment }, { onSuccess: () => { - toast.success('审核已拒绝') + toast.success(t('review.rejectSuccess')) navigate({ to: '/dashboard/reviews' }) }, onError: () => { - toast.error('拒绝失败') + toast.error(t('review.rejectFailed')) }, } ) @@ -72,7 +74,7 @@ export function ReviewDetailPage() { if (!review) { return (
    -

    审核任务不存在

    +

    {t('review.notFound')}

    ) } @@ -81,22 +83,22 @@ export function ReviewDetailPage() {
    -

    审核详情

    -

    审核 ID: {review.id}

    +

    {t('review.detail')}

    +

    {t('review.id')}: {review.id}

    - +

    {review.namespace}/{review.skillSlug}

    - +

    {review.version} @@ -104,35 +106,35 @@ export function ReviewDetailPage() {

    - +

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

    - +

    {review.submittedByName || review.submittedBy}

    - +

    {formatDate(review.submittedAt)}

    {review.reviewedBy && ( <>
    - +

    {review.reviewedByName || review.reviewedBy}

    - +

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

    @@ -143,7 +145,7 @@ export function ReviewDetailPage() { {review.reviewComment && (
    - +

    {review.reviewComment}

    )} @@ -151,13 +153,13 @@ export function ReviewDetailPage() { {review.status === 'PENDING' && ( -

    审核操作

    +

    {t('review.actions')}

    - +