diff --git a/README.md b/README.md index a5c0fb36..3a01513a 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ with the Skill's source and the problem it solves, or submit a PR by following t - 📖 **[User Guide](https://iflytek.github.io/skillhub/)** — Skill publishing, search, CLI usage and other user guides - 🛠️ **[Developer Docs](https://zread.ai/iflytek/skillhub)** — Architecture, API reference, local development, deployment and operations +- 🐍 **[Python Examples](./examples/python)** — Search, download, and publish skills from Python via the REST API ## Highlights @@ -410,6 +411,18 @@ Run it against a local backend: ./scripts/smoke-test.sh http://localhost:8080 ``` +Local Compose and staging runs can keep using one backend URL. For an ingress +deployment where the public URL exposes application APIs but keeps Actuator on +the backend service, set a separate Actuator target: + +```bash +ACTUATOR_BASE_URL=http://skillhub-server:8080 \ + ./scripts/smoke-test.sh https://skillhub.example.com +``` + +The health check requires an Actuator JSON response, so an HTML SPA fallback is +reported as a routing or target error instead of a successful health response. + Admin label-management smoke checks run only when current admin credentials are supplied explicitly: diff --git a/README_zh.md b/README_zh.md index 6f655763..90ee1ae9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -50,6 +50,7 @@ Skill,欢迎分享给 SkillHub 社区,与大家一起丰富开放、实用 - 📖 **[用户指南](https://iflytek.github.io/skillhub/)** — 技能发布、搜索、CLI 使用等用户操作指南 - 🛠️ **[开发者文档](https://zread.ai/iflytek/skillhub)** — 架构设计、API 参考、本地开发、部署运维等技术文档 +- 🐍 **[Python 示例](./examples/python)** — 使用 REST API 在 Python 中搜索、下载和发布技能 ## 核心特性 diff --git a/examples/python/README.md b/examples/python/README.md new file mode 100644 index 00000000..39f0e7a2 --- /dev/null +++ b/examples/python/README.md @@ -0,0 +1,85 @@ +# SkillHub Python Examples + +A minimal, dependency-light (`requests`-only) Python client and runnable +examples for the SkillHub REST API. Use it to **search, inspect, download, +and publish** skills from Python — the same operations the ClawHub CLI +performs, without shelling out to the CLI. + +> These are reference examples, not (yet) an officially published pip +> package. See [iflytek/skillhub#701](https://github.com/iflytek/skillhub/issues/701) +> for the discussion on whether to ship a full published SDK. + +## Files + +| File | What it is | +|------|------------| +| [`skillhub_client.py`](./skillhub_client.py) | A small `SkillHubClient` class wrapping the REST API | +| [`example_usage.py`](./example_usage.py) | Runnable script: search → resolve → download, and publish | +| [`requirements.txt`](./requirements.txt) | The only dependency: `requests` | + +## Setup + +```bash +pip install -r requirements.txt + +# Point at your SkillHub instance +export SKILLHUB_URL=https://skill.example.com +# Only needed for write operations (publish / star / rate) +export SKILLHUB_TOKEN= +``` + +Generate an API token from the SkillHub web UI (**Settings → API Tokens**) or +via `POST /api/v1/tokens`. + +## Quick start + +```python +from skillhub_client import SkillHubClient + +client = SkillHubClient() # reads SKILLHUB_URL / SKILLHUB_TOKEN from env + +# Search public skills +results = client.search(keyword="email", size=5) + +# Inspect and resolve a version +detail = client.get_skill("my-namespace", "my-skill") +resolved = client.resolve("my-namespace", "my-skill", tag="stable") + +# Download the latest package (returns the written file path) +path = client.download("my-namespace", "my-skill") + +# Publish a skill package (requires a token) +client.publish("./my-skill.zip", namespace="my-namespace") +``` + +Or run the end-to-end script: + +```bash +python example_usage.py # search + inspect + download +python example_usage.py publish ./my-skill.zip my-namespace +``` + +## Supported operations + +| Method | Endpoint | Auth | +|--------|----------|------| +| `search(keyword, namespace, page, size)` | `GET /api/v1/skills` | — | +| `get_skill(namespace, slug)` | `GET /api/v1/skills/{ns}/{slug}` | — | +| `list_versions(namespace, slug)` | `GET /api/v1/skills/{ns}/{slug}/versions` | — | +| `resolve(namespace, slug, version, tag)` | `GET /api/v1/skills/{ns}/{slug}/resolve` | — | +| `download(namespace, slug, version, dest)` | `GET /api/v1/skills/{ns}/{slug}[/versions/{v}]/download` | — | +| `whoami()` | `GET /api/v1/whoami` | Bearer | +| `publish(zip_path, namespace, request_id)` | `POST /api/v1/publish` | Bearer | +| `star(namespace, slug)` | `POST /api/v1/skills/{ns}/{slug}/star` | Bearer | +| `rate(namespace, slug, score)` | `POST /api/v1/skills/{ns}/{slug}/rating` | Bearer | + +The client unwraps the unified `{code, msg, data}` response envelope +automatically and raises `SkillHubError` on a non-zero business code. + +## Notes + +- Write operations accept an optional `request_id` (a UUID) that is sent as + the `X-Request-Id` header for idempotency. +- For the full API surface (namespaces, reviews, promotion, tags), see the + [Developer Docs → API](https://iflytek.github.io/skillhub/) and + [`document/docs/04-developer/api`](../../document/docs/04-developer/api). diff --git a/examples/python/example_usage.py b/examples/python/example_usage.py new file mode 100644 index 00000000..71540caa --- /dev/null +++ b/examples/python/example_usage.py @@ -0,0 +1,90 @@ +"""Runnable examples for the SkillHub Python client. + +Configure the target registry via environment variables: + + export SKILLHUB_URL=https://skill.example.com + export SKILLHUB_TOKEN= # only needed for write operations + +Then run: + + python example_usage.py # search + inspect + download + python example_usage.py publish ./my-skill.zip my-namespace +""" + +from __future__ import annotations + +import os +import sys + +from skillhub_client import SkillHubClient, SkillHubError + + +def _pick(obj, *keys, default=None): + """Best-effort field access across slightly different response shapes.""" + for key in keys: + if isinstance(obj, dict) and obj.get(key) is not None: + return obj[key] + return default + + +def demo_read(client: SkillHubClient) -> None: + print(f"Searching {client.base_url} for skills matching 'email'...\n") + result = client.search(keyword="email", size=5) + + # The search payload may expose the list under 'items' or 'results'. + items = _pick(result, "items", "results", default=result if isinstance(result, list) else []) + if not items: + print("No skills found. Try a different keyword or registry.") + return + + for skill in items: + name = _pick(skill, "name", "slug", default="(unnamed)") + ns = _pick(skill, "namespace", default="") + version = _pick(skill, "version", "latestVersion", default="?") + downloads = _pick(skill, "downloadCount", "downloads", default=0) + coord = f"{ns}/{name}" if ns else name + print(f" - {coord} v{version} ({downloads} downloads)") + + # Download the first result's latest package. + first = items[0] + ns = _pick(first, "namespace", default="") + slug = _pick(first, "slug", "name") + if ns and slug: + print(f"\nResolving latest version of {ns}/{slug}...") + resolved = client.resolve(ns, slug) + version = _pick(resolved, "version", default=None) + print(f" resolved version: {version}") + + dest = client.download(ns, slug, version=version) + size = os.path.getsize(dest) + print(f" downloaded -> {dest} ({size} bytes)") + + +def demo_publish(client: SkillHubClient, zip_path: str, namespace: str) -> None: + if not client.token: + sys.exit("Publishing requires SKILLHUB_TOKEN to be set.") + print(f"Publishing {zip_path} to namespace '{namespace}'...") + result = client.publish(zip_path, namespace) + print(f" published: {result}") + + +def main() -> None: + try: + client = SkillHubClient() + except ValueError as exc: + sys.exit(str(exc)) + + args = sys.argv[1:] + try: + if args and args[0] == "publish": + if len(args) != 3: + sys.exit("usage: python example_usage.py publish ") + demo_publish(client, args[1], args[2]) + else: + demo_read(client) + except SkillHubError as exc: + sys.exit(f"API error: {exc}") + + +if __name__ == "__main__": + main() diff --git a/examples/python/requirements.txt b/examples/python/requirements.txt new file mode 100644 index 00000000..e8691f91 --- /dev/null +++ b/examples/python/requirements.txt @@ -0,0 +1 @@ +requests>=2.25 diff --git a/examples/python/skillhub_client.py b/examples/python/skillhub_client.py new file mode 100644 index 00000000..f684ed95 --- /dev/null +++ b/examples/python/skillhub_client.py @@ -0,0 +1,244 @@ +"""A minimal Python client for the SkillHub REST API. + +This is a dependency-light reference client (only ``requests``) that mirrors +the operations the ClawHub CLI performs: search, inspect, resolve, download +and publish skills. It is meant as a copy-pasteable starting point for Python +integrations, not (yet) an officially published package. + +API reference: https://iflytek.github.io/skillhub/ (Developer Docs -> API) + +Endpoints used (see docs/04-developer/api): + Public (no auth): + GET /api/v1/skills?keyword=&namespace=&page=&size= + GET /api/v1/skills/{namespace}/{slug} + GET /api/v1/skills/{namespace}/{slug}/versions + GET /api/v1/skills/{namespace}/{slug}/resolve?version=&tag= + GET /api/v1/skills/{namespace}/{slug}/download + GET /api/v1/skills/{namespace}/{slug}/versions/{version}/download + Authenticated (Bearer token): + GET /api/v1/whoami + POST /api/v1/publish (multipart: file, namespace) + POST /api/v1/skills/{namespace}/{slug}/star + POST /api/v1/skills/{namespace}/{slug}/rating (json: {"score": 1-5}) +""" + +from __future__ import annotations + +import os +from typing import Any, Dict, Optional + +import requests + + +class SkillHubError(RuntimeError): + """Raised when the API returns a non-zero business code.""" + + def __init__(self, code: Any, message: str, request_id: Optional[str] = None): + self.code = code + self.request_id = request_id + super().__init__(f"SkillHub API error {code}: {message}" + + (f" (requestId={request_id})" if request_id else "")) + + +class SkillHubClient: + """Thin wrapper over the SkillHub REST API. + + Args: + base_url: Registry base URL, e.g. ``https://skill.example.com``. + token: Optional API token for authenticated calls (Bearer). + timeout: Per-request timeout in seconds. + session: Optional pre-configured ``requests.Session``. + """ + + def __init__( + self, + base_url: Optional[str] = None, + token: Optional[str] = None, + timeout: int = 30, + session: Optional[requests.Session] = None, + ): + base_url = base_url or os.environ.get("SKILLHUB_URL") + if not base_url: + raise ValueError( + "base_url is required (pass it explicitly or set SKILLHUB_URL)" + ) + self.base_url = base_url.rstrip("/") + self.token = token or os.environ.get("SKILLHUB_TOKEN") + self.timeout = timeout + self.session = session or requests.Session() + + # -- internals ------------------------------------------------------- + + def _headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: + headers: Dict[str, str] = {} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + if extra: + headers.update(extra) + return headers + + def _url(self, path: str) -> str: + return f"{self.base_url}{path}" + + def _unwrap(self, resp: requests.Response) -> Any: + """Return the payload, unwrapping the ``{code,msg,data}`` envelope. + + Native ``/api/v1`` endpoints wrap responses in a unified envelope, + while the CLI-compat endpoints return the object directly. This + handles both. + """ + resp.raise_for_status() + payload = resp.json() + if isinstance(payload, dict) and "code" in payload and "data" in payload: + if payload.get("code") not in (0, None): + raise SkillHubError( + payload.get("code"), payload.get("msg", ""), payload.get("requestId") + ) + return payload["data"] + return payload + + # -- public API ------------------------------------------------------ + + def search( + self, + keyword: Optional[str] = None, + namespace: Optional[str] = None, + page: int = 1, + size: int = 20, + ) -> Any: + """Search public skills.""" + params = {"keyword": keyword, "namespace": namespace, "page": page, "size": size} + params = {k: v for k, v in params.items() if v is not None} + return self._unwrap( + self.session.get( + self._url("/api/v1/skills"), + params=params, + headers=self._headers(), + timeout=self.timeout, + ) + ) + + def get_skill(self, namespace: str, slug: str) -> Any: + """Fetch a single skill's detail.""" + return self._unwrap( + self.session.get( + self._url(f"/api/v1/skills/{namespace}/{slug}"), + headers=self._headers(), + timeout=self.timeout, + ) + ) + + def list_versions(self, namespace: str, slug: str) -> Any: + """List all versions of a skill.""" + return self._unwrap( + self.session.get( + self._url(f"/api/v1/skills/{namespace}/{slug}/versions"), + headers=self._headers(), + timeout=self.timeout, + ) + ) + + def resolve( + self, + namespace: str, + slug: str, + version: Optional[str] = None, + tag: Optional[str] = None, + ) -> Any: + """Resolve a version constraint / tag to a concrete version.""" + params = {"version": version, "tag": tag} + params = {k: v for k, v in params.items() if v is not None} + return self._unwrap( + self.session.get( + self._url(f"/api/v1/skills/{namespace}/{slug}/resolve"), + params=params, + headers=self._headers(), + timeout=self.timeout, + ) + ) + + def download( + self, + namespace: str, + slug: str, + version: Optional[str] = None, + dest: Optional[str] = None, + ) -> str: + """Download a skill package (zip). Returns the written file path. + + If ``version`` is omitted the ``latest`` package is downloaded. If + ``dest`` is omitted a file named ``{slug}-{version}.zip`` (or + ``{slug}.zip``) is written to the current directory. + """ + if version: + path = f"/api/v1/skills/{namespace}/{slug}/versions/{version}/download" + else: + path = f"/api/v1/skills/{namespace}/{slug}/download" + if dest is None: + dest = f"{slug}-{version}.zip" if version else f"{slug}.zip" + with self.session.get( + self._url(path), headers=self._headers(), timeout=self.timeout, stream=True + ) as resp: + resp.raise_for_status() + with open(dest, "wb") as fh: + for chunk in resp.iter_content(chunk_size=8192): + if chunk: + fh.write(chunk) + return dest + + # -- authenticated API ---------------------------------------------- + + def whoami(self) -> Any: + """Return the authenticated principal (requires a token).""" + return self._unwrap( + self.session.get( + self._url("/api/v1/whoami"), + headers=self._headers(), + timeout=self.timeout, + ) + ) + + def publish( + self, zip_path: str, namespace: str, request_id: Optional[str] = None + ) -> Any: + """Publish a skill package (zip) to a namespace. Requires a token. + + Pass ``request_id`` (a UUID) to make the publish idempotent via the + ``X-Request-Id`` header. + """ + extra = {"X-Request-Id": request_id} if request_id else None + with open(zip_path, "rb") as fh: + files = {"file": (os.path.basename(zip_path), fh, "application/zip")} + data = {"namespace": namespace} + return self._unwrap( + self.session.post( + self._url("/api/v1/publish"), + files=files, + data=data, + headers=self._headers(extra), + timeout=self.timeout, + ) + ) + + def star(self, namespace: str, slug: str) -> Any: + """Star a skill. Requires a token.""" + return self._unwrap( + self.session.post( + self._url(f"/api/v1/skills/{namespace}/{slug}/star"), + headers=self._headers(), + timeout=self.timeout, + ) + ) + + def rate(self, namespace: str, slug: str, score: int) -> Any: + """Rate a skill from 1 to 5. Requires a token.""" + if not 1 <= score <= 5: + raise ValueError("score must be between 1 and 5") + return self._unwrap( + self.session.post( + self._url(f"/api/v1/skills/{namespace}/{slug}/rating"), + json={"score": score}, + headers=self._headers(), + timeout=self.timeout, + ) + ) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index a523aafe..78852f1b 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -2,16 +2,18 @@ set -euo pipefail BASE_URL="${1:-http://localhost:8080}" +ACTUATOR_BASE_URL="${ACTUATOR_BASE_URL:-$BASE_URL}" PASS=0 FAIL=0 -COOKIE_JAR="$(mktemp)" +TMP_DIR="$(mktemp -d)" +COOKIE_JAR="$TMP_DIR/cookies" USERNAME="smoketest_$(date +%s)" EMAIL="${USERNAME}@example.com" PASSWORD="Smoke@2026" NEW_PASSWORD="Smoke@2027" cleanup() { - rm -f "$COOKIE_JAR" + rm -rf "$TMP_DIR" } trap cleanup EXIT @@ -31,6 +33,56 @@ check() { fi } +check_health() { + local desc="$1" + local url="$2" + local body_file="$TMP_DIR/health-body" + local result + local status + local content_type + result="$(curl --retry 3 --retry-delay 1 --max-time 10 -sS -o "$body_file" \ + -w "%{http_code}|%{content_type}" "$url" || true)" + status="${result%%|*}" + content_type="${result#*|}" + + if [[ "$content_type" == text/html* ]]; then + echo "FAIL: $desc (routing/target error: received $content_type from $url)" + FAIL=$((FAIL + 1)) + elif [[ "$status" == "200" \ + && ( "$content_type" == application/json* || "$content_type" == application/*+json* ) \ + && -f "$body_file" \ + && "$(grep -Ec '"status"[[:space:]]*:' "$body_file" || true)" -gt 0 ]]; then + echo "PASS: $desc (HTTP $status, $content_type)" + PASS=$((PASS + 1)) + else + echo "FAIL: $desc (expected HTTP 200 actuator JSON, got HTTP $status, ${content_type:-no content type})" + FAIL=$((FAIL + 1)) + fi +} + +check_protected_actuator() { + local desc="$1" + local url="$2" + local result + local status + local content_type + result="$(curl --retry 3 --retry-delay 1 --max-time 10 -sS -o /dev/null \ + -w "%{http_code}|%{content_type}" "$url" || true)" + status="${result%%|*}" + content_type="${result#*|}" + + if [[ "$content_type" == text/html* ]]; then + echo "FAIL: $desc (routing/target error: received $content_type from $url)" + FAIL=$((FAIL + 1)) + elif [[ "$status" == "401" ]]; then + echo "PASS: $desc (HTTP $status)" + PASS=$((PASS + 1)) + else + echo "FAIL: $desc (expected 401, got $status)" + FAIL=$((FAIL + 1)) + fi +} + finish() { echo echo "Results: $PASS passed, $FAIL failed" @@ -38,11 +90,12 @@ finish() { } echo "=== SkillHub Smoke Test ===" -echo "Target: $BASE_URL" +echo "API target: $BASE_URL" +echo "Actuator target: $ACTUATOR_BASE_URL" echo -check "Health endpoint" "$BASE_URL/actuator/health" "200" -check "Prometheus metrics requires auth" "$BASE_URL/actuator/prometheus" "401" +check_health "Health endpoint" "$ACTUATOR_BASE_URL/actuator/health" +check_protected_actuator "Prometheus metrics requires auth" "$ACTUATOR_BASE_URL/actuator/prometheus" check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401" check "Auth required" "$BASE_URL/api/v1/auth/me" "401" diff --git a/scripts/tests/smoke-test-admin-mode-test.sh b/scripts/tests/smoke-test-admin-mode-test.sh index aa0c6d9e..672b1a58 100755 --- a/scripts/tests/smoke-test-admin-mode-test.sh +++ b/scripts/tests/smoke-test-admin-mode-test.sh @@ -26,6 +26,7 @@ data="" cookie_in="" cookie_out="" write_code=false +write_format="" output_file="" while (($#)); do case "$1" in @@ -47,6 +48,7 @@ while (($#)); do ;; -w) write_code=true + write_format="$2" shift 2 ;; -o) @@ -76,8 +78,16 @@ fi printf '%s\n' "$method $url $data" >>"${SMOKE_CURL_LOG:?SMOKE_CURL_LOG is required}" status=200 +content_type="application/json" +body='{}' case "$url" in - */actuator/health) status=200 ;; + https://public.example/actuator/health|https://public.example/actuator/prometheus) + content_type="text/html" + body='SkillHub' + ;; + */actuator/health) + body='{"status":"UP"}' + ;; */actuator/prometheus) status=401 ;; */api/v1/namespaces) if [[ -n "$cookie_in" && -f "$cookie_in.session" ]]; then status=200; else status=401; fi @@ -108,24 +118,35 @@ case "$url" in esac if [[ "$output_file" != "/dev/null" && -n "$output_file" ]]; then - printf '{}\n' >"$output_file" + printf '%s\n' "$body" >"$output_file" fi if [[ "$write_code" == true ]]; then - printf '%s' "$status" + if [[ "$write_format" == *content_type* ]]; then + printf '%s|%s' "$status" "$content_type" + else + printf '%s' "$status" + fi fi EOF chmod +x "$TMP_DIR/bin/curl" -run_smoke() { +run_smoke_at() { local name="$1" - shift + local base_url="$2" + shift 2 local log="$TMP_DIR/$name.curl.log" local out="$TMP_DIR/$name.out" local status=0 - env PATH="$TMP_DIR/bin:$PATH" SMOKE_CURL_LOG="$log" "$@" "$SMOKE_SCRIPT" http://skillhub.test >"$out" 2>&1 || status=$? + env PATH="$TMP_DIR/bin:$PATH" SMOKE_CURL_LOG="$log" "$@" "$SMOKE_SCRIPT" "$base_url" >"$out" 2>&1 || status=$? printf '%s\n' "$status" } +run_smoke() { + local name="$1" + shift + run_smoke_at "$name" http://skillhub.test "$@" +} + status="$(run_smoke skip-admin env)" [[ "$status" == "0" ]] || fail "default smoke without admin credentials should pass" grep -Fq "SKIP: Admin label management" "$TMP_DIR/skip-admin.out" \ @@ -153,4 +174,22 @@ if grep -Fq 'ChangeMe!2026' "$TMP_DIR/explicit-admin.curl.log"; then fail "admin login must not fall back to the bootstrap default password" fi +status="$(run_smoke_at split-targets https://public.example env \ + ACTUATOR_BASE_URL=http://actuator.internal:8080 SMOKE_ADMIN_CHECKS=false)" +[[ "$status" == "0" ]] || fail "split public and actuator targets should pass" +grep -Fq "GET http://actuator.internal:8080/actuator/health" "$TMP_DIR/split-targets.curl.log" \ + || fail "health check should use ACTUATOR_BASE_URL" +grep -Fq "GET http://actuator.internal:8080/actuator/prometheus" "$TMP_DIR/split-targets.curl.log" \ + || fail "Prometheus check should use ACTUATOR_BASE_URL" +if grep -Fq "https://public.example/actuator/" "$TMP_DIR/split-targets.curl.log"; then + fail "actuator checks must not use the public API target when ACTUATOR_BASE_URL is set" +fi +grep -Fq "GET https://public.example/api/v1/auth/me" "$TMP_DIR/split-targets.curl.log" \ + || fail "application API checks should continue using BASE_URL" + +status="$(run_smoke_at html-fallback https://public.example env SMOKE_ADMIN_CHECKS=false)" +[[ "$status" != "0" ]] || fail "HTML SPA fallback must not pass as actuator health" +grep -Fq "routing/target error: received text/html" "$TMP_DIR/html-fallback.out" \ + || fail "HTML fallback should produce an actionable routing/target error" + echo "smoke-test-admin-mode-test passed" diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java index b9cfb13d..801b2ed2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java @@ -20,8 +20,10 @@ 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.domain.social.SkillStarService; +import com.iflytek.skillhub.dto.SkillLabelDto; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.service.SkillLabelProjectionService; import com.iflytek.skillhub.service.SkillSearchAppService; import java.io.IOException; import java.util.HashMap; @@ -51,6 +53,7 @@ public class ClawHubCompatAppService { private final CompatSkillLookupService compatSkillLookupService; private final SkillStarService skillStarService; private final RequestIdAccessor requestIdAccessor; + private final SkillLabelProjectionService skillLabelProjectionService; public ClawHubCompatAppService(CanonicalSlugMapper mapper, SkillSearchAppService skillSearchAppService, @@ -61,7 +64,8 @@ public class ClawHubCompatAppService { AuditLogService auditLogService, CompatSkillLookupService compatSkillLookupService, SkillStarService skillStarService, - RequestIdAccessor requestIdAccessor) { + RequestIdAccessor requestIdAccessor, + SkillLabelProjectionService skillLabelProjectionService) { this.mapper = mapper; this.skillSearchAppService = skillSearchAppService; this.skillQueryService = skillQueryService; @@ -72,6 +76,7 @@ public class ClawHubCompatAppService { this.compatSkillLookupService = compatSkillLookupService; this.skillStarService = skillStarService; this.requestIdAccessor = requestIdAccessor; + this.skillLabelProjectionService = skillLabelProjectionService; } public ClawHubSearchResponse search(String q, @@ -195,6 +200,15 @@ public class ClawHubCompatAppService { String sort, String userId, Map userNsRoles) { + return listSkills(page, limit, sort, false, userId, userNsRoles); + } + + public ClawHubSkillListResponse listSkills(int page, + int limit, + String sort, + boolean includeLabels, + String userId, + Map userNsRoles) { String sortBy = sort != null ? sort : "newest"; SkillSearchAppService.SearchResponse response = skillSearchAppService.search( "", @@ -206,8 +220,15 @@ public class ClawHubCompatAppService { userNsRoles ); + Map> labelsBySkillId = includeLabels + ? skillLabelProjectionService.labelsBySkillIds( + response.items().stream().map(SkillSummaryResponse::id).toList()) + : Map.of(); + List items = response.items().stream() - .map(this::toSkillListItem) + .map(item -> toSkillListItem( + item, + includeLabels ? labelsBySkillId.getOrDefault(item.id(), List.of()) : null)) .toList(); String nextCursor = null; @@ -383,7 +404,8 @@ public class ClawHubCompatAppService { return new ClawHubResolveResponse(matchVersion, latestVersion); } - private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) { + private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item, + List labels) { long createdAt = 0; long updatedAt = item.updatedAt() != null ? item.updatedAt().toEpochMilli() : 0; @@ -413,7 +435,8 @@ public class ClawHubCompatAppService { stats, createdAt, updatedAt, - latestVersion + latestVersion, + labels ); } 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 2d499c31..50013251 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 @@ -10,10 +10,13 @@ import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse; import com.iflytek.skillhub.compat.dto.ClawHubStarResponse; import com.iflytek.skillhub.compat.dto.ClawHubUnstarResponse; import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse; +import com.iflytek.skillhub.controller.support.IncludeOptions; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.ratelimit.RateLimit; +import io.swagger.v3.oas.annotations.Parameter; import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; +import java.util.List; import java.util.Map; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -93,9 +96,12 @@ public class ClawHubCompatController { public ClawHubSkillListResponse listSkills(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "25") int limit, @RequestParam(required = false) String sort, + @Parameter(description = "Optional response expansions. Supported value: labels") + @RequestParam(name = "include", required = false) List include, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - return clawHubCompatAppService.listSkills(page, limit, sort, userId, userNsRoles); + return clawHubCompatAppService.listSkills( + page, limit, sort, IncludeOptions.includesLabels(include), userId, userNsRoles); } @RateLimit(category = "skills", authenticated = 60, anonymous = 20) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java index 9da699a2..36d7d7b0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java @@ -3,15 +3,21 @@ package com.iflytek.skillhub.compat; 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.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import java.util.Comparator; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; /** @@ -21,6 +27,10 @@ import org.springframework.stereotype.Service; @Service public class CompatSkillLookupService { + private static final int LEGACY_SLUG_PUBLISHED_SCORE = 1_000; + private static final int LEGACY_SLUG_PUBLIC_SCORE = 100; + private static final int LEGACY_SLUG_GLOBAL_SCORE = 10; + private final SkillRepository skillRepository; private final NamespaceRepository namespaceRepository; private final SkillVersionRepository skillVersionRepository; @@ -40,10 +50,27 @@ public class CompatSkillLookupService { } public CompatSkillContext findByLegacySlug(String slug) { - Skill skill = skillRepository.findBySlug(slug).stream().findFirst() - .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", slug)); - Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) - .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", skill.getNamespaceId())); + List skills = skillRepository.findBySlug(slug); + if (skills.isEmpty()) { + throw new DomainNotFoundException("error.skill.notFound", slug); + } + // Batch-fetch all candidate namespaces in a single query to avoid N+1 lookups. + List namespaceIds = skills.stream() + .map(Skill::getNamespaceId) + .distinct() + .toList(); + Map namespacesById = namespaceIds.isEmpty() + ? Map.of() + : namespaceRepository.findByIdIn(namespaceIds).stream() + .collect(Collectors.toMap(Namespace::getId, Function.identity())); + Skill skill = skills.stream() + .min(Comparator.comparingInt(s -> -legacySlugCandidateScore(s, namespacesById)) + .thenComparing(Skill::getId)) + .orElse(skills.get(0)); + Namespace namespace = namespacesById.get(skill.getNamespaceId()); + if (namespace == null) { + throw new DomainNotFoundException("error.namespace.notFound", skill.getNamespaceId()); + } return new CompatSkillContext(namespace, skill, findLatestVersion(skill)); } @@ -86,6 +113,16 @@ public class CompatSkillLookupService { return skillVersionRepository.findById(skill.getLatestVersionId()); } + private static int legacySlugCandidateScore(Skill skill, Map namespacesById) { + Namespace namespace = namespacesById.get(skill.getNamespaceId()); + int score = 0; + // Bare-slug CLI resolution should prefer installable candidates before namespace defaults. + if (skill.getLatestVersionId() != null) score += LEGACY_SLUG_PUBLISHED_SCORE; + if (skill.getVisibility() == SkillVisibility.PUBLIC) score += LEGACY_SLUG_PUBLIC_SCORE; + if (namespace != null && namespace.getType() == NamespaceType.GLOBAL) score += LEGACY_SLUG_GLOBAL_SCORE; + return score; + } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { try { return skillSlugResolutionService.resolve( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillListResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillListResponse.java index d1f7f376..f2bdea7e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillListResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillListResponse.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.compat.dto; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.iflytek.skillhub.dto.SkillLabelDto; import java.util.List; public record ClawHubSkillListResponse( @@ -14,8 +16,27 @@ public record ClawHubSkillListResponse( Object stats, long createdAt, long updatedAt, - LatestVersion latestVersion + LatestVersion latestVersion, + /** + * Labels attached to the skill, present only when the caller passes + * {@code include=labels}. Omitted otherwise, so the legacy payload is unchanged. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + List labels ) { + + public SkillListItem( + String slug, + String displayName, + String summary, + Object tags, + Object stats, + long createdAt, + long updatedAt, + LatestVersion latestVersion) { + this(slug, displayName, summary, tags, stats, createdAt, updatedAt, latestVersion, null); + } + public record LatestVersion( String version, long createdAt, 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 36227b31..403560aa 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 @@ -1,16 +1,20 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.controller.support.IncludeOptions; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.SkillLabelDto; +import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.ratelimit.RateLimit; +import com.iflytek.skillhub.service.SkillLabelProjectionService; import com.iflytek.skillhub.service.SkillSearchAppService; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*; +import java.util.List; import java.util.Map; import java.util.regex.Pattern; @@ -27,11 +31,14 @@ public class SkillSearchController extends BaseApiController { private static final int DEFAULT_SIZE = 20; private final SkillSearchAppService skillSearchAppService; + private final SkillLabelProjectionService skillLabelProjectionService; public SkillSearchController(SkillSearchAppService skillSearchAppService, + SkillLabelProjectionService skillLabelProjectionService, ApiResponseFactory responseFactory) { super(responseFactory); this.skillSearchAppService = skillSearchAppService; + this.skillLabelProjectionService = skillLabelProjectionService; } @GetMapping @@ -40,6 +47,8 @@ public class SkillSearchController extends BaseApiController { @RequestParam(required = false) String q, @RequestParam(required = false) String namespace, @RequestParam(name = "label", required = false) java.util.List labels, + @Parameter(description = "Optional response expansions. Supported value: labels") + @RequestParam(name = "include", required = false) List include, @Parameter(schema = @Schema(defaultValue = DEFAULT_SORT)) @RequestParam(required = false) String sort, @Parameter(schema = @Schema(type = "integer", defaultValue = "0", minimum = "0")) @@ -49,6 +58,7 @@ public class SkillSearchController extends BaseApiController { @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + boolean includeLabels = IncludeOptions.includesLabels(include); SkillSearchAppService.SearchResponse response = skillSearchAppService.search( q, namespace, @@ -60,7 +70,18 @@ public class SkillSearchController extends BaseApiController { userNsRoles ); - return ok("response.success.read", response); + return ok("response.success.read", includeLabels ? withLabels(response) : response); + } + + private SkillSearchAppService.SearchResponse withLabels(SkillSearchAppService.SearchResponse response) { + Map> labelsBySkillId = skillLabelProjectionService.labelsBySkillIds( + response.items().stream().map(SkillSummaryResponse::id).toList()); + + List items = response.items().stream() + .map(item -> item.withLabels(labelsBySkillId.getOrDefault(item.id(), List.of()))) + .toList(); + + return new SkillSearchAppService.SearchResponse(items, response.total(), response.page(), response.size()); } private String normalizeSort(String sort) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/IncludeOptions.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/IncludeOptions.java new file mode 100644 index 00000000..72af5407 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/IncludeOptions.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.controller.support; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Parses optional response expansions from {@code include=...} query parameters. + */ +public final class IncludeOptions { + + private static final String LABELS = "labels"; + private static final Set SUPPORTED = Set.of(LABELS); + + private IncludeOptions() { + } + + public static boolean includesLabels(List include) { + if (include == null || include.isEmpty()) { + return false; + } + + boolean requested = false; + for (String rawValue : include) { + if (rawValue == null || rawValue.isBlank()) { + continue; + } + for (String rawOption : rawValue.split(",")) { + String option = rawOption.trim().toLowerCase(Locale.ROOT); + if (option.isBlank()) { + continue; + } + if (!SUPPORTED.contains(option)) { + throw new DomainBadRequestException("error.request.include.unsupported", option); + } + requested = true; + } + } + return requested; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index 8011a12c..8717ba62 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -1,7 +1,9 @@ package com.iflytek.skillhub.dto; +import com.fasterxml.jackson.annotation.JsonInclude; import java.math.BigDecimal; import java.time.Instant; +import java.util.List; public record SkillSummaryResponse( Long id, @@ -21,5 +23,46 @@ public record SkillSummaryResponse( SkillLifecycleVersionResponse publishedVersion, SkillLifecycleVersionResponse ownerPreviewVersion, String resolutionMode, - ComplianceSnapshotResponse complianceSnapshot -) {} + ComplianceSnapshotResponse complianceSnapshot, + /** + * Labels attached to the skill, present only when the caller asked for them. + * Left out of the payload otherwise, so responses are unchanged for callers + * that do not opt in. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + List labels +) { + + /** + * Summary without label projection. + */ + public SkillSummaryResponse( + Long id, + String slug, + String displayName, + String summary, + String visibility, + String status, + Long downloadCount, + Integer starCount, + BigDecimal ratingAvg, + Integer ratingCount, + String namespace, + Instant updatedAt, + boolean canSubmitPromotion, + SkillLifecycleVersionResponse headlineVersion, + SkillLifecycleVersionResponse publishedVersion, + SkillLifecycleVersionResponse ownerPreviewVersion, + String resolutionMode, + ComplianceSnapshotResponse complianceSnapshot) { + this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg, + ratingCount, namespace, updatedAt, canSubmitPromotion, headlineVersion, publishedVersion, + ownerPreviewVersion, resolutionMode, complianceSnapshot, null); + } + + public SkillSummaryResponse withLabels(List labels) { + return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount, + starCount, ratingAvg, ratingCount, namespace, updatedAt, canSubmitPromotion, headlineVersion, + publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelProjectionService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelProjectionService.java new file mode 100644 index 00000000..a0292f34 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelProjectionService.java @@ -0,0 +1,94 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.label.LabelDefinition; +import com.iflytek.skillhub.domain.label.LabelDefinitionService; +import com.iflytek.skillhub.domain.label.LabelTranslation; +import com.iflytek.skillhub.domain.label.SkillLabel; +import com.iflytek.skillhub.domain.label.SkillLabelService; +import com.iflytek.skillhub.dto.SkillLabelDto; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; + +/** + * Projects skill labels for a whole page of skills in a fixed number of queries. + * + *

Listing endpoints need labels for every item they return, so resolving them one + * skill at a time would issue three queries per row. This service batches the + * assignment, definition, and translation lookups instead.

+ */ +@Service +public class SkillLabelProjectionService { + + private final SkillLabelService skillLabelService; + private final LabelDefinitionService labelDefinitionService; + private final LabelLocalizationService labelLocalizationService; + + public SkillLabelProjectionService(SkillLabelService skillLabelService, + LabelDefinitionService labelDefinitionService, + LabelLocalizationService labelLocalizationService) { + this.skillLabelService = skillLabelService; + this.labelDefinitionService = labelDefinitionService; + this.labelLocalizationService = labelLocalizationService; + } + + /** + * Labels for each requested skill, keyed by skill id. Skills without labels are absent + * from the map rather than mapped to an empty list. + */ + public Map> labelsBySkillIds(List skillIds) { + if (skillIds == null || skillIds.isEmpty()) { + return Map.of(); + } + + List distinctSkillIds = skillIds.stream() + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + if (distinctSkillIds.isEmpty()) { + return Map.of(); + } + + List assignments = skillLabelService.listSkillLabelsBySkillIds(distinctSkillIds); + if (assignments.isEmpty()) { + return Map.of(); + } + + List labelIds = assignments.stream() + .map(SkillLabel::getLabelId) + .distinct() + .toList(); + Map definitionsById = labelDefinitionService.listByIds(labelIds).stream() + .collect(Collectors.toMap(LabelDefinition::getId, Function.identity())); + Map> translationsByLabelId = + labelDefinitionService.listTranslationsByLabelIds(labelIds); + + return assignments.stream() + .filter(assignment -> definitionsById.containsKey(assignment.getLabelId())) + .collect(Collectors.groupingBy( + SkillLabel::getSkillId, + Collectors.collectingAndThen( + Collectors.toList(), + skillAssignments -> skillAssignments.stream() + .map(assignment -> toDto( + definitionsById.get(assignment.getLabelId()), + translationsByLabelId)) + .sorted(Comparator.comparing(SkillLabelDto::type) + .thenComparing(SkillLabelDto::slug)) + .toList()))); + } + + private SkillLabelDto toDto(LabelDefinition definition, + Map> translationsByLabelId) { + return new SkillLabelDto( + definition.getSlug(), + definition.getType().name(), + labelLocalizationService.resolveDisplayName( + definition.getSlug(), + translationsByLabelId.getOrDefault(definition.getId(), List.of())) + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java index fce9bcc2..3b1b2c66 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java @@ -11,6 +11,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.observability.MessageObservationSupport; import com.iflytek.skillhub.storage.ObjectStorageService; +import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import java.io.IOException; @@ -26,6 +27,7 @@ import java.util.Map; public class ScanTaskConsumer extends AbstractStreamConsumer { private static final Path SCAN_TEMP_DIR = Paths.get("/tmp/skillhub-scans").toAbsolutePath().normalize(); + private final RedissonClient redissonClient; private final SecurityScanner securityScanner; private final SecurityScanService securityScanService; private final SkillVersionRepository skillVersionRepository; @@ -42,6 +44,7 @@ public class ScanTaskConsumer extends AbstractStreamConsumer= maxAttempts) { + outbox.markFailed(now, error.toString()); + repository.save(outbox); + versionRepository.findById(outbox.getVersionId()) + .filter(version -> version.getStatus() == SkillVersionStatus.SCANNING) + .ifPresent(version -> { + version.setStatus(SkillVersionStatus.SCAN_FAILED); + versionRepository.save(version); + }); + log.error("Scan task publish failed permanently: taskId={}, versionId={}, attempts={}", + outbox.getTaskId(), outbox.getVersionId(), outbox.getRetryCount(), error); + return; + } + Duration delay = retryDelay(nextAttempt); + outbox.markRetry(now, delay, error.toString()); + repository.save(outbox); + log.warn("Failed to publish scan task; will retry taskId={}, retryCount={}, nextDelay={}", + outbox.getTaskId(), outbox.getRetryCount(), delay, error); + } + + @Scheduled(cron = "0 20 2 * * ?") + @Transactional + public void cleanupSent() { + int deleted = repository.deleteSentBefore(Instant.now(clock).minus(Duration.ofDays(7))); + if (deleted > 0) { + log.info("Cleaned up {} sent scan outbox records", deleted); + } + } + + private Duration retryDelay(int retryCount) { + long seconds = Math.min(maxBackoff.toSeconds(), 1L << Math.min(retryCount, 16)); + return Duration.ofSeconds(Math.max(seconds, 1)); + } +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V44__scan_task_outbox.sql b/server/skillhub-app/src/main/resources/db/migration/V44__scan_task_outbox.sql new file mode 100644 index 00000000..2b56fb36 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V44__scan_task_outbox.sql @@ -0,0 +1,28 @@ +CREATE TABLE scan_task_outbox ( + id BIGSERIAL PRIMARY KEY, + task_id VARCHAR(100) NOT NULL, + version_id BIGINT NOT NULL, + skill_path VARCHAR(1000), + bundle_key VARCHAR(1000), + publisher_id VARCHAR(255), + status VARCHAR(20) NOT NULL, + retry_count INTEGER NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL, + lease_until TIMESTAMPTZ, + last_error VARCHAR(2000), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + entity_version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_scan_task_outbox_task_id UNIQUE (task_id), + CONSTRAINT ck_scan_task_outbox_status CHECK (status IN ('PENDING', 'SENDING', 'SENT', 'FAILED')) +); + +CREATE INDEX idx_scan_task_outbox_pending + ON scan_task_outbox (status, next_attempt_at, created_at); +CREATE INDEX idx_scan_task_outbox_lease + ON scan_task_outbox (status, lease_until); +CREATE INDEX idx_scan_task_outbox_version + ON scan_task_outbox (version_id); + +ALTER TABLE security_audit ADD COLUMN task_id VARCHAR(100); +CREATE INDEX idx_security_audit_task_id ON security_audit (task_id); \ No newline at end of file diff --git a/server/skillhub-app/src/main/resources/db/migration/V45__scan_task_outbox_metadata.sql b/server/skillhub-app/src/main/resources/db/migration/V45__scan_task_outbox_metadata.sql new file mode 100644 index 00000000..b07bb8e8 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V45__scan_task_outbox_metadata.sql @@ -0,0 +1,2 @@ +ALTER TABLE scan_task_outbox + ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 8791e6be..50493763 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -54,6 +54,7 @@ error.forbidden=Forbidden error.apiToken.scope.missing=API token is missing required scope: {0} error.apiToken.endpoint.unsupported=API token cannot access endpoint: {0} error.request.timeout=Request timed out +error.request.include.unsupported=Unsupported include option: {0} error.rateLimit.exceeded=Rate limit exceeded error.storage.unavailable=Object storage is temporarily unavailable. Please try again later. error.internal=An unexpected error occurred diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties new file mode 100644 index 00000000..8ea9d823 --- /dev/null +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -0,0 +1,179 @@ +response.success=Успешно +response.success.read=Успешно получено +response.success.created=Успешно создано +response.success.updated=Успешно обновлено +response.success.deleted=Успешно удалено +response.success.published=Успешно опубликовано +response.success.revoked=Успешно отозвано +response.success.health=Сервис работает +validation.namespace.slug.notBlank=Slug не может быть пустым +validation.namespace.slug.size=Slug должен содержать от 2 до 64 символов +validation.namespace.displayName.notBlank=Отображаемое имя не может быть пустым +validation.namespace.displayName.size=Отображаемое имя не должно превышать 128 символов +validation.namespace.description.size=Описание не должно превышать 512 символов +validation.member.userId.notNull=Требуется ID пользователя +validation.member.role.notNull=Требуется роль +validation.auth.local.username.notBlank=Имя пользователя не может быть пустым +validation.auth.local.password.notBlank=Пароль не может быть пустым +validation.auth.local.email.notBlank=Email не может быть пустым +validation.auth.local.currentPassword.notBlank=Текущий пароль не может быть пустым +validation.auth.local.newPassword.notBlank=Новый пароль не может быть пустым +validation.auth.local.email.invalid=Некорректный формат email +validation.token.name.notBlank=Имя токена не может быть пустым +validation.token.name.size=Имя токена должно содержать не более 64 символов +validation.token.expiresAt.invalid=Некорректный формат времени истечения +validation.token.expiresAt.future=Время истечения должно быть в будущем +error.token.name.duplicate=У вас уже есть токен с таким именем +error.token.notFound=Токен не найден: {0} +error.auth.required=Требуется аутентификация +error.auth.local.username.exists=Имя пользователя уже занято +error.auth.local.email.exists=Email уже занят +error.auth.local.password.tooShort=Пароль должен содержать не менее 8 символов +error.auth.local.password.tooLong=Пароль не должен превышать 128 символов +error.auth.local.password.tooWeak=Пароль должен включать не менее 3 типов символов +error.auth.local.username.invalid=Имя пользователя: 3–64 символа, только буквы, цифры или подчёркивания +error.auth.local.invalidCredentials=Неверное имя пользователя или пароль +error.auth.local.notEnabled=Вход по локальной учётной записи для этого пользователя не включён +error.auth.local.accountDisabled=Эта учётная запись отключена +error.auth.local.accountPending=Эта учётная запись ожидает активации +error.auth.local.accountMerged=Эта учётная запись объединена и больше не может использоваться для входа +error.auth.local.locked=Слишком много неудачных попыток. Повторите через {0} мин. +error.auth.login.throttled=Слишком много попыток входа. Повторите через {0} мин. +error.auth.direct.disabled=Совместимость прямой аутентификации отключена +error.auth.direct.providerUnsupported=Неподдерживаемый провайдер прямой аутентификации: {0} +error.auth.sessionBootstrap.disabled=Инициализация сессии отключена +error.auth.sessionBootstrap.providerUnsupported=Неподдерживаемый провайдер инициализации сессии: {0} +error.auth.sessionBootstrap.notAuthenticated=Внешняя аутентифицированная сессия не найдена +error.badRequest=Некорректный запрос +error.methodNotAllowed=HTTP-метод не поддерживается +error.unsupportedMediaType=Неподдерживаемый тип медиа +error.notAcceptable=Запрошенный тип ответа не поддерживается +error.forbidden=Доступ запрещён +error.apiToken.scope.missing=У API-токена отсутствует требуемая область доступа: {0} +error.apiToken.endpoint.unsupported=API-токен не может обращаться к эндпоинту: {0} +error.request.timeout=Время ожидания запроса истекло +error.rateLimit.exceeded=Превышен лимит запросов +error.storage.unavailable=Объектное хранилище временно недоступно. Повторите попытку позже. +error.internal=Произошла непредвиденная ошибка +error.slug.blank=Slug не может быть пустым +error.slug.length=Длина slug должна быть от {0} до {1} символов +error.slug.pattern=Slug может содержать только строчные буквы, цифры и дефисы и должен начинаться и заканчиваться буквой или цифрой +error.slug.doubleHyphen=Slug не может содержать два дефиса подряд +error.slug.reserved=Slug ''{0}'' зарезервирован и не может быть использован +label.definition.too_many=Достигнут лимит определений меток ({0}) +label.sort_order.empty=Полезная нагрузка обновления порядка сортировки не может быть пустой +label.translation.empty=Требуется хотя бы один перевод метки +label.translation.locale.blank=Локаль перевода метки не может быть пустой +label.translation.display_name.blank=Отображаемое имя перевода метки не может быть пустым +label.translation.locale.duplicate=Дублирующаяся локаль перевода метки: {0} +label.translation.locale.conflict=Дублирующаяся локаль перевода метки +error.namespace.slug.exists=Slug пространства имён ''{0}'' уже существует +error.namespace.id.notFound=Пространство имён не найдено: {0} +error.namespace.slug.notFound=Пространство имён не найдено: {0} +error.namespace.membership.required=Требуется членство в пространстве имён +error.namespace.global.members.platformAdmin.required=Только администраторы пользователей платформы могут просматривать участников глобального пространства имён +error.namespace.admin.required=Требуется роль владельца или администратора пространства имён +error.namespace.owner.required=Требуется роль владельца пространства имён +error.namespace.create.platformAdminRequired=Создавать пространства имён могут только SKILL_ADMIN или SUPER_ADMIN +error.namespace.delete.hasDependencies=Пространство имён нельзя удалить, пока в нём есть скиллы или записи управления +error.namespace.member.owner.assignDirect=Нельзя назначить роль OWNER напрямую +error.namespace.member.alreadyExists=Пользователь уже является участником пространства имён +error.namespace.member.notFound=Участник не найден +error.namespace.member.owner.remove=Нельзя удалить владельца пространства имён +error.namespace.member.owner.setDirect=Нельзя задать роль OWNER напрямую, используйте передачу владения +error.namespace.member.search.tooShort=Поисковый запрос должен содержать не менее 2 символов +error.namespace.owner.current.notFound=Текущий владелец не найден +error.namespace.owner.current.invalid=Текущий пользователь не является владельцем пространства имён +error.namespace.owner.new.notFound=Новый владелец не является участником пространства имён +error.skill.metadata.content.empty=Содержимое SKILL.md не может быть пустым +error.skill.metadata.frontmatter.missingStart=Отсутствует начальный маркер frontmatter ''---'' +error.skill.metadata.frontmatter.missingContent=Отсутствует содержимое frontmatter после начального маркера +error.skill.metadata.frontmatter.missingEnd=Отсутствует конечный маркер frontmatter ''---'' +error.skill.metadata.yaml.notMap=Frontmatter должен быть YAML-объектом +error.skill.metadata.yaml.invalid=Некорректный YAML во frontmatter: {0} +error.skill.metadata.requiredField.missing=Отсутствует обязательное поле: {0} +error.skill.metadata.compliance.invalid=Некорректные метаданные x-astron-compliance: {0} +error.skill.publish.publisher.notMember=Публикующий не является участником пространства имён: {0} +error.skill.publish.package.invalid=Проверка пакета не пройдена: {0} +error.skill.publish.skillMd.notFound=SKILL.md не найден +error.skill.publish.precheck.confirmRequired=Предупреждения перед публикацией требуют подтверждения:\n{0} +error.skill.publish.precheck.failed=Проверка перед публикацией не пройдена: {0} +error.security.scanner.required=Перед публикацией публичных или видимых в пространстве имён скиллов необходимо включить сканер безопасности +error.skill.publish.archived=Архивный скилл нужно восстановить перед публикацией: {0} +review.withdraw.not_pending=Отозвать можно только заявки на ревью со статусом pending: {0} +review.withdraw.not_submitter=Отозвать это ревью может только отправитель +review.approve.scan_in_progress=Сканирование безопасности ещё выполняется. Одобрение недоступно до завершения сканирования. +review_task.not_found_for_version=Не найдена ожидающая заявка на ревью для версии: {0} +error.skill.publish.summary.tooLong=Описание скилла не должно превышать {0} символов +error.skill.notFound=Скилл не найден: {0} +error.skill.access.denied=Нет доступа к скиллу: {0} +error.skill.status.notActive=Скилл не активен +error.skill.lifecycle.noPermission=Управлять этим скиллом может только владелец скилла или администратор пространства имён +error.skill.version.exists=Версия уже существует: {0} +error.skill.version.notFound=Версия не найдена: {0} +error.skill.version.notPublished=Версия не опубликована: {0} +error.skill.version.delete.unsupported=Удалять можно только версии в статусах DRAFT, UPLOADED, REJECTED или SCAN_FAILED: {0} +error.skill.version.delete.lastVersion=Нельзя удалить последнюю оставшуюся версию: {0} +error.skill.version.compare.same=Нельзя сравнить версию саму с собой +error.skill.report.reason.required=Укажите причину жалобы +error.skill.report.unavailable=Сейчас на этот скилл нельзя пожаловаться: {0} +error.skill.report.self=Нельзя пожаловаться на свой собственный скилл +error.skill.report.duplicate=У вас уже есть ожидающая жалоба на этот скилл +error.skill.report.notFound=Жалоба на скилл не найдена: {0} +error.skill.report.alreadyHandled=Эта жалоба на скилл уже обработана +error.skill.report.status.invalid=Неподдерживаемый статус жалобы на скилл: {0} +error.skill.version.latest.unavailable=Нет опубликованной версии для скилла: {0} +error.skill.version.latest.notFound=Последняя опубликованная версия не найдена +error.skill.file.notFound=Файл не найден: {0} +error.skill.tag.latest.reserved=Имя тега ''latest'' зарезервировано +error.skill.tag.latest.delete=Имя тега ''latest'' зарезервировано и не может быть удалено +error.skill.tag.notFound=Тег не найден: {0} +error.skill.tag.targetVersion.notPublished=Целевая версия должна быть опубликована +error.skill.tag.version.missing=Тег не указывает на версию: {0} +error.skill.tag.version.notFound=Версия, на которую указывает тег, не найдена: {0} +error.skill.bundle.notFound=Опубликованный пакет не найден в хранилище +error.skill.resolve.versionTag.conflict=Параметры version и tag нельзя использовать вместе +error.deviceAuth.userCode.invalid=Неверный или просроченный код пользователя +error.deviceAuth.deviceCode.expired=Код устройства истёк +error.deviceAuth.deviceCode.invalid=Код устройства истёк или недействителен +error.deviceAuth.deviceCode.used=Код устройства уже использован +error.admin.user.notFound=Пользователь не найден: {0} +error.admin.user.role.invalid=Неверная роль: {0} +error.admin.user.role.superAdmin.assignDenied=Изменять состояние роли SUPER_ADMIN может только SUPER_ADMIN +error.admin.user.systemAccount.immutable=Системные учётные записи нельзя изменять из управления пользователями +error.admin.user.status.invalid=Неверный статус пользователя: {0} +error.admin.user.status.unsupported=Здесь можно управлять только статусами ACTIVE или DISABLED +error.skill.publish.nameConflict=Опубликованный скилл с именем ''{0}'' уже существует в этом пространстве имён +error.skill.publish.nameConflict.private=Приватный скилл с именем ''{0}'' уже опубликован в этом пространстве имён +error.skill.approve.nameConflict=Нельзя одобрить: опубликованный скилл с именем ''{0}'' уже существует в этом пространстве имён +error.skill.version.submit.notUploaded=Версия ''{0}'' не в статусе UPLOADED и не может быть отправлена на ревью +error.skill.version.confirm.notUploaded=Версия ''{0}'' не в статусе UPLOADED и не может быть подтверждена +error.skill.confirm.notPrivate=Confirm-publish доступен только для PRIVATE скиллов +error.skill.version.notDownloadable=Версия ''{0}'' недоступна для скачивания +error.profile.displayName.length=Отображаемое имя должно содержать от 2 до 32 символов +error.profile.displayName.pattern=Отображаемое имя может содержать только китайские иероглифы, латинские буквы, цифры, пробелы, подчёркивания и дефисы +error.profile.noChanges=Необходимо указать хотя бы одно поле +response.profile.updated=Профиль успешно обновлён +response.profile.pendingReview=Изменения профиля отправлены на ревью +error.profileReview.notFound=Заявка на изменение профиля не найдена +error.profileReview.notPending=Эта заявка уже рассмотрена +error.profileReview.commentRequired=Требуется причина отклонения +error.profileReview.commentTooLong=Причина отклонения не должна превышать 500 символов +error.profileReview.status.invalid=Неверный статус ревью: {0} +error.profileReview.userDisabled=Нельзя применить изменения — учётная запись пользователя отключена +response.auth.password.reset.requested=Если учётная запись подходит, код подтверждения для сброса пароля отправлен. +response.auth.password.reset.confirmed=Пароль успешно сброшен. Войдите с новым паролем. +error.auth.password.reset.invalid.code=Код подтверждения недействителен или истёк. +error.auth.password.reset.not.eligible=Эта учётная запись не подходит для сброса пароля. +error.auth.password.reset.no.credential=У этой учётной записи нет локальных учётных данных. +error.auth.password.reset.email.failed=Не удалось отправить код подтверждения для сброса пароля. Повторите попытку позже. +validation.auth.password.reset.email.notBlank=Email не может быть пустым +validation.auth.password.reset.email.invalid=Некорректный формат email +validation.auth.password.reset.code.notBlank=Код подтверждения не может быть пустым +validation.auth.password.reset.code.invalid=Код подтверждения должен состоять из 6 цифр +validation.auth.password.reset.newPassword.notBlank=Новый пароль не может быть пустым +promotion.target_skill_conflict=Целевой глобальный скилл "{0}" уже существует +promotion.status.invalid=Неподдерживаемый статус продвижения: {0} +promotion.sort.field.invalid=Неподдерживаемое поле сортировки продвижения: {0} +promotion.sort.direction.invalid=Неподдерживаемое направление сортировки продвижения: {0} +promotion.sort.pending_unsupported=Ожидающие заявки на продвижение не поддерживают сортировку по времени ревью diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 0e1b3fc3..febc9c4e 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -54,6 +54,7 @@ error.forbidden=没有权限执行该操作 error.apiToken.scope.missing=API 令牌缺少所需权限范围:{0} error.apiToken.endpoint.unsupported=API 令牌无法访问接口:{0} error.request.timeout=请求超时 +error.request.include.unsupported=不支持的 include 参数:{0} error.rateLimit.exceeded=请求过于频繁,请稍后再试 error.storage.unavailable=对象存储暂时不可用,请稍后再试 error.internal=服务器内部错误 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java index 7c2a694f..b3a7f019 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java @@ -15,8 +15,13 @@ 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.domain.social.SkillStarService; +import com.iflytek.skillhub.compat.dto.ClawHubSkillListResponse; +import com.iflytek.skillhub.dto.SkillLabelDto; +import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.service.SkillLabelProjectionService; import com.iflytek.skillhub.service.SkillSearchAppService; +import java.util.List; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -31,6 +36,7 @@ class ClawHubCompatAppServiceTest { private final AuditLogService auditLogService = mock(AuditLogService.class); private final CompatSkillLookupService compatSkillLookupService = mock(CompatSkillLookupService.class); private final SkillStarService skillStarService = mock(SkillStarService.class); + private final SkillLabelProjectionService skillLabelProjectionService = mock(SkillLabelProjectionService.class); private final ClawHubCompatAppService service = new ClawHubCompatAppService( new CanonicalSlugMapper(), @@ -42,7 +48,8 @@ class ClawHubCompatAppServiceTest { auditLogService, compatSkillLookupService, skillStarService, - new RequestIdAccessor() + new RequestIdAccessor(), + skillLabelProjectionService ); @Test @@ -120,4 +127,35 @@ class ClawHubCompatAppServiceTest { assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/versions/20260707.025847/download"); } + + @Test + void listSkills_omitsLabelsByDefault() { + when(skillSearchAppService.search("", null, "newest", 0, 25, null, Map.of())) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25)); + + ClawHubSkillListResponse response = service.listSkills(0, 25, null, null, Map.of()); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).labels()).isNull(); + } + + @Test + void listSkills_returnsLabelsWhenRequested() { + when(skillSearchAppService.search("", null, "newest", 0, 25, null, Map.of())) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25)); + when(skillLabelProjectionService.labelsBySkillIds(List.of(7L))) + .thenReturn(Map.of(7L, List.of(new SkillLabelDto("automation", "RECOMMENDED", "Automation")))); + + ClawHubSkillListResponse response = service.listSkills(0, 25, null, true, null, Map.of()); + + assertThat(response.items().get(0).labels()) + .extracting(SkillLabelDto::slug) + .containsExactly("automation"); + } + + private static SkillSummaryResponse summary(Long id) { + return new SkillSummaryResponse( + id, "demo-skill", "Demo Skill", "A demo", "PUBLIC", "PUBLISHED", + 0L, 0, null, 0, "global", null, false, null, null, null, null, null); + } } 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 b07914a4..ac1abaff 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 @@ -19,7 +19,9 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; +import com.iflytek.skillhub.dto.SkillLabelDto; import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.service.SkillLabelProjectionService; import com.iflytek.skillhub.service.SkillSearchAppService; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; @@ -48,6 +50,7 @@ import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @@ -72,6 +75,9 @@ class ClawHubCompatControllerTest { @MockBean private SkillSearchAppService skillSearchAppService; + @MockBean + private SkillLabelProjectionService skillLabelProjectionService; + @MockBean private SkillQueryService skillQueryService; @@ -154,6 +160,41 @@ class ClawHubCompatControllerTest { verify(apiTokenService).touchLastUsed(same(token)); } + @Test + void listSkills_shouldOmitLabelsByDefault() throws Exception { + when(skillSearchAppService.search(eq(""), isNull(), eq("newest"), eq(0), eq(25), isNull(), isNull())) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25)); + + mockMvc.perform(get("/api/v1/skills")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].slug").value("demo-skill")) + .andExpect(jsonPath("$.items[0].labels").doesNotExist()); + } + + @Test + void listSkills_shouldReturnLabelsWhenIncluded() throws Exception { + when(skillSearchAppService.search(eq(""), isNull(), eq("newest"), eq(0), eq(25), isNull(), isNull())) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25)); + when(skillLabelProjectionService.labelsBySkillIds(List.of(7L))) + .thenReturn(java.util.Map.of( + 7L, + List.of(new SkillLabelDto("automation", "RECOMMENDED", "Automation")))); + + mockMvc.perform(get("/api/v1/skills").param("include", "labels")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].labels[0].slug").value("automation")) + .andExpect(jsonPath("$.items[0].labels[0].type").value("RECOMMENDED")) + .andExpect(jsonPath("$.items[0].labels[0].displayName").value("Automation")); + } + + @Test + void listSkills_shouldRejectUnsupportedIncludeOptions() throws Exception { + mockMvc.perform(get("/api/v1/skills").param("include", "labels,stats")) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(skillSearchAppService); + } + @Test void downloadQuery_withBearerToken_shouldProjectNamespaceRolesIntoRequestContext() throws Exception { ApiToken token = new ApiToken("user-7", "cli", "sk_test", "hash", "[]"); @@ -401,6 +442,28 @@ class ClawHubCompatControllerTest { return version; } + private SkillSummaryResponse summary(Long id) { + return new SkillSummaryResponse( + id, + "demo-skill", + "Demo Skill", + "A demo", + "PUBLIC", + "PUBLISHED", + 0L, + 0, + null, + 0, + "global", + null, + false, + null, + null, + null, + null, + null); + } + private UsernamePasswordAuthenticationToken superAdminAuth() { PlatformPrincipal principal = new PlatformPrincipal( "user-42", diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java index 3500ce04..b8722391 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.when; 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.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; @@ -15,6 +16,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import java.util.List; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -36,6 +38,40 @@ class CompatSkillLookupServiceTest { visibilityChecker ); + @Test + void findByLegacySlug_prefersPublicGlobalPublishedCandidate() { + Skill privateTeamSkill = skill(11L, 1L, "demo", SkillVisibility.PRIVATE, 110L); + Skill publicTeamSkill = skill(12L, 1L, "demo", SkillVisibility.PUBLIC, 120L); + Skill publicGlobalSkill = skill(13L, 2L, "demo", SkillVisibility.PUBLIC, 130L); + Namespace teamNamespace = namespace(1L, "team-a", NamespaceType.TEAM); + Namespace globalNamespace = namespace(2L, "global", NamespaceType.GLOBAL); + + when(skillRepository.findBySlug("demo")) + .thenReturn(List.of(privateTeamSkill, publicTeamSkill, publicGlobalSkill)); + when(namespaceRepository.findByIdIn(List.of(1L, 2L))).thenReturn(List.of(teamNamespace, globalNamespace)); + + CompatSkillLookupService.CompatSkillContext result = service.findByLegacySlug("demo"); + + assertThat(result.skill().getId()).isEqualTo(13L); + assertThat(result.namespace().getSlug()).isEqualTo("global"); + } + + @Test + void findByLegacySlug_prefersPublishedCandidateOverGlobalDraft() { + Skill publicGlobalDraft = skill(21L, 2L, "demo", SkillVisibility.PUBLIC, null); + Skill publicTeamPublished = skill(22L, 1L, "demo", SkillVisibility.PUBLIC, 220L); + Namespace teamNamespace = namespace(1L, "team-a", NamespaceType.TEAM); + Namespace globalNamespace = namespace(2L, "global", NamespaceType.GLOBAL); + + when(skillRepository.findBySlug("demo")).thenReturn(List.of(publicGlobalDraft, publicTeamPublished)); + when(namespaceRepository.findByIdIn(List.of(2L, 1L))).thenReturn(List.of(globalNamespace, teamNamespace)); + + CompatSkillLookupService.CompatSkillContext result = service.findByLegacySlug("demo"); + + assertThat(result.skill().getId()).isEqualTo(22L); + assertThat(result.namespace().getSlug()).isEqualTo("team-a"); + } + @Test void resolveVisible_throwsNotFoundWhenCallerCannotAccessSkill() { Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); @@ -75,4 +111,18 @@ class CompatSkillLookupServiceTest { assertThat(result.skill().getId()).isEqualTo(7L); } + + private static Skill skill(Long id, Long namespaceId, String slug, SkillVisibility visibility, Long latestVersionId) { + Skill skill = new Skill(namespaceId, slug, "owner-1", visibility); + ReflectionTestUtils.setField(skill, "id", id); + skill.setLatestVersionId(latestVersionId); + return skill; + } + + private static Namespace namespace(Long id, String slug, NamespaceType type) { + Namespace namespace = new Namespace(slug, slug, "owner-1"); + ReflectionTestUtils.setField(namespace, "id", id); + namespace.setType(type); + return namespace; + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java index bed761ad..2674fc2e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java @@ -1,6 +1,9 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.dto.SkillLabelDto; +import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.service.SkillLabelProjectionService; import com.iflytek.skillhub.service.SkillSearchAppService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -15,6 +18,7 @@ import java.util.Map; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -34,6 +38,9 @@ class SkillSearchControllerTest { @MockBean private SkillSearchAppService skillSearchAppService; + @MockBean + private SkillLabelProjectionService skillLabelProjectionService; + @Test void searchShouldUseUnifiedEnvelopeAndItemsField() throws Exception { when(skillSearchAppService.search( @@ -143,4 +150,59 @@ class SkillSearchControllerTest { .andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.size").value(20)); } + + @Test + void searchShouldOmitLabelsUnlessRequested() throws Exception { + when(skillSearchAppService.search( + eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any())) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20)); + + mockMvc.perform(get("/api/web/skills")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].slug").value("demo-skill")) + .andExpect(jsonPath("$.data.items[0].labels").doesNotExist()); + } + + @Test + void searchShouldReturnLabelsWhenRequested() throws Exception { + when(skillSearchAppService.search( + eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any())) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20)); + when(skillLabelProjectionService.labelsBySkillIds(List.of(7L))) + .thenReturn(Map.of(7L, List.of(new SkillLabelDto("automation", "TOPIC", "Automation")))); + + mockMvc.perform(get("/api/web/skills").param("include", "labels")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].labels[0].slug").value("automation")) + .andExpect(jsonPath("$.data.items[0].labels[0].type").value("TOPIC")) + .andExpect(jsonPath("$.data.items[0].labels[0].displayName").value("Automation")); + } + + @Test + void searchShouldReturnEmptyLabelArrayForSkillsWithoutLabels() throws Exception { + when(skillSearchAppService.search( + eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any())) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20)); + when(skillLabelProjectionService.labelsBySkillIds(List.of(7L))).thenReturn(Map.of()); + + mockMvc.perform(get("/api/web/skills").param("include", "labels")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].labels").isArray()) + .andExpect(jsonPath("$.data.items[0].labels").isEmpty()); + } + + @Test + void searchShouldRejectUnsupportedIncludeOptions() throws Exception { + mockMvc.perform(get("/api/web/skills").param("include", "labels,stats")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + verifyNoInteractions(skillSearchAppService); + } + + private static SkillSummaryResponse summary(Long id) { + return new SkillSummaryResponse( + id, "demo-skill", "Demo Skill", "A demo", "PUBLIC", "PUBLISHED", + 0L, 0, null, 0, "global", null, false, null, null, null, null, null); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/IncludeOptionsTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/IncludeOptionsTest.java new file mode 100644 index 00000000..7379f7c0 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/IncludeOptionsTest.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.controller.support; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.util.List; +import org.junit.jupiter.api.Test; + +class IncludeOptionsTest { + + @Test + void includesLabels_acceptsAbsentBlankRepeatedAndCommaSeparatedInputs() { + assertThat(IncludeOptions.includesLabels(null)).isFalse(); + assertThat(IncludeOptions.includesLabels(List.of("", " "))).isFalse(); + assertThat(IncludeOptions.includesLabels(List.of("labels"))).isTrue(); + assertThat(IncludeOptions.includesLabels(List.of(" LABELS "))).isTrue(); + assertThat(IncludeOptions.includesLabels(List.of("labels,"))).isTrue(); + assertThat(IncludeOptions.includesLabels(List.of("", "labels"))).isTrue(); + } + + @Test + void includesLabels_rejectsUnsupportedOptions() { + assertThatThrownBy(() -> IncludeOptions.includesLabels(List.of("labels,stats"))) + .isInstanceOf(DomainBadRequestException.class) + .hasMessage("error.request.include.unsupported"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelProjectionServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelProjectionServiceTest.java new file mode 100644 index 00000000..55fa32dc --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelProjectionServiceTest.java @@ -0,0 +1,81 @@ +package com.iflytek.skillhub.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.label.LabelDefinition; +import com.iflytek.skillhub.domain.label.LabelDefinitionService; +import com.iflytek.skillhub.domain.label.LabelType; +import com.iflytek.skillhub.domain.label.SkillLabel; +import com.iflytek.skillhub.domain.label.SkillLabelService; +import com.iflytek.skillhub.dto.SkillLabelDto; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class SkillLabelProjectionServiceTest { + + private final SkillLabelService skillLabelService = mock(SkillLabelService.class); + private final LabelDefinitionService labelDefinitionService = mock(LabelDefinitionService.class); + private final LabelLocalizationService labelLocalizationService = new LabelLocalizationService(); + + private final SkillLabelProjectionService service = new SkillLabelProjectionService( + skillLabelService, labelDefinitionService, labelLocalizationService); + + @Test + void labelsBySkillIds_groupsLabelsPerSkillInOneBatch() { + LabelDefinition automation = definition(10L, "automation", LabelType.RECOMMENDED); + LabelDefinition audited = definition(11L, "audited", LabelType.PRIVILEGED); + + when(skillLabelService.listSkillLabelsBySkillIds(List.of(1L, 2L))).thenReturn(List.of( + new SkillLabel(1L, 10L, "owner-1"), + new SkillLabel(1L, 11L, "owner-1"), + new SkillLabel(2L, 10L, "owner-2") + )); + when(labelDefinitionService.listByIds(anyList())).thenReturn(List.of(automation, audited)); + when(labelDefinitionService.listTranslationsByLabelIds(anyList())).thenReturn(Map.of()); + + Map> labels = service.labelsBySkillIds(List.of(1L, 2L)); + + // sorted by label type, then slug: PRIVILEGED before RECOMMENDED + assertEquals(List.of("audited", "automation"), labels.get(1L).stream().map(SkillLabelDto::slug).toList()); + assertEquals(List.of("automation"), labels.get(2L).stream().map(SkillLabelDto::slug).toList()); + + // One query per lookup for the whole page, not per skill. + verify(skillLabelService, times(1)).listSkillLabelsBySkillIds(anyList()); + verify(labelDefinitionService, times(1)).listByIds(anyList()); + verify(labelDefinitionService, times(1)).listTranslationsByLabelIds(anyList()); + } + + @Test + void labelsBySkillIds_skipsAssignmentsWithoutADefinition() { + when(skillLabelService.listSkillLabelsBySkillIds(anyList())) + .thenReturn(List.of(new SkillLabel(1L, 99L, "owner-1"))); + when(labelDefinitionService.listByIds(anyList())).thenReturn(List.of()); + when(labelDefinitionService.listTranslationsByLabelIds(anyList())).thenReturn(Map.of()); + + assertTrue(service.labelsBySkillIds(List.of(1L)).isEmpty()); + } + + @Test + void labelsBySkillIds_touchesNoRepositoryForAnEmptyPage() { + assertTrue(service.labelsBySkillIds(List.of()).isEmpty()); + assertTrue(service.labelsBySkillIds(null).isEmpty()); + + verify(skillLabelService, never()).listSkillLabelsBySkillIds(any()); + } + + private static LabelDefinition definition(Long id, String slug, LabelType type) { + LabelDefinition definition = new LabelDefinition(slug, type, true, 0, "admin"); + ReflectionTestUtils.setField(definition, "id", id); + return definition; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java index 8f473b65..50e73da8 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java @@ -20,6 +20,7 @@ import com.iflytek.skillhub.storage.ObjectStorageService; import io.micrometer.observation.ObservationRegistry; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.redisson.api.RLock; import org.redisson.api.RStream; import org.redisson.api.RedissonClient; import org.redisson.api.StreamMessageId; @@ -35,6 +36,7 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class ScanTaskConsumerLoggingTest { @@ -150,6 +152,15 @@ class ScanTaskConsumerLoggingTest { } } + private static RedissonClient redissonClientWithAvailableProcessingLock() { + RedissonClient redissonClient = mock(RedissonClient.class); + RLock processingLock = mock(RLock.class); + when(redissonClient.getLock(org.mockito.ArgumentMatchers.anyString())).thenReturn(processingLock); + when(processingLock.tryLock()).thenReturn(true); + when(processingLock.isHeldByCurrentThread()).thenReturn(true); + return redissonClient; + } + private static final class TestableLoggingConsumer extends ScanTaskConsumer { private final RStream stream = mock(RStream.class); @@ -159,7 +170,7 @@ class ScanTaskConsumerLoggingTest { ScanTaskProducer scanTaskProducer, ObjectStorageService objectStorageService) { super( - mock(RedissonClient.class), + redissonClientWithAvailableProcessingLock(), "skillhub:scan:requests", "skillhub-scanners", securityScanner, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java index 7f8cabaa..07d7d9d6 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java @@ -20,6 +20,7 @@ import com.iflytek.skillhub.storage.ObjectStorageService; import com.iflytek.skillhub.storage.ObjectMetadata; import io.micrometer.observation.ObservationRegistry; import org.junit.jupiter.api.Test; +import org.redisson.api.RLock; import org.redisson.api.RStream; import org.redisson.api.RedissonClient; import org.redisson.api.StreamMessageId; @@ -38,7 +39,11 @@ import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class ScanTaskConsumerTest { private static final Path SCAN_TEMP_DIR = Path.of("/tmp/skillhub-scans"); @@ -268,6 +273,88 @@ class ScanTaskConsumerTest { assertThat(listScanTempFiles(versionId)).isEmpty(); } + @Test + void processBusiness_whenTaskIsAlreadyInFlight_skipsScanAndPreservesSharedTempPath() throws Exception { + Files.createDirectories(SCAN_TEMP_DIR); + Path tempDir = Files.createTempDirectory(SCAN_TEMP_DIR, "scan-task-consumer-inflight"); + Path skillFile = Files.writeString(tempDir.resolve("SKILL.md"), "# demo"); + StubSecurityScanner securityScanner = new StubSecurityScanner(); + RLock processingLock = mock(RLock.class); + when(processingLock.tryLock()).thenReturn(false); + TestableScanTaskConsumer consumer = new TestableScanTaskConsumer( + securityScanner, + new StubSecurityScanService(), + new InMemorySkillVersionRepository(), + new InMemoryScanTaskProducer(), + new InMemoryObjectStorageService(), + redissonClient(processingLock) + ); + ScanTaskConsumer.ScanTaskPayload payload = new ScanTaskConsumer.ScanTaskPayload( + "task-inflight", 42L, tempDir.toString(), null, ScannerType.SKILL_SCANNER); + + try { + assertThatThrownBy(() -> consumer.invokeProcessBusiness(payload)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Security scan is already in progress: taskId=task-inflight"); + + assertThat(securityScanner.lastRequest).isNull(); + assertThat(skillFile).exists(); + verify(processingLock, never()).unlock(); + } finally { + Files.deleteIfExists(skillFile); + Files.deleteIfExists(tempDir); + } + } + + @Test + void handleMessage_whenTaskLockIsHeld_republishesInsteadOfDroppingDelivery() { + StubSecurityScanner securityScanner = new StubSecurityScanner(); + InMemoryScanTaskProducer producer = new InMemoryScanTaskProducer(); + RLock processingLock = mock(RLock.class); + when(processingLock.tryLock()).thenReturn(false); + TestableScanTaskConsumer consumer = new TestableScanTaskConsumer( + securityScanner, + new StubSecurityScanService(), + new InMemorySkillVersionRepository(), + producer, + new InMemoryObjectStorageService(), + redissonClient(processingLock) + ); + + consumer.handleMessage(new StreamMessageId(11, 0), Map.of( + "taskId", "task-reclaimed", + "versionId", "42", + "skillPath", "/tmp/skillhub-scans/42", + "scannerType", ScannerType.SKILL_SCANNER.getValue() + )); + + assertThat(producer.publishedTask.taskId()).isEqualTo("task-reclaimed"); + assertThat(producer.publishedTask.metadata()).containsEntry("retryCount", "1"); + verify(consumer.stream).ack("skillhub-scanners", new StreamMessageId(11, 0)); + } + + @Test + void processBusiness_whenScannerFails_releasesProcessingLock() { + StubSecurityScanner securityScanner = new StubSecurityScanner(); + securityScanner.failure = new IllegalStateException("scanner unavailable"); + RLock processingLock = availableProcessingLock(); + TestableScanTaskConsumer consumer = new TestableScanTaskConsumer( + securityScanner, + new StubSecurityScanService(), + new InMemorySkillVersionRepository(), + new InMemoryScanTaskProducer(), + new InMemoryObjectStorageService(), + redissonClient(processingLock) + ); + ScanTaskConsumer.ScanTaskPayload payload = new ScanTaskConsumer.ScanTaskPayload( + "task-failure", 42L, "/tmp/failure", null, ScannerType.SKILL_SCANNER); + + assertThatThrownBy(() -> consumer.invokeProcessBusiness(payload)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("scanner unavailable"); + verify(processingLock).unlock(); + } + private void setField(Object target, String fieldName, Object value) throws Exception { Field field = target.getClass().getDeclaredField(fieldName); field.setAccessible(true); @@ -299,7 +386,28 @@ class ScanTaskConsumerTest { ScanTaskProducer scanTaskProducer, ObjectStorageService objectStorageService) { super( - mock(RedissonClient.class), + redissonClient(availableProcessingLock()), + "skillhub:scan:requests", + "skillhub-scanners", + securityScanner, + securityScanService, + skillVersionRepository, + scanTaskProducer, + objectStorageService, + new MessageObservationSupport(ObservationRegistry.NOOP, new RequestIdAccessor()) + ); + this.stream = mock(RStream.class); + } + + @SuppressWarnings("unchecked") + private TestableScanTaskConsumer(SecurityScanner securityScanner, + SecurityScanService securityScanService, + SkillVersionRepository skillVersionRepository, + ScanTaskProducer scanTaskProducer, + ObjectStorageService objectStorageService, + RedissonClient redissonClient) { + super( + redissonClient, "skillhub:scan:requests", "skillhub-scanners", securityScanner, @@ -334,6 +442,19 @@ class ScanTaskConsumerTest { } } + private static RLock availableProcessingLock() { + RLock processingLock = mock(RLock.class); + when(processingLock.tryLock()).thenReturn(true); + when(processingLock.isHeldByCurrentThread()).thenReturn(true); + return processingLock; + } + + private static RedissonClient redissonClient(RLock processingLock) { + RedissonClient redissonClient = mock(RedissonClient.class); + when(redissonClient.getLock(org.mockito.ArgumentMatchers.anyString())).thenReturn(processingLock); + return redissonClient; + } + private static final class StubSecurityScanner implements SecurityScanner { private SecurityScanRequest lastRequest; private SecurityScanResponse response; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/ScanTaskOutboxDispatcherTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/ScanTaskOutboxDispatcherTest.java new file mode 100644 index 00000000..eafa3b6f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/ScanTaskOutboxDispatcherTest.java @@ -0,0 +1,140 @@ +package com.iflytek.skillhub.task; + +import com.iflytek.skillhub.domain.security.ScanTask; +import com.iflytek.skillhub.domain.security.ScanTaskOutbox; +import com.iflytek.skillhub.domain.security.ScanTaskOutboxRepository; +import com.iflytek.skillhub.domain.security.ScanTaskOutboxStatus; +import com.iflytek.skillhub.domain.security.ScanTaskProducer; +import com.iflytek.skillhub.domain.security.ScannerType; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class ScanTaskOutboxDispatcherTest { + @Mock ScanTaskOutboxRepository repository; + @Mock ScanTaskProducer producer; + @Mock SkillVersionRepository versionRepository; + + @Test + void failedRedisPublishLeavesTaskPendingForRetry() { + ScanTaskOutbox outbox = outbox("task-1", 1L); + given(repository.findDispatchable(any(), any(Integer.class))).willReturn(List.of(outbox)); + doThrow(new IllegalStateException("redis unavailable")).when(producer).publishScanTask(any()); + + dispatcher(10).dispatch(); + + assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.PENDING); + assertThat(outbox.getRetryCount()).isEqualTo(1); + verify(producer).publishScanTask(any()); + verify(repository).save(outbox); + } + + @Test + void successfulPublishMarksTaskSentWithoutChangingVersion() { + ScanTaskOutbox outbox = outbox("task-success", 3L); + given(repository.findDispatchable(any(), any(Integer.class))).willReturn(List.of(outbox)); + + dispatcher(10).dispatch(); + + assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.SENT); + assertThat(outbox.getRetryCount()).isZero(); + verify(producer).publishScanTask(any()); + verify(repository).save(outbox); + verifyNoInteractions(versionRepository); + } + + @Test + void lastPublishAttemptMarksOutboxAndVersionFailed() { + ScanTaskOutbox outbox = outbox("task-2", 2L); + SkillVersion version = new SkillVersion(9L, "1.0.0", "user"); + version.setStatus(SkillVersionStatus.SCANNING); + given(repository.findDispatchable(any(), any(Integer.class))).willReturn(List.of(outbox)); + given(versionRepository.findById(2L)).willReturn(Optional.of(version)); + doThrow(new IllegalStateException("redis unavailable")).when(producer).publishScanTask(any()); + + dispatcher(1).dispatch(); + + assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.FAILED); + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCAN_FAILED); + verify(versionRepository).save(version); + } + + @Test + void lastPublishAttemptDoesNotOverwriteTerminalVersionStatus() { + ScanTaskOutbox outbox = outbox("task-published", 4L); + SkillVersion version = new SkillVersion(9L, "1.0.0", "user"); + version.setStatus(SkillVersionStatus.PUBLISHED); + given(repository.findDispatchable(any(), any(Integer.class))).willReturn(List.of(outbox)); + given(versionRepository.findById(4L)).willReturn(Optional.of(version)); + doThrow(new IllegalStateException("redis unavailable")).when(producer).publishScanTask(any()); + + dispatcher(1).dispatch(); + + assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.FAILED); + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED); + verify(versionRepository, never()).save(version); + } + + @Test + void expiredLeaseCanBeReclaimedAndPublished() { + ScanTaskOutbox outbox = outbox("task-expired", 5L); + assertThat(outbox.claim(Instant.parse("2025-12-31T23:00:00Z"), Duration.ofMinutes(2))).isTrue(); + given(repository.findDispatchable(any(), any(Integer.class))).willReturn(List.of(outbox)); + + dispatcher(10).dispatch(); + + assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.SENT); + verify(producer).publishScanTask(any()); + } + + @Test + void staleFinderResultInTerminalStateIsIgnored() { + ScanTaskOutbox outbox = outbox("task-sent", 6L); + outbox.markSent(Instant.parse("2025-12-31T23:00:00Z")); + given(repository.findDispatchable(any(), any(Integer.class))).willReturn(List.of(outbox)); + + dispatcher(10).dispatch(); + + verifyNoInteractions(producer); + verify(repository, never()).save(outbox); + } + + @Test + void maxAttemptsMustBePositive() { + assertThatThrownBy(() -> dispatcher(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxAttempts"); + } + + private ScanTaskOutboxDispatcher dispatcher(int maxAttempts) { + Clock clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); + return new ScanTaskOutboxDispatcher(repository, producer, versionRepository, clock, + 50, maxAttempts, Duration.ofMinutes(2), Duration.ofMinutes(5)); + } + + private ScanTaskOutbox outbox(String taskId, Long versionId) { + return new ScanTaskOutbox(new ScanTask(taskId, versionId, "/tmp/" + versionId, null, "user", 1L, + java.util.Map.of("scannerType", ScannerType.SKILL_SCANNER.getValue()))); + } +} 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 index c72b1d9d..5cd28902 100644 --- 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 @@ -28,15 +28,26 @@ public class SkillHubOAuth2AuthorizationRequestResolver @Override public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { - OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request); - oauthLoginFlowService.rememberReturnTo(request); - return authorizationRequest; + return rememberIfAuthorizationRequest(request, delegate.resolve(request)); } @Override public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { - OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId); - oauthLoginFlowService.rememberReturnTo(request); + return rememberIfAuthorizationRequest(request, delegate.resolve(request, clientRegistrationId)); + } + + /** + * {@code OAuth2AuthorizationRequestRedirectFilter} calls the resolver on every request in the + * chain, not only on authorization requests; the delegate simply answers null for the rest. + * Recording the return target on those calls would clear it again on the very next request — + * including the provider callback, which carries no {@code returnTo} and is processed by this + * filter before authentication succeeds. Only an actual authorization request may touch it. + */ + private OAuth2AuthorizationRequest rememberIfAuthorizationRequest( + HttpServletRequest request, OAuth2AuthorizationRequest authorizationRequest) { + if (authorizationRequest != null) { + oauthLoginFlowService.rememberReturnTo(request); + } return authorizationRequest; } } 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 index 357ada33..9cef26b2 100644 --- 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 @@ -54,6 +54,25 @@ class OAuth2AuthorizationRequestResolverTest { .isEqualTo("/dashboard/publish?draft=1"); } + @Test + void resolve_keepsReturnToOnNonAuthorizationRequests() { + // The redirect filter runs the resolver on every request in the chain, the provider + // callback included. That request carries no returnTo, so treating it as an + // authorization request would clear the target before the success handler reads it. + MockHttpServletRequest authorization = new MockHttpServletRequest("GET", "/oauth2/authorization/github"); + authorization.setParameter("returnTo", "/device"); + resolver.resolve(authorization, "github"); + HttpSession session = authorization.getSession(false); + + MockHttpServletRequest callback = new MockHttpServletRequest("GET", "/login/oauth2/code/github"); + callback.setParameter("code", "auth-code"); + callback.setSession(session); + + assertThat(resolver.resolve(callback)).isNull(); + assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)) + .isEqualTo("/device"); + } + @Test void resolve_ignoresUnsafeReturnTo() { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github"); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillLabelService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillLabelService.java index 9c699cb5..b0b66095 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillLabelService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillLabelService.java @@ -38,6 +38,13 @@ public class SkillLabelService { return skillLabelRepository.findBySkillId(skillId); } + public List listSkillLabelsBySkillIds(List skillIds) { + if (skillIds == null || skillIds.isEmpty()) { + return List.of(); + } + return skillLabelRepository.findBySkillIdIn(skillIds); + } + public List listByLabelId(Long labelId) { return skillLabelRepository.findByLabelId(labelId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/ScanTaskOutbox.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/ScanTaskOutbox.java new file mode 100644 index 00000000..7da371fb --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/ScanTaskOutbox.java @@ -0,0 +1,131 @@ +package com.iflytek.skillhub.domain.security; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; + +@Entity +@Table(name = "scan_task_outbox") +public class ScanTaskOutbox { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "task_id", nullable = false, unique = true, length = 100) + private String taskId; + @Column(name = "version_id", nullable = false) + private Long versionId; + @Column(name = "skill_path", length = 1000) + private String skillPath; + @Column(name = "bundle_key", length = 1000) + private String bundleKey; + @Column(name = "publisher_id", length = 255) + private String publisherId; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "metadata", nullable = false, columnDefinition = "jsonb") + private Map metadata; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) + private ScanTaskOutboxStatus status; + @Column(name = "retry_count", nullable = false) + private int retryCount; + @Column(name = "next_attempt_at", nullable = false) + private Instant nextAttemptAt; + @Column(name = "lease_until") + private Instant leaseUntil; + @Column(name = "last_error", length = 2000) + private String lastError; + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + @Version @Column(nullable = false) + private long entityVersion; + + protected ScanTaskOutbox() { } + + public ScanTaskOutbox(ScanTask task) { + this.taskId = task.taskId(); + this.versionId = task.versionId(); + this.skillPath = task.skillPath(); + this.bundleKey = task.bundleKey(); + this.publisherId = task.publisherId(); + this.metadata = task.metadata() == null ? Map.of() : Map.copyOf(task.metadata()); + this.status = ScanTaskOutboxStatus.PENDING; + Instant taskCreatedAt = Instant.ofEpochMilli(task.createdAtMillis()); + this.nextAttemptAt = taskCreatedAt; + this.createdAt = taskCreatedAt; + this.updatedAt = taskCreatedAt; + } + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(Clock.systemUTC()); + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; + if (nextAttemptAt == null) nextAttemptAt = now; + } + + public ScanTask toScanTask() { + return new ScanTask(taskId, versionId, skillPath, bundleKey, publisherId, + createdAt.toEpochMilli(), metadata == null ? Map.of() : Map.copyOf(metadata)); + } + + public boolean claim(Instant now, Duration lease) { + if (status != ScanTaskOutboxStatus.PENDING + && !(status == ScanTaskOutboxStatus.SENDING && leaseUntil != null && leaseUntil.isBefore(now))) return false; + status = ScanTaskOutboxStatus.SENDING; + leaseUntil = now.plus(lease); + updatedAt = now; + return true; + } + + public void markSent(Instant now) { + status = ScanTaskOutboxStatus.SENT; + leaseUntil = null; + lastError = null; + updatedAt = now; + } + + public void markFailed(Instant now, String error) { + retryCount++; + status = ScanTaskOutboxStatus.FAILED; + leaseUntil = null; + lastError = truncateError(error); + updatedAt = now; + } + + public void markRetry(Instant now, Duration delay, String error) { + retryCount++; + status = ScanTaskOutboxStatus.PENDING; + nextAttemptAt = now.plus(delay); + leaseUntil = null; + lastError = truncateError(error); + updatedAt = now; + } + + public Long getId() { return id; } + public String getTaskId() { return taskId; } + public Long getVersionId() { return versionId; } + public ScanTaskOutboxStatus getStatus() { return status; } + public int getRetryCount() { return retryCount; } + public Instant getNextAttemptAt() { return nextAttemptAt; } + public Instant getLeaseUntil() { return leaseUntil; } + + private String truncateError(String error) { + return error == null ? null : error.substring(0, Math.min(error.length(), 2000)); + } + + public Instant getCreatedAt() { return createdAt; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/ScanTaskOutboxRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/ScanTaskOutboxRepository.java new file mode 100644 index 00000000..7ed29c5d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/ScanTaskOutboxRepository.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.domain.security; + +import java.time.Instant; +import java.util.List; + +public interface ScanTaskOutboxRepository { + ScanTaskOutbox save(ScanTaskOutbox outbox); + List findDispatchable(Instant now, int limit); + int deleteSentBefore(Instant cutoff); + int deleteByVersionId(Long versionId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/ScanTaskOutboxStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/ScanTaskOutboxStatus.java new file mode 100644 index 00000000..dc0fd83a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/ScanTaskOutboxStatus.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.security; + +public enum ScanTaskOutboxStatus { + PENDING, + SENDING, + SENT, + FAILED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAudit.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAudit.java index 055287f0..0b7844de 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAudit.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAudit.java @@ -26,6 +26,9 @@ public class SecurityAudit { @Column(name = "skill_version_id", nullable = false) private Long skillVersionId; + @Column(name = "task_id", length = 100) + private String taskId; + @Column(name = "scan_id", length = 100) private String scanId; @@ -66,8 +69,13 @@ public class SecurityAudit { } public SecurityAudit(Long skillVersionId, ScannerType scannerType) { + this(skillVersionId, scannerType, null); + } + + public SecurityAudit(Long skillVersionId, ScannerType scannerType, String taskId) { this.skillVersionId = skillVersionId; this.scannerType = scannerType; + this.taskId = taskId; this.verdict = SecurityVerdict.SUSPICIOUS; this.isSafe = false; this.findingsCount = 0; @@ -91,6 +99,10 @@ public class SecurityAudit { return scanId; } + public String getTaskId() { + return taskId; + } + public ScannerType getScannerType() { return scannerType; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAuditRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAuditRepository.java index 2ae16856..9bde6ea6 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAuditRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAuditRepository.java @@ -12,6 +12,8 @@ public interface SecurityAuditRepository { Optional findByScanId(String scanId); + boolean existsByTaskIdAndScannedAtIsNotNull(String taskId); + boolean existsBySkillVersionId(Long skillVersionId); /** diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java index 2d455064..df12ee8e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java @@ -9,6 +9,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,23 +33,36 @@ public class SecurityScanService { private final SecurityAuditRepository auditRepository; private final SkillVersionRepository skillVersionRepository; + private final ScanTaskOutboxRepository scanTaskOutboxRepository; private final ScanTaskProducer scanTaskProducer; private final ObjectMapper objectMapper; private final String scanMode; private final boolean enabled; + @Autowired public SecurityScanService(SecurityAuditRepository auditRepository, SkillVersionRepository skillVersionRepository, ScanTaskProducer scanTaskProducer, ObjectMapper objectMapper, @Value("${skillhub.security.scanner.mode:local}") String scanMode, - @Value("${skillhub.security.scanner.enabled:false}") boolean enabled) { + @Value("${skillhub.security.scanner.enabled:false}") boolean enabled, + ScanTaskOutboxRepository scanTaskOutboxRepository) { this.auditRepository = auditRepository; this.skillVersionRepository = skillVersionRepository; this.scanTaskProducer = scanTaskProducer; this.objectMapper = objectMapper; this.scanMode = scanMode; this.enabled = enabled; + this.scanTaskOutboxRepository = scanTaskOutboxRepository; + } + + public SecurityScanService(SecurityAuditRepository auditRepository, + SkillVersionRepository skillVersionRepository, + ScanTaskProducer scanTaskProducer, + ObjectMapper objectMapper, + String scanMode, + boolean enabled) { + this(auditRepository, skillVersionRepository, scanTaskProducer, objectMapper, scanMode, enabled, null); } public boolean isEnabled() { @@ -74,7 +88,6 @@ public class SecurityScanService { packagePath = saveTempDirectory(versionId, entries).toString(); } // Always create a new audit record — supports multiple rounds per version - auditRepository.save(new SecurityAudit(versionId, ScannerType.SKILL_SCANNER)); final ScanTask scanTask = new ScanTask( UUID.randomUUID().toString(), versionId, @@ -84,9 +97,12 @@ public class SecurityScanService { System.currentTimeMillis(), Map.of("scannerType", ScannerType.SKILL_SCANNER.getValue()) ); - // The stream consumer must not observe this task before skill_version / - // security_audit rows are committed and visible. - TransactionCommitCallbacks.afterCommitOrNow(() -> scanTaskProducer.publishScanTask(scanTask)); + auditRepository.save(new SecurityAudit(versionId, ScannerType.SKILL_SCANNER, scanTask.taskId())); + if (scanTaskOutboxRepository != null) { + scanTaskOutboxRepository.save(new ScanTaskOutbox(scanTask)); + } else { + TransactionCommitCallbacks.afterCommitOrNow(() -> scanTaskProducer.publishScanTask(scanTask)); + } // Only transition to SCANNING if the version is not already published (auto-publish flow) if (version.getStatus() != SkillVersionStatus.PUBLISHED) { version.setStatus(SkillVersionStatus.SCANNING); @@ -94,6 +110,11 @@ public class SecurityScanService { } } + public boolean isTaskAlreadyProcessed(String taskId) { + return taskId != null && auditRepository != null + && auditRepository.existsByTaskIdAndScannedAtIsNotNull(taskId); + } + @Transactional public void processScanResult(Long versionId, ScannerType scannerType, SecurityScanResponse response) { SecurityAudit audit = auditRepository.findLatestActiveByVersionIdAndScannerType(versionId, scannerType) @@ -186,6 +207,9 @@ public class SecurityScanService { */ @Transactional public void softDeleteByVersionId(Long versionId) { + if (scanTaskOutboxRepository != null) { + scanTaskOutboxRepository.deleteByVersionId(versionId); + } List audits = auditRepository.findAllActiveBySkillVersionId(versionId); if (audits.isEmpty()) { log.debug("No active security audits to soft-delete for versionId={}", versionId); @@ -203,5 +227,8 @@ public class SecurityScanService { @Transactional public void hardDeleteByVersionId(Long versionId) { auditRepository.deleteBySkillVersionId(versionId); + if (scanTaskOutboxRepository != null) { + scanTaskOutboxRepository.deleteByVersionId(versionId); + } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/package-info.java new file mode 100644 index 00000000..8abf6ca7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/package-info.java @@ -0,0 +1,2 @@ +/** Security scanning domain model and durable task dispatch ports. */ +package com.iflytek.skillhub.domain.security; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/ScanTaskOutboxTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/ScanTaskOutboxTest.java new file mode 100644 index 00000000..373baaf6 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/ScanTaskOutboxTest.java @@ -0,0 +1,56 @@ +package com.iflytek.skillhub.domain.security; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScanTaskOutboxTest { + @Test + void claimAndMarkSentProducesStableTaskPayload() { + ScanTask task = new ScanTask("task-1", 7L, "/tmp/7", null, "u1", 123L, + Map.of( + "scannerType", ScannerType.SKILL_SCANNER.getValue(), + "futureAttribute", "preserved")); + ScanTaskOutbox outbox = new ScanTaskOutbox(task); + Instant now = Instant.parse("2026-01-01T00:00:00Z"); + + assertThat(outbox.claim(now, Duration.ofMinutes(2))).isTrue(); + assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.SENDING); + outbox.markSent(now.plusSeconds(1)); + + assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.SENT); + assertThat(outbox.toScanTask()).isEqualTo(task); + } + + @Test + void exhaustedPublishAttemptsMoveTaskToFailed() { + ScanTaskOutbox outbox = new ScanTaskOutbox( + new ScanTask("task-failed", 9L, null, "bundle.zip", null, 1L, Map.of())); + Instant now = Instant.parse("2026-01-01T00:00:00Z"); + outbox.claim(now, Duration.ofMinutes(2)); + + outbox.markFailed(now, "permanent failure"); + + assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.FAILED); + assertThat(outbox.getRetryCount()).isEqualTo(1); + assertThat(outbox.getLeaseUntil()).isNull(); + assertThat(outbox.claim(now.plusSeconds(1), Duration.ofMinutes(2))).isFalse(); + } + + @Test + void failedPublishReturnsToPendingWithBackoffAndTruncatesError() { + ScanTaskOutbox outbox = new ScanTaskOutbox( + new ScanTask("task-2", 8L, null, "packages/1/8/bundle.zip", null, 1L, Map.of())); + Instant now = Instant.parse("2026-01-01T00:00:00Z"); + outbox.claim(now, Duration.ofMinutes(2)); + outbox.markRetry(now, Duration.ofSeconds(5), "x".repeat(5000)); + + assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.PENDING); + assertThat(outbox.getRetryCount()).isEqualTo(1); + assertThat(outbox.getNextAttemptAt()).isEqualTo(now.plusSeconds(5)); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanOutboxTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanOutboxTest.java new file mode 100644 index 00000000..f0e056bf --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanOutboxTest.java @@ -0,0 +1,59 @@ +package com.iflytek.skillhub.domain.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +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 java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class SecurityScanOutboxTest { + @Mock SecurityAuditRepository auditRepository; + @Mock SkillVersionRepository versionRepository; + @Mock ScanTaskProducer producer; + @Mock ScanTaskOutboxRepository outboxRepository; + + @Test + void triggerPersistsAuditStateAndOutboxWithoutPublishingInsideTransaction() throws Exception { + SkillVersion version = new SkillVersion(9L, "1.0.0", "publisher"); + Field id = SkillVersion.class.getDeclaredField("id"); + id.setAccessible(true); + id.set(version, 42L); + given(versionRepository.findById(42L)).willReturn(Optional.of(version)); + SecurityScanService service = new SecurityScanService(auditRepository, versionRepository, producer, + new ObjectMapper(), "upload", true, outboxRepository); + + service.triggerScan(42L, List.of(new PackageEntry("SKILL.md", new byte[0], 0, "text/markdown")), "publisher"); + + ArgumentCaptor outbox = ArgumentCaptor.forClass(ScanTaskOutbox.class); + verify(outboxRepository).save(outbox.capture()); + verify(producer, never()).publishScanTask(org.mockito.ArgumentMatchers.any()); + assertThat(outbox.getValue().getVersionId()).isEqualTo(42L); + assertThat(outbox.getValue().getStatus()).isEqualTo(ScanTaskOutboxStatus.PENDING); + } + + @Test + void softDeleteRemovesPendingOutboxEvenWhenNoActiveAuditExists() { + given(auditRepository.findAllActiveBySkillVersionId(42L)).willReturn(List.of()); + SecurityScanService service = new SecurityScanService(auditRepository, versionRepository, producer, + new ObjectMapper(), "upload", true, outboxRepository); + + service.softDeleteByVersionId(42L); + + verify(outboxRepository).deleteByVersionId(42L); + verify(auditRepository, never()).saveAll(org.mockito.ArgumentMatchers.anyList()); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ScanTaskOutboxJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ScanTaskOutboxJpaRepository.java new file mode 100644 index 00000000..84b08993 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ScanTaskOutboxJpaRepository.java @@ -0,0 +1,34 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.security.ScanTaskOutbox; +import com.iflytek.skillhub.domain.security.ScanTaskOutboxRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; + +@Repository +public interface ScanTaskOutboxJpaRepository extends JpaRepository, ScanTaskOutboxRepository { + @Override + @Query(value = """ + SELECT * FROM scan_task_outbox + WHERE (status = 'PENDING' AND next_attempt_at <= :now) + OR (status = 'SENDING' AND lease_until < :now) + ORDER BY created_at + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, nativeQuery = true) + List findDispatchable(@Param("now") Instant now, @Param("limit") int limit); + + @Override + @Modifying + @Query("DELETE FROM ScanTaskOutbox o WHERE o.status = com.iflytek.skillhub.domain.security.ScanTaskOutboxStatus.SENT AND o.updatedAt < :cutoff") + int deleteSentBefore(@Param("cutoff") Instant cutoff); + + @Override + int deleteByVersionId(Long versionId); +} \ No newline at end of file diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts index 867c8248..6cb0cf40 100644 --- a/web/e2e/publish-flow-ui.spec.ts +++ b/web/e2e/publish-flow-ui.spec.ts @@ -45,7 +45,9 @@ test.describe('Publish Flow UI (Real API)', () => { }) await expect(namespaceTrigger).toContainText(`@${namespace.slug}`) - await page.locator('input[type="file"]').setInputFiles(packagePath) + // The publish form also exposes a second file input for folder selection. + // Scope this regression test to the existing ZIP picker. + await page.locator('input[type="file"][accept*=".zip"]').setInputFiles(packagePath) await expect(page.getByText(path.basename(packagePath))).toBeVisible() const confirmButton = page.getByRole('button', { name: 'Confirm Publish' }) await expect(confirmButton).toBeEnabled() diff --git a/web/index.html b/web/index.html index 63a5423d..dc99437f 100644 --- a/web/index.html +++ b/web/index.html @@ -6,13 +6,14 @@ SkillHub - - - + + diff --git a/web/public/fonts/LICENSE.md b/web/public/fonts/LICENSE.md new file mode 100644 index 00000000..8dee9361 --- /dev/null +++ b/web/public/fonts/LICENSE.md @@ -0,0 +1,101 @@ +# Vendored web fonts + +The font files in this directory are vendored from Fontsource packages and +served by SkillHub to avoid a runtime dependency on Google Fonts. + +| Font | Source package | Version | License | Copyright | +| --- | --- | --- | --- | --- | +| Inter | `@fontsource/inter` | `5.3.0` | SIL Open Font License 1.1 | Copyright 2016 The Inter Project Authors | +| JetBrains Mono | `@fontsource/jetbrains-mono` | `5.3.0` | SIL Open Font License 1.1 | Copyright 2020 The JetBrains Mono Project Authors | + +The vendored `.woff2` files are unmodified copies from the Fontsource package +distributions. + +## SIL Open Font License 1.1 + +``` +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. +``` diff --git a/web/public/fonts/fonts.css b/web/public/fonts/fonts.css new file mode 100644 index 00000000..7d643dab --- /dev/null +++ b/web/public/fonts/fonts.css @@ -0,0 +1,109 @@ +/* + * Self-hosted web fonts (Inter, JetBrains Mono). + * + * Replaces the runtime dependency on fonts.googleapis.com / fonts.gstatic.com, + * which is slow or unreachable on some networks and blocks first paint + * (see #716). Files are the same woff2 Google serves, vendored from the + * @fontsource distribution. latin / latin-ext are split by unicode-range so + * the browser only fetches the subset a glyph actually needs. + */ + +/* ---------- Inter ---------- */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/inter-latin-400-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/inter-latin-ext-400-normal.woff2') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/inter-latin-500-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/inter-latin-ext-500-normal.woff2') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/fonts/inter-latin-600-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/fonts/inter-latin-ext-600-normal.woff2') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/inter-latin-700-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/inter-latin-ext-700-normal.woff2') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* ---------- JetBrains Mono ---------- */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/jetbrains-mono-latin-400-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/jetbrains-mono-latin-ext-400-normal.woff2') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/jetbrains-mono-latin-500-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/jetbrains-mono-latin-ext-500-normal.woff2') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} diff --git a/web/public/fonts/inter-latin-400-normal.woff2 b/web/public/fonts/inter-latin-400-normal.woff2 new file mode 100644 index 00000000..f15b025d Binary files /dev/null and b/web/public/fonts/inter-latin-400-normal.woff2 differ diff --git a/web/public/fonts/inter-latin-500-normal.woff2 b/web/public/fonts/inter-latin-500-normal.woff2 new file mode 100644 index 00000000..54f0a595 Binary files /dev/null and b/web/public/fonts/inter-latin-500-normal.woff2 differ diff --git a/web/public/fonts/inter-latin-600-normal.woff2 b/web/public/fonts/inter-latin-600-normal.woff2 new file mode 100644 index 00000000..d1897949 Binary files /dev/null and b/web/public/fonts/inter-latin-600-normal.woff2 differ diff --git a/web/public/fonts/inter-latin-700-normal.woff2 b/web/public/fonts/inter-latin-700-normal.woff2 new file mode 100644 index 00000000..a68fb101 Binary files /dev/null and b/web/public/fonts/inter-latin-700-normal.woff2 differ diff --git a/web/public/fonts/inter-latin-ext-400-normal.woff2 b/web/public/fonts/inter-latin-ext-400-normal.woff2 new file mode 100644 index 00000000..5243e4b2 Binary files /dev/null and b/web/public/fonts/inter-latin-ext-400-normal.woff2 differ diff --git a/web/public/fonts/inter-latin-ext-500-normal.woff2 b/web/public/fonts/inter-latin-ext-500-normal.woff2 new file mode 100644 index 00000000..2ab7ebf6 Binary files /dev/null and b/web/public/fonts/inter-latin-ext-500-normal.woff2 differ diff --git a/web/public/fonts/inter-latin-ext-600-normal.woff2 b/web/public/fonts/inter-latin-ext-600-normal.woff2 new file mode 100644 index 00000000..bad65d4b Binary files /dev/null and b/web/public/fonts/inter-latin-ext-600-normal.woff2 differ diff --git a/web/public/fonts/inter-latin-ext-700-normal.woff2 b/web/public/fonts/inter-latin-ext-700-normal.woff2 new file mode 100644 index 00000000..c9507e3f Binary files /dev/null and b/web/public/fonts/inter-latin-ext-700-normal.woff2 differ diff --git a/web/public/fonts/jetbrains-mono-latin-400-normal.woff2 b/web/public/fonts/jetbrains-mono-latin-400-normal.woff2 new file mode 100644 index 00000000..58588733 Binary files /dev/null and b/web/public/fonts/jetbrains-mono-latin-400-normal.woff2 differ diff --git a/web/public/fonts/jetbrains-mono-latin-500-normal.woff2 b/web/public/fonts/jetbrains-mono-latin-500-normal.woff2 new file mode 100644 index 00000000..be878e68 Binary files /dev/null and b/web/public/fonts/jetbrains-mono-latin-500-normal.woff2 differ diff --git a/web/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 b/web/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 new file mode 100644 index 00000000..9d97c9b7 Binary files /dev/null and b/web/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 differ diff --git a/web/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 b/web/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 new file mode 100644 index 00000000..5cc89b16 Binary files /dev/null and b/web/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 differ diff --git a/web/public/oidc-logo.svg b/web/public/oidc-logo.svg new file mode 100644 index 00000000..c1ab624c --- /dev/null +++ b/web/public/oidc-logo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 3c4f5b8e..423ee239 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -4353,6 +4353,7 @@ export interface components { ownerPreviewVersion?: components["schemas"]["SkillLifecycleVersionResponse"]; resolutionMode?: string; complianceSnapshot?: components["schemas"]["ComplianceSnapshotResponse"]; + labels?: components["schemas"]["SkillLabelDto"][]; }; ApiResponseBoolean: { /** Format: int32 */ @@ -4980,6 +4981,7 @@ export interface components { /** Format: int64 */ updatedAt?: number; latestVersion?: components["schemas"]["LatestVersion"]; + labels?: components["schemas"]["SkillLabelDto"][]; }; ApiResponseListSecurityAuditResponse: { /** Format: int32 */ @@ -8002,6 +8004,8 @@ export interface operations { page?: number; limit?: number; sort?: string; + /** @description Optional response expansions. Supported value: labels */ + include?: string[]; }; header?: never; path?: never; @@ -9039,6 +9043,8 @@ export interface operations { q?: string; namespace?: string; label?: string[]; + /** @description Optional response expansions. Supported value: labels */ + include?: string[]; sort?: string; page?: number; size?: number; diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index dc5a3b80..7a52720b 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -123,7 +123,6 @@ export function Layout() { ) : ( {t('nav.login')} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index bb1aac30..71d91116 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -183,8 +183,8 @@ const skillsRoute = createRoute({ const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', - validateSearch: (search: Record): { returnTo: string; reason?: string } => ({ - returnTo: typeof search.returnTo === 'string' ? search.returnTo : '', + validateSearch: (search: Record): { returnTo?: string; reason?: string } => ({ + returnTo: typeof search.returnTo === 'string' && search.returnTo ? search.returnTo : undefined, reason: typeof search.reason === 'string' ? search.reason : undefined, }), component: LoginPage, diff --git a/web/src/features/auth/use-auth-methods.test.ts b/web/src/features/auth/use-auth-methods.test.ts index 36fdad2e..f353b95c 100644 --- a/web/src/features/auth/use-auth-methods.test.ts +++ b/web/src/features/auth/use-auth-methods.test.ts @@ -1,15 +1,19 @@ import { describe, expect, it } from 'vitest' -import * as authMethods from './use-auth-methods' +import { getAuthMethodsQueryOptions, useAuthMethods } from './use-auth-methods' -/** - * use-auth-methods is a thin useQuery wrapper around authApi.getMethods. - * The query key includes the returnTo parameter for proper cache isolation. - * There are no exported pure functions or data transformations to unit-test. - * - * This file verifies the public API surface so that accidental export removals are caught. - */ -describe('use-auth-methods module exports', () => { - it('exports useAuthMethods hook', () => { - expect(authMethods.useAuthMethods).toBeTypeOf('function') +describe('getAuthMethodsQueryOptions', () => { + it('keeps login auth-method lookup local to the page and bypasses the global 401 redirect', () => { + const options = getAuthMethodsQueryOptions('/dashboard') + + expect(options.queryKey).toEqual(['auth', 'methods', '/dashboard']) + expect(options.retry).toBe(false) + expect(options.meta).toEqual({ skipGlobalErrorHandler: true }) + expect(options.queryFn).toBeTypeOf('function') + }) +}) + +describe('use-auth-methods module exports', () => { + it('exports useAuthMethods hook', () => { + expect(useAuthMethods).toBeTypeOf('function') }) }) diff --git a/web/src/features/auth/use-auth-methods.ts b/web/src/features/auth/use-auth-methods.ts index 864d054a..cfe1c907 100644 --- a/web/src/features/auth/use-auth-methods.ts +++ b/web/src/features/auth/use-auth-methods.ts @@ -5,9 +5,17 @@ import type { AuthMethod } from '@/api/types' /** * Loads the backend-advertised authentication methods for the current entry point. */ -export function useAuthMethods(returnTo?: string) { - return useQuery({ +export function getAuthMethodsQueryOptions(returnTo?: string) { + return { queryKey: ['auth', 'methods', returnTo ?? ''], queryFn: () => authApi.getMethods(returnTo), - }) + retry: false, + meta: { + skipGlobalErrorHandler: true, + }, + } +} + +export function useAuthMethods(returnTo?: string) { + return useQuery(getAuthMethodsQueryOptions(returnTo)) } diff --git a/web/src/features/notification/notification-dropdown.tsx b/web/src/features/notification/notification-dropdown.tsx index a1c64532..90c0b568 100644 --- a/web/src/features/notification/notification-dropdown.tsx +++ b/web/src/features/notification/notification-dropdown.tsx @@ -6,26 +6,12 @@ import { resolveNotificationDisplay } from './notification-content' import { useAuth } from '@/features/auth/use-auth' import { useNotifications, useMarkAllRead, useMarkRead } from './use-notifications' import { resolveNotificationTarget } from './notification-target' +import { formatRelativeTime } from '@/shared/lib/format-relative-time' interface Props { onClose: () => void } -function formatRelativeTime(dateStr: string, lang: string): string { - const diff = Date.now() - new Date(dateStr).getTime() - const minutes = Math.floor(diff / 60_000) - const hours = Math.floor(diff / 3_600_000) - const days = Math.floor(diff / 86_400_000) - - const isChinese = lang.startsWith('zh') - - if (minutes < 1) return isChinese ? '刚刚' : 'just now' - if (minutes < 60) return isChinese ? `${minutes}分钟` : `${minutes}m` - if (hours < 24) return isChinese ? `${hours}小时` : `${hours}h` - if (days < 30) return isChinese ? `${days}天` : `${days}d` - return new Date(dateStr).toLocaleDateString() -} - /** * Dropdown panel showing the latest 5 notifications with mark-all-read and view-all actions. */ diff --git a/web/src/features/publish/folder-zip.test.ts b/web/src/features/publish/folder-zip.test.ts new file mode 100644 index 00000000..3568c806 --- /dev/null +++ b/web/src/features/publish/folder-zip.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + collectFolderEntries, + createZipBlob, + crc32, + isIgnoredPath, + packageFolderAsZip, +} from './folder-zip' + +const utf8 = new TextEncoder() + +function fileAt(relativePath: string, content = 'x'): File { + const name = relativePath.split('/').pop() || relativePath + const file = new File([content], name) + Object.defineProperty(file, 'webkitRelativePath', { value: relativePath }) + return file +} + +async function bytesOf(blob: Blob): Promise { + return new Uint8Array(await blob.arrayBuffer()) +} + +describe('isIgnoredPath', () => { + it('keeps normal skill files', () => { + expect(isIgnoredPath('my-skill/SKILL.md')).toBe(false) + expect(isIgnoredPath('my-skill/scripts/run.sh')).toBe(false) + }) + + it('drops VCS, build and OS junk', () => { + expect(isIgnoredPath('my-skill/.git/config')).toBe(true) + expect(isIgnoredPath('my-skill/node_modules/x/index.js')).toBe(true) + expect(isIgnoredPath('my-skill/__pycache__/m.pyc')).toBe(true) + expect(isIgnoredPath('my-skill/.DS_Store')).toBe(true) + expect(isIgnoredPath('my-skill/._resource')).toBe(true) + expect(isIgnoredPath('my-skill/Thumbs.db')).toBe(true) + }) +}) + +describe('crc32', () => { + it('matches known CRC-32/ISO-HDLC vectors', () => { + expect(crc32(utf8.encode(''))).toBe(0x00000000) + expect(crc32(utf8.encode('a'))).toBe(0xe8b7be43) + expect(crc32(utf8.encode('abc'))).toBe(0x352441c2) + }) +}) + +describe('createZipBlob', () => { + it('writes a STORE archive with local, central and EOCD records', async () => { + const blob = createZipBlob([{ path: 'SKILL.md', data: utf8.encode('hello') }]) + const bytes = await bytesOf(blob) + const view = new DataView(bytes.buffer) + + // Local file header signature at offset 0. + expect(view.getUint32(0, true)).toBe(0x04034b50) + // Contains a central directory header and an end-of-central-directory record. + const eocd = bytes.length - 22 + expect(view.getUint32(eocd, true)).toBe(0x06054b50) + expect(view.getUint16(eocd + 10, true)).toBe(1) // total entries + // Central dir offset points at a central directory header signature. + const cdOffset = view.getUint32(eocd + 16, true) + expect(view.getUint32(cdOffset, true)).toBe(0x02014b50) + }) +}) + +describe('collectFolderEntries', () => { + it('filters junk and sorts remaining files by path', async () => { + const entries = await collectFolderEntries([ + fileAt('my-skill/scripts/run.sh', 'run'), + fileAt('my-skill/.git/config', 'gitcfg'), + fileAt('my-skill/SKILL.md', 'md'), + ]) + expect(entries.map((e) => e.path)).toEqual(['my-skill/SKILL.md', 'my-skill/scripts/run.sh']) + }) +}) + +describe('packageFolderAsZip', () => { + it('names the zip after the top-level folder', async () => { + const file = await packageFolderAsZip([fileAt('my-skill/SKILL.md', 'md')]) + expect(file.name).toBe('my-skill.zip') + expect(file.type).toBe('application/zip') + expect(file.size).toBeGreaterThan(0) + }) + + it('throws when everything was filtered out', async () => { + await expect(packageFolderAsZip([fileAt('my-skill/.git/config', 'x')])).rejects.toThrow( + 'empty-folder' + ) + }) +}) diff --git a/web/src/features/publish/folder-zip.ts b/web/src/features/publish/folder-zip.ts new file mode 100644 index 00000000..f10bcfa6 --- /dev/null +++ b/web/src/features/publish/folder-zip.ts @@ -0,0 +1,189 @@ +/** + * Dependency-free packaging of a selected folder into a skill ZIP. + * + * Browsers expose a picked folder as a flat FileList (each File carries a + * `webkitRelativePath` like `my-skill/SKILL.md`). We build a STORE-method ZIP + * (no compression — skill packages are small text files, and STORE keeps this + * dependency-free) from those files so the result flows through the exact same + * upload/publish path as a hand-made ZIP. + * + * We drop VCS/build/OS junk that a real on-disk folder almost always contains + * (`.git/`, `node_modules/`, `.DS_Store`, …) so it never bloats the package or + * the file-count limit. The server additionally strips a single root directory + * and OS-metadata entries, so paths are kept as-is (`my-skill/SKILL.md`). + */ + +/** Directory names whose entire subtree is excluded from the package. */ +const IGNORED_DIR_SEGMENTS = new Set([ + '.git', + '.svn', + '.hg', + 'node_modules', + '__pycache__', + '__MACOSX', +]) + +/** Exact file names that are always excluded. */ +const IGNORED_FILE_NAMES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']) + +/** Returns true if a relative path should be excluded from the package. */ +export function isIgnoredPath(relativePath: string): boolean { + const parts = relativePath.split('/') + const name = parts[parts.length - 1] + if (!name) return true // trailing slash / directory marker + if (parts.some((segment) => IGNORED_DIR_SEGMENTS.has(segment))) return true + if (IGNORED_FILE_NAMES.has(name)) return true + if (name.startsWith('._')) return true + if (name.endsWith('.pyc') || name.endsWith('.swp')) return true + return false +} + +// --- CRC-32 (IEEE 802.3, polynomial 0xEDB88320) ------------------------------- + +const CRC_TABLE = (() => { + const table = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + table[n] = c >>> 0 + } + return table +})() + +export function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff + for (let i = 0; i < bytes.length; i++) { + crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +// --- ZIP writer (STORE method, no data descriptors) --------------------------- + +export interface ZipEntry { + path: string + data: Uint8Array +} + +const utf8 = new TextEncoder() + +/** + * Builds a ZIP archive (STORE method) containing the given entries and returns + * it as a Blob. 32-bit size fields are used; skill packages are far below the + * 4 GB boundary where ZIP64 would be required. + */ +export function createZipBlob(entries: ZipEntry[]): Blob { + const localParts: Uint8Array[] = [] + const centralParts: Uint8Array[] = [] + let offset = 0 + + for (const entry of entries) { + const nameBytes = utf8.encode(entry.path) + const crc = crc32(entry.data) + const size = entry.data.length + + const local = new Uint8Array(30 + nameBytes.length) + const lv = new DataView(local.buffer) + lv.setUint32(0, 0x04034b50, true) // local file header signature + lv.setUint16(4, 20, true) // version needed + lv.setUint16(6, 0x0800, true) // flags: bit 11 = UTF-8 names + lv.setUint16(8, 0, true) // method: STORE + lv.setUint16(10, 0, true) // mod time + lv.setUint16(12, 0, true) // mod date + lv.setUint32(14, crc, true) + lv.setUint32(18, size, true) // compressed size (== uncompressed for STORE) + lv.setUint32(22, size, true) // uncompressed size + lv.setUint16(26, nameBytes.length, true) + lv.setUint16(28, 0, true) // extra length + local.set(nameBytes, 30) + + localParts.push(local, entry.data) + + const central = new Uint8Array(46 + nameBytes.length) + const cv = new DataView(central.buffer) + cv.setUint32(0, 0x02014b50, true) // central directory header signature + cv.setUint16(4, 20, true) // version made by + cv.setUint16(6, 20, true) // version needed + cv.setUint16(8, 0x0800, true) // flags + cv.setUint16(10, 0, true) // method: STORE + cv.setUint16(12, 0, true) // mod time + cv.setUint16(14, 0, true) // mod date + cv.setUint32(16, crc, true) + cv.setUint32(20, size, true) + cv.setUint32(24, size, true) + cv.setUint16(28, nameBytes.length, true) + cv.setUint16(30, 0, true) // extra length + cv.setUint16(32, 0, true) // comment length + cv.setUint16(34, 0, true) // disk number start + cv.setUint16(36, 0, true) // internal attrs + cv.setUint32(38, 0, true) // external attrs + cv.setUint32(42, offset, true) // relative offset of local header + central.set(nameBytes, 46) + centralParts.push(central) + + offset += local.length + entry.data.length + } + + const centralSize = centralParts.reduce((n, p) => n + p.length, 0) + const eocd = new Uint8Array(22) + const ev = new DataView(eocd.buffer) + ev.setUint32(0, 0x06054b50, true) // end of central directory signature + ev.setUint16(4, 0, true) // disk number + ev.setUint16(6, 0, true) // central dir start disk + ev.setUint16(8, entries.length, true) // entries on this disk + ev.setUint16(10, entries.length, true) // total entries + ev.setUint32(12, centralSize, true) // central dir size + ev.setUint32(16, offset, true) // central dir offset + ev.setUint16(20, 0, true) // comment length + + // Concatenate into a single buffer so the Blob part is a Uint8Array. + const parts = [...localParts, ...centralParts, eocd] + const total = parts.reduce((n, p) => n + p.length, 0) + const out = new Uint8Array(total) + let pos = 0 + for (const part of parts) { + out.set(part, pos) + pos += part.length + } + return new Blob([out], { type: 'application/zip' }) +} + +// --- Folder -> File ------------------------------------------------------------ + +/** Reads the picked folder's files into ZIP entries, skipping junk. */ +export async function collectFolderEntries(files: File[]): Promise { + const entries: ZipEntry[] = [] + for (const file of files) { + const path = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name + if (isIgnoredPath(path)) continue + const data = new Uint8Array(await file.arrayBuffer()) + entries.push({ path, data }) + } + entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) + return entries +} + +/** Top-level folder name of a webkitdirectory selection, for naming the zip. */ +function rootFolderName(files: File[]): string { + for (const file of files) { + const rel = (file as File & { webkitRelativePath?: string }).webkitRelativePath + if (rel && rel.includes('/')) return rel.slice(0, rel.indexOf('/')) + } + return 'skill' +} + +/** + * Packages a picked folder into a `.zip` File ready for the existing + * upload flow. Throws if every file was filtered out as junk. + */ +export async function packageFolderAsZip(fileList: FileList | File[]): Promise { + const files = Array.from(fileList) + const entries = await collectFolderEntries(files) + if (entries.length === 0) { + throw new Error('empty-folder') + } + const blob = createZipBlob(entries) + return new File([blob], `${rootFolderName(files)}.zip`, { type: 'application/zip' }) +} diff --git a/web/src/features/publish/upload-zone.tsx b/web/src/features/publish/upload-zone.tsx index b28fa92b..e5ab4c72 100644 --- a/web/src/features/publish/upload-zone.tsx +++ b/web/src/features/publish/upload-zone.tsx @@ -1,10 +1,12 @@ -import { useCallback } from 'react' +import { useCallback, useEffect, useRef, type ChangeEvent } from 'react' import { useTranslation } from 'react-i18next' import { useDropzone } from 'react-dropzone' import { cn } from '@/shared/lib/utils' interface UploadZoneProps { onFileSelect: (file: File) => void + /** Optional: called with the raw files of a picked folder (webkitdirectory). */ + onFolderSelect?: (files: File[]) => void disabled?: boolean } @@ -13,8 +15,20 @@ interface UploadZoneProps { * The component is intentionally stateless so packaging validation can remain in * the publish flow that knows the surrounding form and backend constraints. */ -export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) { +export function UploadZone({ onFileSelect, onFolderSelect, disabled }: UploadZoneProps) { const { t } = useTranslation() + const folderInputRef = useRef(null) + + // `webkitdirectory` / `directory` are not in React's input attribute types; + // set them imperatively so the folder picker works without an untyped cast. + useEffect(() => { + const el = folderInputRef.current + if (el) { + el.setAttribute('webkitdirectory', '') + el.setAttribute('directory', '') + } + }, []) + const onDrop = useCallback( (acceptedFiles: File[]) => { if (acceptedFiles.length > 0) { @@ -33,44 +47,75 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) { disabled, }) + const handleFolderChange = (event: ChangeEvent) => { + const files = event.target.files + if (files && files.length > 0) { + onFolderSelect?.(Array.from(files)) + } + // Reset so picking the same folder again re-triggers change. + event.target.value = '' + } + return ( -
- -
-
- - - -
- {isDragActive ? ( -

{t('upload.dropHint')}

- ) : ( - <> -

{t('upload.dragHint')}

-

{t('upload.formatHint')}

- +
+
+ +
+
+ + + +
+ {isDragActive ? ( +

{t('upload.dropHint')}

+ ) : ( + <> +

{t('upload.dragHint')}

+

{t('upload.formatHint')}

+ + )} +
+ {onFolderSelect && ( +
+ + +
+ )}
) } diff --git a/web/src/i18n/config.test.ts b/web/src/i18n/config.test.ts index 843605db..dd76b794 100644 --- a/web/src/i18n/config.test.ts +++ b/web/src/i18n/config.test.ts @@ -29,6 +29,10 @@ vi.mock('./locales/zh.json', () => ({ default: { greeting: '你好' }, })) +vi.mock('./locales/ru.json', () => ({ + default: { greeting: 'Привет' }, +})) + // Import triggers the side-effect initialization await import('./config') @@ -54,11 +58,13 @@ describe('i18n config', () => { expect(initOptions.detection.caches).toEqual(['localStorage']) }) - it('registers both english and chinese resource bundles', () => { + it('registers english, russian and chinese resource bundles', () => { const initOptions = initMock.mock.calls[0][0] expect(initOptions.resources).toHaveProperty('en') + expect(initOptions.resources).toHaveProperty('ru') expect(initOptions.resources).toHaveProperty('zh') expect(initOptions.resources.en).toHaveProperty('translation') + expect(initOptions.resources.ru).toHaveProperty('translation') expect(initOptions.resources.zh).toHaveProperty('translation') }) }) diff --git a/web/src/i18n/config.ts b/web/src/i18n/config.ts index 9973671f..e1cee718 100644 --- a/web/src/i18n/config.ts +++ b/web/src/i18n/config.ts @@ -2,6 +2,7 @@ import i18n from 'i18next' import { initReactI18next } from 'react-i18next' import LanguageDetector from 'i18next-browser-languagedetector' import en from './locales/en.json' +import ru from './locales/ru.json' import zh from './locales/zh.json' /** @@ -15,6 +16,7 @@ i18n .init({ resources: { en: { translation: en }, + ru: { translation: ru }, zh: { translation: zh }, }, fallbackLng: 'en', diff --git a/web/src/i18n/landing-quick-start-locale.test.ts b/web/src/i18n/landing-quick-start-locale.test.ts index 0fcac247..1aeb0e8c 100644 --- a/web/src/i18n/landing-quick-start-locale.test.ts +++ b/web/src/i18n/landing-quick-start-locale.test.ts @@ -1,16 +1,19 @@ import { describe, expect, it } from 'vitest' import en from './locales/en.json' +import ru from './locales/ru.json' import zh from './locales/zh.json' describe('landing quick start locales', () => { - it('uses localized agent setup prompts for chinese and english', () => { + it('uses localized agent setup prompts for chinese, english, and russian', () => { expect(zh.landing.quickStart.agent.command).toBe('阅读 https://www.example.com/registry/skill.md,并按照说明完成 SkillHub Skills Registry 的配置') expect(en.landing.quickStart.agent.command).toBe('Read https://www.example.com/registry/skill.md and follow the instructions to setup SkillHub Skills Registry') + expect(ru.landing.quickStart.agent.command).toBe('Прочитайте https://www.example.com/registry/skill.md и следуйте инструкциям для настройки SkillHub Skills Registry') }) it('provides command templates with url placeholder for dynamic rendering', () => { expect(zh.landing.quickStart.agent.commandTemplate).toBe('阅读 {{url}},并按照说明完成 SkillHub Skills Registry 的配置') expect(en.landing.quickStart.agent.commandTemplate).toBe('Read {{url}} and follow the instructions to setup SkillHub Skills Registry') + expect(ru.landing.quickStart.agent.commandTemplate).toBe('Прочитайте {{url}} и следуйте инструкциям для настройки SkillHub Skills Registry') }) it('exposes CLI install command in both locales', () => { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index dcfc28bf..f22f7cf9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1212,7 +1212,8 @@ "upload": { "dropHint": "Drop to upload...", "dragHint": "Drag a ZIP file here, or click to select", - "formatHint": "Only .zip format supported" + "formatHint": "Only .zip format supported", + "folderHint": "Or select a folder to package and upload" }, "layout": { "footerDescription": "Skill registry, providing efficient skill management and distribution for developers." @@ -1389,7 +1390,8 @@ "warningConfirmCancel": "Go back and fix", "frontmatterFailedTitle": "SKILL.md format is invalid", "frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.", - "selectRequired": "Please select namespace and file" + "selectRequired": "Please select namespace and file", + "folderPackagingFailed": "Could not package the selected folder. Make sure it contains files." }, "toast": { "success": "Success", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json new file mode 100644 index 00000000..216a3791 --- /dev/null +++ b/web/src/i18n/locales/ru.json @@ -0,0 +1,1650 @@ +{ + "compliance": { + "title": "Заявления о соответствии", + "mappingCount": "{{count}} элементов", + "unknownStandard": "Неизвестный стандарт", + "evidence": "Доказательства" + }, + "common": { + "expand": "Развернуть подробности", + "collapse": "Свернуть подробности" + }, + "nav": { + "landing": "Главная", + "home": "Центр скиллов", + "search": "Поиск", + "skillDetail": "Карточка скилла", + "explore": "Обзор скиллов", + "dashboard": "Панель управления", + "mySkills": "Мои скиллы", + "publish": "Опубликовать", + "login": "Вход" + }, + "landing": { + "hero": { + "title": "Находите и делитесь AI-скиллами", + "subtitle": "Создавайте мощных AI-агентов на скиллах сообщества", + "searchPlaceholder": "Поиск скиллов...", + "exploreSkills": "Обзор скиллов", + "publishSkill": "Опубликовать скилл" + }, + "features": { + "secure": { + "title": "Безопасность и конфиденциальность", + "description": "Корпоративный уровень защиты для ваших AI-процессов" + }, + "community": { + "title": "Сообщество", + "description": "Делитесь скиллами и находите их у разработчиков по всему миру" + }, + "integration": { + "title": "Простая интеграция", + "description": "Легко встраивается в ваши существующие инструменты" + }, + "versionControl": { + "title": "Управление версиями", + "description": "Структурированное версионирование и релизы, чтобы пакеты скиллов оставались прослеживаемыми и надёжными." + }, + "cli": { + "title": "Инструменты CLI", + "description": "Мощные сценарии командной строки для публикации, установки и управления пакетами скиллов." + }, + "governance": { + "title": "Управление рецензированием", + "description": "Встроенные процессы рецензирования и контроль прав для высокого качества корпоративных скиллов." + } + }, + "stats": { + "skills": "Каталоги", + "downloads": "Загрузки", + "teams": "Команды" + }, + "whySkillHub": { + "title": "Почему SkillHub", + "subtitle": "Частная платформа Agent-скиллов для корпоративных команд" + }, + "badge": "Корпоративный реестр скиллов", + "tagline": "Публикуйте, находите, управляйте", + "taglineHighlight": " скиллами агентов", + "description": "Самостоятельно размещаемый частный реестр скиллов для безопасного и эффективного обмена и совместной работы в командах", + "publishSkill": "Опубликовать скилл", + "statsSkills": "Скиллы", + "statsDownloads": "Загрузки", + "statsTeams": "Команды", + "whyTitle": "Почему выбирают", + "whyDescription": "Корпоративная платформа управления скиллами с полным циклом публикации, поиска и контроля", + "featuresList": { + "privateDeploy": { + "title": "Частное развёртывание", + "description": "Полностью self-hosted с суверенитетом данных. Развёртывание в вашей инфраструктуре в один клик, безопасно за межсетевым экраном." + }, + "versionControl": { + "title": "Управление версиями", + "description": "Семантическое версионирование, пользовательские теги (beta, stable), автоматическое отслеживание последней версии." + }, + "smartSearch": { + "title": "Умный поиск", + "description": "Полнотекстовый поиск с многомерной фильтрацией по пространству имён, загрузкам, рейтингам и времени." + }, + "teamwork": { + "title": "Командная работа", + "description": "Управление пространствами имён, ролевой доступ (Owner/Admin/Member), настройка политики публикации." + }, + "governance": { + "title": "Рецензирование и контроль", + "description": "Командное рецензирование, одобрение на уровне платформы, полный аудит — соответствие требованиям комплаенса." + }, + "cliFirst": { + "title": "CLI в приоритете", + "description": "Нативный REST API, совместимость с существующими инструментами ClawHub CLI — без изменений на стороне клиента." + } + }, + "ctaTitle": "Готовы начать?", + "ctaDescription": "Запустите локальное окружение одной командой и пройдите полный цикл управления скиллами", + "ctaButton": "Попробовать", + "footerLicense": "© 2026 SkillHub. Apache License 2.0.", + "footerDocs": "Документация", + "footerGithub": "GitHub", + "footerCommunity": "Сообщество", + "quickStart": { + "title": "Быстрый старт", + "subtitle": "Быстрый старт", + "description": "Выберите способ работы, скопируйте инструкцию по настройке и продолжайте", + "tip": "💡 Совет: на странице скилла есть команды установки в один клик с переменными окружения", + "tabs": { + "agent": "Я Agent", + "human": "Я человек", + "cli": "CLI" + }, + "cli": { + "description": "Установите SkillHub CLI локально, чтобы выполнять skillhub install для скиллов.", + "command": "npm i -g @astron-team/skillhub" + }, + "agent": { + "description": "Отправьте промпт своему Agent, чтобы настроить SkillHub Registry", + "command": "Прочитайте https://www.example.com/registry/skill.md и следуйте инструкциям для настройки SkillHub Skills Registry", + "commandTemplate": "Прочитайте {{url}} и следуйте инструкциям для настройки SkillHub Skills Registry" + }, + "human": { + "description": "Используйте CLI для установки Skills", + "command": "npx clawhub search " + }, + "steps": { + "configureEnv": { + "title": "1. Настройте переменные окружения", + "description": "Настройте ClawHub CLI для подключения к SkillHub" + }, + "installSkills": { + "title": "2. Установите скиллы", + "description": "Найдите и установите нужные скиллы", + "code": "# Поиск скиллов\nclawhub search \n\n# Установить скилл\nclawhub install " + }, + "publishSkills": { + "title": "3. Опубликуйте скиллы", + "description": "Делитесь скиллами с командой", + "code": "# Опубликовать скилл\nclawhub publish\n\n# Или через веб-интерфейс\n# Нажмите «Опубликовать скилл»" + } + } + } + }, + "home": { + "subtitle": "Реестр скиллов", + "description": "Эффективная платформа управления, распространения и совместной работы со скиллами для разработчиков", + "browseSkills": "Обзор скиллов", + "publishSkill": "Опубликовать скилл", + "popularTitle": "Популярные загрузки", + "popularDescription": "Самые популярные скиллы в сообществе", + "latestTitle": "Последние релизы", + "latestDescription": "Недавно опубликованные скиллы", + "viewAll": "Смотреть все →", + "quickStart": { + "title": "Быстрый старт", + "subtitle": "Быстрый старт", + "description": "Начните работу с SkillHub за несколько простых шагов", + "tip": "💡 Совет: на странице скилла есть команды установки в один клик с переменными окружения", + "steps": { + "configureEnv": { + "title": "1. Настройте переменные окружения", + "description": "Настройте ClawHub CLI для подключения к SkillHub" + }, + "installSkills": { + "title": "2. Установите скиллы", + "description": "Найдите и установите нужные скиллы", + "code": "# Поиск скиллов\nclawhub search \n\n# Установить скилл\nclawhub install " + }, + "publishSkills": { + "title": "3. Опубликуйте скиллы", + "description": "Делитесь скиллами с командой", + "code": "# Опубликовать скилл\nclawhub publish\n\n# Или через веб-интерфейс\n# Нажмите «Опубликовать скилл»" + } + } + } + }, + "search": { + "title": "Поиск скиллов", + "placeholder": "Поиск скиллов...", + "filters": { + "label": "Фильтр:" + }, + "sort": { + "label": "Сортировка:", + "relevance": "Релевантность", + "downloads": "Загрузки", + "stars": "Звёзды", + "newest": "Новизна" + }, + "noResults": "Ничего не найдено", + "noResultsFor": "Скиллы по запросу «{{q}}» не найдены", + "filterStarred": "Только избранные", + "noStarredResults": "Избранные скиллы не найдены", + "noStarredResultsFor": "Нет избранных скиллов по запросу «{{q}}»", + "noStarredSkills": "У вас пока нет избранных скиллов", + "namespaceFilter": "@{{namespace}}", + "enterKeyword": "Введите ключевое слово для поиска", + "results": "Найдено скиллов: {{count}}", + "resultCount": "Найдено результатов: <1>{{count}}", + "loadingMore": "Обновление результатов поиска..." + }, + "searchBar": { + "placeholder": "Поиск скиллов...", + "button": "Найти", + "clear": "Очистить поиск" + }, + "login": { + "title": "Вход в SkillHub", + "subtitle": "Выберите способ продолжения", + "tabPassword": "Пароль", + "tabOAuth": "OAuth", + "username": "Имя пользователя", + "password": "Пароль", + "usernamePlaceholder": "Введите имя пользователя", + "passwordPlaceholder": "Введите пароль", + "usernameRequired": "Имя пользователя обязательно", + "passwordRequired": "Пароль обязателен", + "showPassword": "Показать пароль", + "hidePassword": "Скрыть пароль", + "submitting": "Вход...", + "submit": "Войти", + "forgotPassword": "Забыли пароль?", + "noAccount": "Нет аккаунта?", + "register": "Зарегистрироваться", + "oauthHint": "После аутентификации OAuth вы будете автоматически перенаправлены обратно на этот сайт.", + "passwordCompatHint": "В этом развёртывании включён слой совместимости паролей. Форма направит запрос в {{name}} вместо фиксированной локальной учётной записи.", + "enterpriseSsoTitle": "Корпоративный SSO", + "enterpriseSsoHint": "В этом развёртывании включён слой совместимости. Если в браузере уже есть сессия {{name}}, можно попробовать сразу установить сессию SkillHub.", + "enterpriseSsoAutoHint": "В этом развёртывании включено автоматическое зондирование {{name}}. Если оно не сработает, продолжайте стандартными способами входа.", + "enterpriseSsoAction": "Попробовать {{name}}", + "enterpriseSsoSubmitting": "Пробуем {{name}}...", + "agreementPrefix": "Входя в систему, вы соглашаетесь с", + "terms": "Условиями использования", + "and": "и", + "privacy": "Политикой конфиденциальности" + }, + "register": { + "title": "Создать аккаунт", + "subtitle": "Зарегистрируйтесь локально или войдите через OAuth.", + "tabLocal": "Локальный аккаунт", + "tabOAuth": "OAuth", + "username": "Имя пользователя", + "email": "Электронная почта", + "password": "Пароль", + "usernamePlaceholder": "3–64 символа: буквы, цифры или подчёркивания", + "emailPlaceholder": "Введите email", + "emailRequired": "Email обязателен", + "passwordPlaceholder": "Не менее 8 символов, минимум 3 типа символов", + "submitting": "Регистрация...", + "submit": "Зарегистрироваться и войти", + "hasAccount": "Уже есть аккаунт?", + "login": "Вернуться ко входу", + "oauthHint": "Войдите напрямую через существующий OAuth-аккаунт — локальный пароль не нужен.", + "usernameRequired": "Имя пользователя обязательно", + "usernameInvalid": "Допустимы только буквы, цифры или подчёркивания (3–64 символа)", + "usernameExists": "Имя пользователя уже занято", + "passwordRequired": "Пароль обязателен", + "passwordTooShort": "Пароль должен содержать не менее 8 символов", + "passwordTooWeak": "Пароль должен содержать минимум 3 типа символов (заглавные, строчные, цифры, спецсимволы)", + "emailInvalid": "Неверный формат email", + "emailExists": "Email уже используется" + }, + "resetPassword": { + "title": "Сброс пароля", + "subtitle": "Введите email, код подтверждения и новый пароль.", + "email": "Электронная почта", + "emailPlaceholder": "Введите email", + "emailRequired": "Введите email", + "emailInvalid": "Введите корректный адрес email", + "code": "Код подтверждения", + "codePlaceholder": "Введите 6-значный код", + "sendCode": "Отправить код", + "sendingCode": "Отправка...", + "codeSentMessage": "Если аккаунт подходит, код подтверждения отправлен.", + "codeRequired": "Введите код подтверждения", + "newPassword": "Новый пароль", + "newPasswordPlaceholder": "Введите новый пароль", + "newPasswordRequired": "Введите новый пароль", + "confirmPassword": "Подтвердите пароль", + "confirmPasswordPlaceholder": "Повторите новый пароль", + "passwordMismatch": "Пароли не совпадают", + "submit": "Сбросить пароль", + "submitting": "Сброс...", + "successMessage": "Пароль успешно сброшен. Войдите с новым паролем.", + "genericError": "Не удалось сбросить пароль", + "backToLogin": "Вернуться ко входу" + }, + "device": { + "title": "Авторизация устройства", + "subtitle": "Введите 8-значный код пользователя, показанный на устройстве", + "codeLabel": "Код пользователя", + "codeHint": "Формат: XXXX-XXXX (можно вставить из буфера)", + "incompleteCode": "Введите полный 8-значный код пользователя", + "success": "Устройство успешно авторизовано!", + "defaultError": "Авторизация не удалась, проверьте код пользователя", + "submitting": "Авторизация...", + "submit": "Авторизовать устройство", + "notice": "После авторизации устройство получит доступ к вашему аккаунту" + }, + "cliAuth": { + "validating": "Проверка...", + "pleaseWait": "Подождите", + "creatingToken": "Создание токена...", + "almostThere": "Почти готово", + "success": "Авторизация успешна", + "redirecting": "Перенаправление в CLI...", + "fallbackInstructions": "Если браузер не перенаправил автоматически, скопируйте токен ниже:", + "error": "Авторизация не удалась", + "notAuthenticated": "Вы не вошли в систему", + "invalidRedirectUri": "Некорректный redirect URI", + "missingState": "Отсутствует параметр безопасности", + "windowsUrlBug": "Известная проблема Windows: браузер открыл неполный URL. Скопируйте полный URL из вывода CLI (строка, начинающаяся с 'Opening browser:') и вставьте его в адресную строку браузера.", + "tokenCreationFailed": "Не удалось создать токен", + "loginRequired": "Сначала войдите в систему, чтобы авторизовать доступ CLI", + "goToLogin": "Перейти ко входу" + }, + "dashboard": { + "title": "Панель управления", + "subtitle": "Аккаунт, скиллы и учётные данные доступа — в одном месте", + "backToDashboard": "Назад к панели", + "userInfo": "Сведения об аккаунте", + "userInfoDesc": "Основные данные аккаунта и роли на платформе", + "loginVia": "Вход через {{provider}}", + "platformRoles": "Роли платформы", + "starsAndRatings": "Звёзды и рейтинги", + "viewStars": "Мои звёзды", + "subscriptions": "Подписки", + "viewSubscriptions": "Мои подписки", + "mySkillsTitle": "Мои скиллы", + "openMySkills": "Перейти к моим скиллам", + "mySkillsPreviewDescription": "Показаны 5 последних скиллов. Откройте скилл или перейдите в «Мои скиллы», чтобы увидеть все.", + "mySkillsPreviewEmpty": "Вы ещё не публиковали скиллы", + "credentials": "Учётные данные", + "openTokens": "Токены API", + "governanceTitle": "Рецензирование и контроль", + "viewGovernance": "Центр управления", + "viewPromotions": "Продвижения", + "reportsTitle": "Управление жалобами", + "viewReports": "Жалобы на скиллы", + "previewMore": "...", + "previewMoreLabel": "Смотреть все", + "userId": "ID пользователя" + }, + "mySkills": { + "title": "Мои скиллы", + "subtitle": "Управление опубликованными скиллами", + "searchPlaceholder": "Поиск по имени, slug или описанию", + "namespaceFilterLabel": "Фильтр по пространству имён", + "namespaceFilterAll": "Все пространства имён", + "clearSearch": "Сбросить фильтры", + "emptySearchTitle": "Нет подходящих скиллов", + "emptySearchDescription": "Измените ключевое слово или выберите другое пространство имён.", + "filters": { + "ALL": "Все", + "PENDING_REVIEW": "На рецензии", + "PUBLISHED": "Опубликованы", + "REJECTED": "Отклонены", + "ARCHIVED": "В архиве", + "HIDDEN": "Скрыты" + }, + "publishNew": "Опубликовать новый скилл", + "update": "Обновить", + "archive": "В архив", + "unarchive": "Восстановить", + "statusArchived": "В архиве", + "statusPendingReview": "На рецензии", + "statusPublished": "Опубликован", + "statusRejected": "Отклонён", + "statusHidden": "Скрыт", + "statusScanning": "Сканирование", + "statusScanFailed": "Ошибка сканирования", + "archiveConfirmTitle": "Архивировать скилл", + "archiveConfirmDescription": "После архивирования обычные пользователи больше не смогут просматривать или скачивать «{{skill}}». Продолжить?", + "unarchiveConfirmTitle": "Восстановить скилл", + "unarchiveConfirmDescription": "«{{skill}}» снова станет видимым и сможет публиковать новые версии после восстановления.", + "archiveSuccessTitle": "Скилл архивирован", + "archiveSuccessDescription": "«{{skill}}» архивирован.", + "archiveErrorTitle": "Не удалось архивировать скилл", + "unarchiveSuccessTitle": "Скилл восстановлен", + "unarchiveSuccessDescription": "«{{skill}}» восстановлен и снова может публиковать новые версии.", + "unarchiveErrorTitle": "Не удалось восстановить скилл", + "withdrawReview": "Отозвать рецензию", + "withdrawConfirmTitle": "Отозвать загрузку", + "withdrawConfirmDescription": "После отзыва «{{skill}}» больше не будет рецензироваться, а ожидающая версия будет удалена.", + "withdrawSuccessTitle": "Загрузка отозвана", + "withdrawSuccessDescription": "Ожидающая версия для «{{skill}}» отозвана.", + "withdrawErrorTitle": "Не удалось отозвать загрузку", + "promoteToGlobal": "Продвинуть в Global", + "promotionConfirmTitle": "Отправить запрос на продвижение", + "promotionConfirmDescription": "Отправить v{{version}} скилла «{{skill}}» на продвижение в глобальное пространство имён?", + "promotionSuccessTitle": "Запрос на продвижение отправлен", + "promotionSuccessDescription": "v{{version}} скилла «{{skill}}» добавлена в очередь рецензирования глобального продвижения.", + "promotionDuplicateTitle": "Продвижение уже ожидает", + "promotionDuplicateDescription": "У этой версии уже есть ожидающий запрос на продвижение.", + "promotionAlreadyPromotedTitle": "Скилл уже продвинут", + "promotionAlreadyPromotedDescription": "Этот скилл уже продвинут в глобальное пространство имён.", + "promotionErrorTitle": "Не удалось отправить запрос на продвижение", + "emptyTitle": "Скиллов пока нет", + "emptyDescription": "Опубликуйте свой первый скилл", + "emptyFilteredTitle": "Нет скиллов по этому фильтру", + "emptyFilteredDescription": { + "PENDING_REVIEW": "Сейчас нет скиллов, ожидающих рецензии.", + "PUBLISHED": "Сейчас нет опубликованных скиллов.", + "REJECTED": "Сейчас нет отклонённых скиллов.", + "ARCHIVED": "Сейчас нет архивных скиллов.", + "HIDDEN": "Сейчас нет скрытых скиллов." + }, + "publishSkill": "Опубликовать скилл" + }, + "myNamespaces": { + "title": "Мои пространства имён", + "subtitle": "Управление пространствами имён и командами", + "create": "Создать пространство имён", + "creating": "Создание...", + "createSubmit": "Создать", + "createDialogTitle": "Создать командное пространство имён", + "createDialogDescription": "После создания вы станете OWNER и сможете управлять участниками, рецензиями и публикацией скиллов.", + "createSlugLabel": "Slug пространства имён", + "createSlugPlaceholder": "напр. team-ml", + "createSlugHint": "2–64 символа: строчные буквы, цифры или дефисы, без дефиса в начале и конце.", + "createSlugRequired": "Slug пространства имён обязателен", + "createSlugLength": "Slug пространства имён должен быть от {{min}} до {{max}} символов", + "createSlugPattern": "Slug пространства имён может содержать только строчные буквы, цифры или дефисы", + "createSlugDoubleHyphen": "Slug пространства имён не может содержать подряд идущие дефисы", + "createSlugReserved": "«{{slug}}» зарезервирован. Выберите другой slug.", + "createDisplayNameLabel": "Отображаемое имя", + "createDisplayNamePlaceholder": "напр. Команда машинного обучения", + "createDisplayNameRequired": "Отображаемое имя обязательно", + "createDisplayNameLength": "Отображаемое имя — не более {{max}} символов", + "createDescriptionLabel": "Описание", + "createDescriptionPlaceholder": "Кратко опишите назначение пространства имён, кто им пользуется и как оно управляется", + "createDescriptionHint": "Необязательно, до 512 символов.", + "createDescriptionLength": "Описание — не более {{max}} символов", + "createSuccessTitle": "Пространство имён создано", + "createSuccessDescription": "«{{name}}» готово. Можно управлять участниками или публиковать скиллы.", + "createErrorTitle": "Не удалось создать пространство имён", + "typeGlobal": "Глобальное", + "typeTeam": "Команда", + "roleLabel": "Текущая роль", + "roleUnknown": "Неизвестно", + "manageMembers": "Участники", + "reviewTasks": "Задачи рецензирования", + "freeze": "Заморозить", + "unfreeze": "Разморозить", + "archive": "В архив", + "restore": "Восстановить", + "delete": "Удалить", + "activeHint": "Это пространство имён полностью активно: можно управлять участниками, рецензиями и публикацией скиллов.", + "frozenHint": "Пространство имён заморожено. Участники могут его просматривать, но оно только для чтения: публикация и изменение состава недоступны.", + "archivedHint": "Пространство имён в архиве. Публичные точки входа скрыты, но его можно просмотреть и восстановить из панели управления.", + "immutableHint": "Это встроенное системное пространство имён и всегда только для чтения.", + "freezeConfirmTitle": "Заморозить пространство имён", + "freezeConfirmDescription": "«{{name}}» станет доступно только для чтения. Продолжить?", + "unfreezeConfirmTitle": "Разморозить пространство имён", + "unfreezeConfirmDescription": "После разморозки «{{name}}» снова получит обычное управление и публикацию.", + "archiveConfirmTitle": "Архивировать пространство имён", + "archiveConfirmDescription": "«{{name}}» будет скрыто из публичных точек входа и останется доступным только для восстановления из панели управления.", + "restoreConfirmTitle": "Восстановить пространство имён", + "restoreConfirmDescription": "«{{name}}» вернётся в активное рабочее состояние.", + "deleteConfirmTitle": "Удалить пространство имён", + "deleteConfirmDescription": "«{{name}}» будет удалено безвозвратно. Это действие нельзя отменить.", + "freezeSuccessTitle": "Пространство имён заморожено", + "freezeSuccessDescription": "«{{name}}» теперь только для чтения.", + "freezeErrorTitle": "Не удалось заморозить пространство имён", + "unfreezeSuccessTitle": "Пространство имён разморожено", + "unfreezeSuccessDescription": "«{{name}}» снова активно.", + "unfreezeErrorTitle": "Не удалось разморозить пространство имён", + "archiveSuccessTitle": "Пространство имён архивировано", + "archiveSuccessDescription": "«{{name}}» скрыто из публичных точек входа.", + "archiveErrorTitle": "Не удалось архивировать пространство имён", + "restoreSuccessTitle": "Пространство имён восстановлено", + "restoreSuccessDescription": "«{{name}}» снова работает в обычном режиме.", + "restoreErrorTitle": "Не удалось восстановить пространство имён", + "deleteSuccessTitle": "Пространство имён удалено", + "deleteSuccessDescription": "«{{name}}» удалено безвозвратно.", + "deleteErrorTitle": "Не удалось удалить пространство имён", + "emptyTitle": "Пространств имён пока нет", + "emptyDescription": "Создайте пространство имён, чтобы организовать скиллы" + }, + "tokens": { + "pageTitle": "Управление токенами", + "pageSubtitle": "Учётные данные доступа для CLI и API" + }, + "reviews": { + "title": "Центр рецензирования", + "subtitle": "Задачи рецензирования на платформе", + "typeSkill": "Рецензии скиллов", + "typeProfile": "Рецензии профилей", + "tabPending": "Ожидают", + "tabApproved": "Одобрены", + "tabRejected": "Отклонены", + "empty": "Нет задач рецензирования", + "colSkill": "Скилл", + "colVersion": "Версия", + "colSubmitter": "Отправитель", + "colSubmitTime": "Отправлено", + "colReviewer": "Рецензент", + "colReviewTime": "Рецензировано", + "sortLabel": "Порядок по времени", + "sortNewest": "Сначала новые", + "sortOldest": "Сначала старые", + "pageSummary": "Всего записей: {{total}}, страница {{page}}", + "prevPage": "Назад", + "nextPage": "Вперёд" + }, + "profileReview": { + "queueTitle": "Очередь рецензий профилей", + "queueSubtitle": "Обработка запросов на смену отображаемого имени: фильтры статуса, пагинация и действия рецензирования.", + "empty": "Нет задач рецензирования профилей", + "colUser": "Пользователь", + "colCurrentName": "Текущее имя", + "colRequestedName": "Запрошенное имя", + "colSubmittedAt": "Отправлено", + "colMachineResult": "Машинная проверка", + "colActions": "Действия", + "colReviewInfo": "Сведения о рецензии", + "approve": "Одобрить", + "reject": "Отклонить", + "approveSuccess": "Смена профиля одобрена", + "approveFailed": "Не удалось одобрить", + "rejectSuccess": "Смена профиля отклонена", + "rejectFailed": "Не удалось отклонить", + "confirmApproveTitle": "Подтвердить одобрение", + "confirmApproveDesc": "Одобрить смену «{{from}}» на «{{to}}»?", + "confirmRejectTitle": "Отклонить изменение", + "confirmRejectDesc": "Укажите причину отклонения.", + "rejectPlaceholder": "Причина отклонения...", + "userId": "ID пользователя", + "reviewer": "Рецензент", + "comment": "Комментарий", + "totalItems": "Всего записей рецензий профилей: {{total}}", + "sortLabel": "Порядок по времени", + "sortNewest": "Сначала новые", + "sortOldest": "Сначала старые", + "pageSummary": "Всего записей: {{total}}, страница {{page}}", + "prevPage": "Назад", + "nextPage": "Вперёд", + "tabPending": "Ожидают", + "tabApproved": "Одобрены", + "tabRejected": "Отклонены" + }, + "stars": { + "title": "Мои звёзды", + "subtitle": "Избранные скиллы", + "empty": "Избранных скиллов пока нет" + }, + "subscriptions": { + "title": "Мои подписки", + "subtitle": "Скиллы, на обновления которых вы подписаны", + "empty": "Вы ещё не подписались ни на один скилл." + }, + "promotions": { + "title": "Рецензирование продвижений", + "subtitle": "Запросы на продвижение командных скиллов в глобальное пространство имён", + "tabPending": "Ожидают", + "tabApproved": "Одобрены", + "tabRejected": "Отклонены", + "commentPlaceholder": "Комментарий рецензии (необязательно)", + "approve": "Одобрить", + "reject": "Отклонить", + "empty": "Нет запросов на продвижение", + "historyTableLabel": "История продвижений", + "colSkill": "Скилл", + "colVersion": "Версия", + "colSubmitter": "Отправитель", + "colReviewer": "Рецензент", + "colReviewedAt": "Рецензировано", + "colReviewComment": "Комментарий рецензии", + "sortReviewedTimeAsc": "Сортировка по времени рецензии по возрастанию", + "sortReviewedTimeDesc": "Сортировка по времени рецензии по убыванию", + "emptyValue": "-", + "versionTag": "v{{version}}", + "submitterTag": "Отправитель {{user}}", + "fileCountTag": "Файлов: {{count}}", + "packageSizeTag": "{{size}}", + "downloadCountTag": "Загрузок: {{value}}", + "starCountTag": "Звёзд: {{value}}" + }, + "adminNamespaces": { + "title": "Управление пространствами имён", + "subtitle": "Просмотр всех пространств имён и управление участниками, жизненным циклом и действиями управления.", + "statTotal": "Всего пространств имён", + "statActive": "Активные", + "statFrozen": "Замороженные", + "statArchived": "В архиве", + "searchLabel": "Поиск пространств имён", + "searchPlaceholder": "Поиск по slug, отображаемому имени или описанию", + "searchAction": "Поиск", + "clearSearch": "Очистить", + "statusFilter": "Статус", + "typeFilter": "Тип", + "filterAll": "Все", + "typeGlobal": "Глобальное", + "typeTeam": "Командное", + "colNamespace": "Пространство имён", + "colType": "Тип", + "colStatus": "Статус", + "colMembers": "Участники", + "colSkills": "Скиллы", + "colUpdated": "Обновлено", + "empty": "Нет пространств имён, соответствующих текущим фильтрам", + "selectNamespace": "Выберите пространство имён для просмотра деталей", + "members": "Участники", + "skills": "Скиллы", + "currentRole": "Текущая роль", + "platformOverride": "Переопределение платформы", + "yes": "Да", + "no": "Нет", + "noMembership": "Не участник", + "createdAt": "Создано", + "updatedAt": "Обновлено", + "membersTitle": "Управление участниками", + "membersDescription": "Администраторы платформы могут управлять участниками и ролями командных пространств имён. Глобальное пространство имён доступно только для чтения.", + "governanceTitle": "Действия управления", + "governanceDescription": "Заморозка, архивация и восстановление влияют на публикацию и обычную видимость. Действия записываются в журнал аудита.", + "reasonLabel": "Причина", + "reasonPlaceholder": "Объясните необходимость действия для аудиторской проверки.", + "memberRoleUpdated": "Роль участника обновлена", + "freezeAction": "Заморозить пространство имён", + "unfreezeAction": "Разморозить пространство имён", + "archiveAction": "Архивировать пространство имён", + "restoreAction": "Восстановить пространство имён", + "freezeConfirmTitle": "Заморозить пространство имён?", + "freezeConfirmDescription": "«{{name}}» станет доступным только для чтения, и участники не смогут публиковать новые версии скиллов.", + "unfreezeConfirmTitle": "Разморозить пространство имён?", + "unfreezeConfirmDescription": "«{{name}}» снова получит возможности публикации и управления участниками.", + "archiveConfirmTitle": "Архивировать пространство имён?", + "archiveConfirmDescription": "«{{name}}» будет скрыто из обычных разделов продукта и останется доступным в панели администрирования.", + "restoreConfirmTitle": "Восстановить пространство имён?", + "restoreConfirmDescription": "«{{name}}» снова станет видимым в обычных разделах продукта.", + "freezeSuccessTitle": "Пространство имён заморожено", + "freezeSuccessDescription": "«{{name}}» теперь доступно только для чтения.", + "unfreezeSuccessTitle": "Пространство имён разморожено", + "unfreezeSuccessDescription": "«{{name}}» снова активно.", + "archiveSuccessTitle": "Пространство имён архивировано", + "archiveSuccessDescription": "«{{name}}» скрыто из обычных разделов продукта.", + "restoreSuccessTitle": "Пространство имён восстановлено", + "restoreSuccessDescription": "«{{name}}» снова отображается.", + "freezeErrorTitle": "Не удалось заморозить пространство имён", + "unfreezeErrorTitle": "Не удалось разморозить пространство имён", + "archiveErrorTitle": "Не удалось архивировать пространство имён", + "restoreErrorTitle": "Не удалось восстановить пространство имён" + }, + "adminUsers": { + "title": "Управление пользователями", + "subtitle": "Управление пользователями платформы и правами доступа", + "searchLabel": "Поиск пользователей", + "searchPlaceholder": "Поиск по имени пользователя или email...", + "searchHint": "Введите имя пользователя или email, затем нажмите Enter или «Поиск».", + "searchAction": "Поиск", + "clearSearch": "Очистить", + "filterLabel": "Фильтр по статусу", + "filterAll": "Все", + "filterActive": "Активные", + "filterPending": "Ожидающие", + "filterDisabled": "Отключённые", + "empty": "Нет данных о пользователях", + "colUsername": "Имя пользователя", + "colUserId": "ID пользователя", + "copyUserId": "Скопировать ID пользователя для {{username}}", + "colEmail": "Электронная почта", + "colStatus": "Статус", + "colRole": "Роль", + "colCreatedAt": "Создан", + "colActions": "Действия", + "statusActive": "Активен", + "statusPending": "Ожидает", + "statusDisabled": "Отключён", + "changeRole": "Изменить роль", + "approveUser": "Одобрить", + "disable": "Отключить", + "enable": "Включить", + "resetPassword": "Сбросить пароль", + "totalRecords": "Всего {{total}} записей, страница {{page}}", + "prevPage": "Назад", + "nextPage": "Вперёд", + "changeRoleTitle": "Изменить роль пользователя", + "changeRoleDesc": "Назначить новую роль пользователю {{username}}", + "roleLabel": "Роль", + "selectRole": "Выберите роль", + "roleUser": "Пользователь", + "roleReviewer": "Ревьюер", + "roleUserAdmin": "Администратор пользователей", + "roleAuditor": "Аудитор", + "roleSuperAdmin": "Суперадминистратор", + "confirmAction": "Подтвердить действие", + "confirmDisable": "Вы уверены, что хотите отключить пользователя {{username}}?", + "confirmEnable": "Вы уверены, что хотите включить пользователя {{username}}?", + "confirmResetPassword": "Отправить код подтверждения сброса пароля пользователю {{username}}?" + }, + "adminLabels": { + "title": "Управление метками", + "subtitle": "Управление определениями меток, видимостью, переводами и порядком в фильтрах.", + "createAction": "Создать метку", + "editAction": "Редактировать", + "deleteAction": "Удалить", + "saveAction": "Сохранить изменения", + "cancelAction": "Отмена", + "moveUp": "Выше", + "moveDown": "Ниже", + "empty": "Метки ещё не определены.", + "summaryDefinitionsTitle": "Определения", + "summaryVisibleTitle": "Видимые фильтры", + "summaryPrivilegedTitle": "Привилегированные метки", + "colLabel": "Метка", + "colType": "Тип", + "colVisibility": "Видимость", + "colSortOrder": "Порядок сортировки", + "colTranslations": "Переводы", + "colCreatedAt": "Создана", + "colActions": "Действия", + "typeRecommended": "Рекомендуемая", + "typePrivileged": "Привилегированная", + "visibilityVisible": "Видна в фильтрах", + "visibilityHidden": "Скрыта из фильтров", + "createDialogTitle": "Создать метку", + "createDialogDescription": "Задайте новую метку и переводы, которые будут отображаться в поиске и на экранах деталей.", + "editDialogTitle": "Редактировать метку", + "editDialogDescription": "Обновите правила отображения и переводы для этого определения метки.", + "deleteDialogTitle": "Удалить метку", + "deleteDialogDescription": "Удалить метку «{{slug}}» и снять её со всех привязанных скиллов.", + "formSlug": "Slug", + "formType": "Тип", + "formVisibility": "Видимость", + "formSortOrder": "Порядок сортировки", + "formTranslations": "Переводы", + "formTranslationsHint": "Укажите хотя бы одну пару локаль и отображаемое имя.", + "translationLocalePlaceholder": "Локаль, например en или zh-CN", + "translationDisplayNamePlaceholder": "Отображаемое имя", + "addTranslation": "Добавить перевод", + "removeTranslation": "Удалить", + "validationSlugTitle": "Требуется slug", + "validationSlugDescription": "Введите стабильный slug перед сохранением метки.", + "validationSlugPatternDescription": "Используйте только строчные буквы, цифры и одиночные дефисы. Slug должен начинаться и заканчиваться буквой или цифрой.", + "validationTranslationsTitle": "Требуются переводы", + "validationTranslationsDescription": "Нужна хотя бы одна полная пара локаль и отображаемое имя.", + "validationDuplicateLocaleDescription": "Каждая локаль перевода может встречаться только один раз.", + "createSuccessTitle": "Метка создана", + "createSuccessDescription": "Определение метки теперь доступно.", + "createErrorTitle": "Не удалось создать метку", + "updateSuccessTitle": "Метка обновлена", + "updateSuccessDescription": "Определение метки обновлено.", + "updateErrorTitle": "Не удалось обновить метку", + "deleteSuccessTitle": "Метка удалена", + "deleteSuccessDescription": "Определение метки удалено.", + "deleteErrorTitle": "Не удалось удалить метку", + "sortSuccessTitle": "Порядок сортировки обновлён", + "sortSuccessDescription": "Порядок меток сохранён.", + "sortErrorTitle": "Не удалось обновить порядок сортировки", + "fallbackErrorDescription": "Действие с меткой не удалось выполнить." + }, + "auditLog": { + "title": "Журнал аудита", + "subtitle": "Просмотр записей о системных операциях", + "filterAll": "Все", + "filterCliPublish": "Публикация CLI", + "filterCompatPublish": "Совместимая публикация", + "filterReviewSubmit": "Ревью отправлено", + "filterReviewApprove": "Ревью одобрено", + "filterReviewReject": "Ревью отклонено", + "filterPromotionSubmit": "Продвижение отправлено", + "filterPromotionApprove": "Продвижение одобрено", + "filterPromotionReject": "Продвижение отклонено", + "filterReportSkill": "Скилл пожалован", + "filterResolveSkillReport": "Жалоба обработана", + "filterDismissSkillReport": "Жалоба отклонена", + "filterHideSkill": "Скилл скрыт", + "filterArchiveSkill": "Скилл архивирован", + "filterUnhideSkill": "Скилл показан", + "filterUnarchiveSkill": "Скилл восстановлен", + "filterYankVersion": "Версия отозвана", + "filterRebuildSearchIndex": "Индекс поиска перестроен", + "quickFilterSearchRebuild": "Только перестройки индекса поиска", + "clearFilters": "Сбросить фильтры", + "userIdPlaceholder": "ID пользователя...", + "requestIdPlaceholder": "ID запроса...", + "ipPlaceholder": "IP-адрес...", + "resourceTypePlaceholder": "Тип ресурса...", + "resourceIdPlaceholder": "ID ресурса...", + "empty": "Нет записей аудита", + "colTime": "Время", + "colAction": "Действие", + "colUserId": "ID пользователя", + "colUsername": "Имя пользователя", + "colIp": "IP-адрес", + "colDetail": "Подробности", + "totalRecords": "Всего {{total}} записей, страница {{page}}", + "prevPage": "Назад", + "nextPage": "Вперёд" + }, + "profile": { + "title": "Настройки профиля", + "subtitle": "Управление отображаемым именем и личной информацией.", + "displayName": "Отображаемое имя", + "email": "Электронная почта", + "resetPassword": "Сбросить пароль", + "edit": "Редактировать", + "save": "Сохранить", + "saving": "Сохранение...", + "cancel": "Отмена", + "successTitle": "Профиль обновлён", + "successDescription": "Ваше отображаемое имя обновлено.", + "pendingReviewTitle": "Отправлено на проверку", + "pendingReviewDescription": "Изменение отображаемого имени ожидает проверки и вступит в силу после одобрения.", + "defaultError": "Не удалось обновить профиль. Попробуйте снова.", + "validation": { + "length": "Отображаемое имя должно содержать 2–32 символа.", + "pattern": "Отображаемое имя может содержать только китайские и английские символы, цифры, пробелы, подчёркивания и дефисы." + }, + "pendingReview": "Изменение отображаемого имени на «{{name}}» ожидает проверки.", + "rejected": "Изменение отображаемого имени отклонено.", + "rejectedReason": "Причина: {{reason}}", + "reviewHint": "Некоторые изменения требуют проверки администратором перед вступлением в силу.", + "partiallyAppliedTitle": "Применено частично", + "partiallyAppliedDescription": "Часть изменений применена сразу. Остальные ожидают проверки администратором.", + "noChanges": "Нет изменений для сохранения.", + "userId": "ID пользователя" + }, + "security": { + "title": "Настройки безопасности", + "subtitle": "Обновите пароль, если включён вход через локальную учётную запись.", + "currentPassword": "Текущий пароль", + "newPassword": "Новый пароль", + "currentPasswordRequired": "Введите текущий пароль", + "newPasswordRequired": "Введите новый пароль", + "invalidCurrentPassword": "Текущий пароль неверен", + "success": "Пароль успешно изменён", + "successTitle": "Пароль успешно изменён", + "successDescription": "Войдите снова с новым паролем.", + "defaultError": "Не удалось изменить пароль", + "unavailableTitle": "Смена пароля недоступна для этой учётной записи.", + "unavailableDescription": "Эта учётная запись входит через внешнего поставщика идентификации или не имеет локальных учётных данных пароля.", + "submitting": "Отправка...", + "submit": "Обновить пароль" + }, + "accounts": { + "initiateTitle": "Начать объединение учётных записей", + "initiateDesc": "Введите идентификатор вторичной учётной записи. Поддерживается локальное имя пользователя или формат `provider:subject` для внешних идентичностей.", + "secondaryLabel": "Вторичный идентификатор", + "secondaryPlaceholder": "например: other_user или github:123456", + "initiating": "Инициализация...", + "initiate": "Начать объединение", + "initiateSuccess": "Запрос на объединение создан, secondary={{secondaryUserId}}", + "initiateError": "Не удалось начать объединение", + "verifyTitle": "Проверить и завершить объединение", + "verifyDesc": "Сначала завершите проверку токена, затем подтвердите выполнение миграции данных.", + "mergeRequestId": "ID запроса на объединение", + "verificationToken": "Токен проверки", + "verifying": "Проверка...", + "verify": "Завершить объединение", + "verifySuccess": "Проверка успешна, подтвердите выполнение объединения", + "verifyError": "Проверка объединения не удалась", + "confirming": "Подтверждение...", + "confirm": "Подтвердить и завершить объединение", + "confirmSuccess": "Объединение учётных записей завершено", + "confirmError": "Подтверждение объединения не удалось" + }, + "namespace": { + "notFound": "Пространство имён не найдено", + "skillList": "Скиллы", + "emptyTitle": "Нет скиллов", + "emptyDescription": "В этом пространстве имён ещё не опубликовано ни одного скилла" + }, + "skillDetail": { + "back": "Назад", + "notFound": "Скилл не найден", + "notFoundDesc": "Этот скилл мог быть удалён или никогда не существовал", + "loginRequired": "Требуется вход", + "loginRequiredDesc": "Этот скилл является приватным. Войдите, чтобы просмотреть подробности.", + "accessDenied": "Доступ запрещён", + "accessDeniedDesc": "У вас нет прав на просмотр этого скилла", + "tabReadme": "README", + "tabOverview": "Обзор", + "tabFiles": "Файлы", + "tabVersions": "Версии", + "noReadme": "Нет README", + "readmeUnavailable": "README временно недоступен. Попробуйте позже или загрузите эту версию скилла заново.", + "documentationSource": "Источник: {{path}}", + "documentationUnavailableTitle": "Документация недоступна", + "documentationUnavailable": "Не удалось загрузить файл документации. Вы всё ещё можете просмотреть содержимое пакета в списке файлов.", + "packageLinkMissingTitle": "Файл не найден", + "packageLinkMissingDescription": "Эта ссылка указывает на файл, которого нет в текущей версии скилла.", + "authorLabel": "Автор: {{name}}", + "expandOverview": "Развернуть полный обзор", + "collapseOverview": "Свернуть содержимое", + "noDocumentationTitle": "Нет документации пакета", + "noDocumentationDescription": "В этой версии скилла нет читаемого файла обзора.", + "noDocumentationHint": "Многие скиллы содержат только исполняемые файлы. Вы можете продолжить со списком файлов и сведениями о версиях ниже.", + "summaryLabel": "Краткое описание", + "noFiles": "Нет файлов", + "noVersions": "Нет версий", + "fileCount": "{{count}} файлов", + "version": "Версия", + "downloads": "Загрузки", + "rating": "Оценка", + "ratingNone": "Нет", + "namespaceLabel": "Пространство имён", + "loginToRate": "Войдите, чтобы отметить звёздочкой и оценить", + "install": "Установить", + "installMethodClawhub": "ClawHub CLI", + "installMethodSkillhub": "SkillHub CLI", + "download": "Скачать", + "labelsSectionTitle": "Метки", + "labelsSectionDescription": "Прикрепите или удалите рекомендуемые метки, которые помогают пользователям фильтровать и находить этот скилл.", + "labelsSectionDescriptionSuperAdmin": "Управляйте всеми метками этого скилла, включая привилегированные, скрытые из публичных фильтров.", + "currentLabelsTitle": "Текущие метки", + "availableLabelsTitle": "Доступные метки", + "noLabelsAssigned": "К этому скиллу ещё не прикреплено ни одной метки.", + "loadingAvailableLabels": "Загрузка доступных меток...", + "noAvailableLabels": "Сейчас нельзя прикрепить больше меток.", + "addLabel": "Добавить {{label}}", + "removeLabel": "Удалить", + "labelRestrictedHint": "Только суперадминистратор", + "labelAttachSuccessTitle": "Метка прикреплена", + "labelAttachSuccessDescription": "Метки скилла обновлены.", + "labelAttachErrorTitle": "Не удалось прикрепить метку", + "labelDetachSuccessTitle": "Метка удалена", + "labelDetachSuccessDescription": "Метки скилла обновлены.", + "labelDetachErrorTitle": "Не удалось удалить метку", + "labelActionFallbackError": "Действие с меткой не удалось выполнить.", + "lifecycle": "Жизненный цикл", + "lifecycleHint": "Вы можете архивировать этот скилл. Архивированные скиллы скрыты от обычных пользователей и недоступны для скачивания.", + "archivedPublishHint": "Этот скилл архивирован. Восстановите его перед публикацией новой версии.", + "archivedInstallHint": "Этот скилл архивирован и недоступен для публичного скачивания.", + "statusActive": "Активен", + "statusArchived": "Архивирован", + "statusHidden": "Скрыт", + "versionStatusDraft": "Черновик", + "versionStatusScanning": "Сканирование", + "versionStatusScanFailed": "Сканирование не удалось", + "versionStatusUploaded": "Загружена", + "versionStatusPendingReview": "Ожидает ревью", + "versionStatusPublished": "Опубликована", + "versionStatusRejected": "Отклонена", + "versionStatusYanked": "Отозвана", + "pendingPreviewBadge": "Предпросмотр ожидающей", + "rejectedBadge": "Ревью отклонено", + "pendingPreviewTitle": "Вы просматриваете ожидающую версию", + "pendingPreviewDescription": "Эта версия видна только вам. До одобрения ревью вы можете просмотреть README, файлы и сведения о версии, но не можете отмечать звёздочкой, оценивать, жаловаться или скачивать.", + "pendingPreviewInteractionHint": "Звёздочки, оценки и жалобы отключены, пока эта версия ожидает ревью.", + "rejectedPreviewDescription": "Эта версия отклонена и сейчас видна только вам. Обновите её по замечаниям ниже и опубликуйте снова.", + "rejectedPreviewInteractionHint": "Отклонённые версии для предпросмотра нельзя отмечать звёздочкой, оценивать или жаловаться на них.", + "rejectedFeedbackTitle": "Ревью отклонено", + "rejectedFeedbackLabel": "Комментарий ревьюера", + "rejectedFeedbackFallback": "Комментарий об отклонении не предоставлен.", + "governance": "Управление", + "processing": "Обработка...", + "archiveSkill": "Архивировать скилл", + "unarchiveSkill": "Восстановить скилл", + "deleteSkill": "Удалить скилл", + "deleteSkillContinue": "Продолжить", + "deleteSkillFinal": "Удалить безвозвратно", + "withdrawReview": "Отозвать ревью", + "hideSkill": "Скрыть скилл", + "unhideSkill": "Показать скилл", + "archiveConfirmTitle": "Архивировать скилл", + "archiveConfirmDescription": "После архивирования обычные пользователи больше не смогут просматривать или скачивать «{{skill}}». Продолжить?", + "unarchiveConfirmTitle": "Восстановить скилл", + "unarchiveConfirmDescription": "«{{skill}}» снова станет видимым и сможет публиковать новые версии после восстановления.", + "deleteSkillConfirmTitle": "Безвозвратно удалить скилл", + "deleteSkillConfirmDescription": "Будут удалены все версии, файлы и пакеты загрузки для «{{skill}}». Это действие нельзя отменить.", + "deleteSkillInputTitle": "Введите slug скилла для подтверждения", + "deleteSkillInputDescription": "Введите slug скилла «{{slug}}», чтобы продолжить.", + "deleteSkillInputPlaceholder": "Введите slug скилла", + "deleteSkillWarning": "Это физическое жёсткое удаление. Исторические версии и пакеты будут удалены безвозвратно.", + "archiveSuccessTitle": "Скилл архивирован", + "archiveSuccessDescription": "«{{skill}}» архивирован.", + "archiveErrorTitle": "Не удалось архивировать скилл", + "unarchiveSuccessTitle": "Скилл восстановлен", + "unarchiveSuccessDescription": "«{{skill}}» восстановлен.", + "unarchiveErrorTitle": "Не удалось восстановить скилл", + "deleteSkillSuccessTitle": "Скилл удалён", + "deleteSkillSuccessDescription": "«{{skill}}» безвозвратно удалён.", + "deleteSkillErrorTitle": "Не удалось удалить скилл", + "withdrawReviewConfirmTitle": "Отозвать ревью", + "withdrawReviewConfirmDescription": "После отзыва версия {{version}} покинет очередь ревью и вернётся в черновик, чтобы её можно было отправить снова позже.", + "withdrawReviewSuccessTitle": "Ревью отозвано", + "withdrawReviewSuccessDescription": "Версия {{version}} отозвана из ревью.", + "withdrawReviewErrorTitle": "Не удалось отозвать ревью", + "confirmPublish": "Подтвердить публикацию", + "confirmPublishDialogTitle": "Подтвердить публикацию", + "confirmPublishDialogDescription": "Опубликовать версию {{version}} как приватный скилл? Она будет доступна вам для скачивания и установки, но не видна на маркетплейсе.", + "confirmPublishSuccessTitle": "Версия опубликована", + "confirmPublishSuccessDescription": "Версия {{version}} опубликована как приватный скилл.", + "confirmPublishErrorTitle": "Не удалось подтвердить публикацию", + "submitReview": "Отправить на ревью", + "submitReviewDialogTitle": "Отправить на ревью", + "submitReviewDialogDescription": "Отправить версию {{version}} на публичное ревью? После одобрения она станет видимой на маркетплейсе.", + "submitReviewSuccessTitle": "Отправлено на ревью", + "submitReviewSuccessDescription": "Версия {{version}} отправлена на ревью.", + "submitReviewErrorTitle": "Не удалось отправить на ревью", + "deleteVersion": "Удалить версию", + "deleteVersionConfirmTitle": "Удалить версию", + "deleteVersionConfirmDescription": "Версию {{version}} нельзя будет восстановить после удаления. Продолжить?", + "deleteVersionSuccessTitle": "Версия удалена", + "deleteVersionSuccessDescription": "Версия {{version}} удалена.", + "deleteVersionErrorTitle": "Не удалось удалить версию", + "currentVersion": "Текущая", + "currentPublicVersion": "Текущая публичная версия", + "pendingReviewSectionTitle": "Версия на ревью", + "pendingReviewSectionDescription": "v{{pendingVersion}} сейчас на ревью. Публичная и устанавливаемая версия остаётся v{{publishedVersion}}.", + "pendingReviewVersionLabel": "Ожидающая версия", + "pendingReviewStatusLabel": "Статус ревью", + "pendingReviewStatusValue": "На ревью", + "lifecyclePublicVersionLabel": "Текущая публичная версия", + "lifecycleNoPublishedVersion": "Нет опубликованной версии", + "lifecyclePendingVersionLabel": "Версия, ожидающая ревью", + "lifecyclePendingVersionValue": "v{{version}} на ревью", + "lifecycleNoPendingVersion": "Нет версии, ожидающей ревью", + "lifecycleContainerStateLabel": "Состояние контейнера скилла", + "compareVersions": "Сравнить", + "compareDialogTitle": "Сравнение версий", + "compareDialogDescription": "Сравнить v{{source}} с v{{target}}.", + "compareSourceLabel": "Выбранная версия", + "compareTargetLabel": "Сравнение с", + "versionCompareUnavailableTitle": "Недостаточно версий для сравнения", + "versionCompareUnavailableDescription": "Опубликуйте хотя бы две версии, прежде чем использовать сравнение версий.", + "metadataChanges": "Изменения метаданных", + "noMetadataChanges": "Нет изменений метаданных", + "readmeChange": "Изменение README", + "readmeChanged": "Содержимое README изменено", + "readmeUnchanged": "Содержимое README не изменено", + "fileChanges": "Изменения файлов", + "filesAdded": "Добавлено", + "filesRemoved": "Удалено", + "filesChanged": "Изменено", + "expandDiff": "Показать diff", + "collapseDiff": "Скрыть diff", + "binaryFileNotice": "Бинарный файл — нельзя отобразить diff", + "largeFileWarning": "Этот файл большой. Отрисовка может быть медленной.", + "diffLoadError": "Не удалось загрузить содержимое файла", + "diffRetry": "Повторить", + "diffViewUnified": "Объединённый", + "diffViewSplit": "Разделённый", + "rereleaseVersion": "Переиздать", + "rereleaseDialogTitle": "Переиздание из версии", + "rereleaseDialogDescription": "Создать новую опубликованную версию на основе v{{version}}.", + "rereleaseSourceVersion": "Исходная версия", + "rereleaseTargetVersion": "Новая версия", + "rereleaseSuccessTitle": "Версия переиздана", + "rereleaseSuccessDescription": "Создана v{{target}} из v{{source}}.", + "rereleaseErrorTitle": "Не удалось переиздать версию", + "rereleaseWarningTitle": "Предупреждение перед публикацией", + "rereleaseWarningDescription": "Обнаружены следующие напоминания о рисках. Если вы их понимаете и всё равно хотите продолжить, можно продолжить переиздание.", + "rereleaseWarningConfirm": "Продолжить переиздание", + "yankVersion": "Отозвать текущую версию", + "promoteToGlobal": "Продвинуть в Global", + "promotionSectionTitle": "Продвинуть в Global", + "promotionSectionDescription": "Отправить текущую опубликованную версию v{{version}} на ревью в глобальное пространство имён.", + "promotionConfirmTitle": "Отправить запрос на продвижение", + "promotionConfirmDescription": "Отправить v{{version}} скилла «{{skill}}» на продвижение в глобальное пространство имён?", + "promotionSuccessTitle": "Запрос на продвижение отправлен", + "promotionSuccessDescription": "v{{version}} скилла «{{skill}}» теперь в очереди ревью на продвижение в Global.", + "promotionDuplicateTitle": "Продвижение уже ожидает", + "promotionDuplicateDescription": "Для этой версии уже есть ожидающий запрос на продвижение.", + "promotionAlreadyPromotedTitle": "Скилл уже продвинут", + "promotionAlreadyPromotedDescription": "Этот скилл уже продвинут в глобальное пространство имён.", + "promotionErrorTitle": "Не удалось отправить запрос на продвижение", + "reportSkill": "Пожаловаться на скилл", + "reportedSkill": "Жалоба отправлена", + "reportDialogTitle": "Пожаловаться на скилл", + "reportDialogDescription": "Укажите причину, чтобы администраторы могли быстро проверить и принять меры.", + "reportReasonPlaceholder": "Причина, например нарушение политики, вводящий в заблуждение контент или нарушение прав", + "reportDetailsPlaceholder": "Дополнительные сведения (необязательно)", + "submitReport": "Отправить жалобу", + "reportReasonRequired": "Укажите причину жалобы", + "reportSuccessTitle": "Жалоба отправлена", + "reportSuccessDescription": "Администраторы скоро рассмотрят эту жалобу.", + "downloadErrorTitle": "Скачивание не удалось", + "reportErrorTitle": "Жалоба не отправлена", + "share": { + "button": "Поделиться", + "copied": "Скопировано", + "defaultDescription": "Полезный скилл" + } + }, + "skillCompare": { + "pageTitle": "Сравнение версий", + "filesChanged": "Изменено файлов: {{count}}", + "searchFiles": "Поиск файлов", + "errorLoadingCompare": "Не удалось загрузить сравнение версий", + "loadDiff": "Загрузить diff", + "loading": "Загрузка...", + "totalFiles": "Файлы", + "addedLines": "Добавленные строки", + "removedLines": "Удалённые строки", + "baseVersion": "Базовая версия", + "headVersion": "Целевая версия", + "fileList": "Список файлов", + "binaryFile": "Бинарный файл — нельзя отобразить diff", + "truncatedFile": "Вывод diff обрезан", + "notEnoughPublishedVersions": "Опубликуйте хотя бы две версии перед сравнением.", + "noFilesFound": "Подходящие файлы не найдены.", + "changeTypeAdded": "Добавлен", + "changeTypeModified": "Изменён", + "changeTypeRemoved": "Удалён" + }, + "reports": { + "title": "Жалобы на скиллы", + "subtitle": "Обработка жалоб на скиллы, отправленных пользователями", + "tabPending": "Ожидающие", + "tabResolved": "Обработанные", + "tabDismissed": "Отклонённые", + "empty": "Нет жалоб", + "reporter": "Автор жалобы", + "handledBy": "Обработал", + "resolve": "Обработать", + "resolveAndHide": "Обработать и скрыть", + "resolveAndArchive": "Обработать и архивировать", + "dismiss": "Отклонить", + "resolveConfirmTitle": "Обработать жалобу", + "resolveConfirmDescription": "Отметить жалобу на «{{skill}}» как обработанную?", + "resolveAndHideConfirmDescription": "Обработать жалобу на «{{skill}}» и скрыть скилл от публичного доступа?", + "resolveAndArchiveConfirmDescription": "Обработать жалобу на «{{skill}}» и архивировать скилл?", + "dismissConfirmTitle": "Отклонить жалобу", + "dismissConfirmDescription": "Отклонить жалобу на «{{skill}}»?", + "resolveSuccessTitle": "Жалоба обработана", + "resolveSuccessDescription": "Жалоба на «{{skill}}» отмечена как обработанная.", + "resolveAndHideSuccessTitle": "Жалоба обработана, скилл скрыт", + "resolveAndHideSuccessDescription": "«{{skill}}» скрыт после обработки жалобы.", + "resolveAndArchiveSuccessTitle": "Жалоба обработана, скилл архивирован", + "resolveAndArchiveSuccessDescription": "«{{skill}}» архивирован после обработки жалобы.", + "dismissSuccessTitle": "Жалоба отклонена", + "dismissSuccessDescription": "Жалоба на «{{skill}}» отклонена.", + "resolveErrorTitle": "Не удалось обработать жалобу", + "resolveAndHideErrorTitle": "Не удалось обработать жалобу и скрыть скилл", + "resolveAndArchiveErrorTitle": "Не удалось обработать жалобу и архивировать скилл", + "dismissErrorTitle": "Не удалось отклонить жалобу" + }, + "governance": { + "title": "Центр управления", + "subtitle": "Отслеживайте ревью, продвижения, жалобы и активность аудита в одном месте.", + "pendingReviews": "Ожидающие ревью", + "pendingPromotions": "Ожидающие продвижения", + "pendingReports": "Ожидающие жалобы", + "unreadNotifications": "Непрочитанные уведомления", + "inboxTitle": "Входящие управления", + "inboxSubtitle": "Единая очередь операционных задач, требующих действия.", + "tabAll": "Все", + "tabReview": "Ревью", + "tabPromotion": "Продвижения", + "tabReport": "Жалобы", + "emptyInbox": "Сейчас нет задач управления.", + "openItem": "Открыть", + "notificationsTitle": "Уведомления", + "notificationsSubtitle": "Недавние обновления управления, требующие вашего внимания.", + "emptyNotifications": "Уведомлений пока нет.", + "markRead": "Отметить как прочитанное", + "activityTitle": "Активность управления", + "activitySubtitle": "Недавние события аудита по ревью, продвижению, жалобам и действиям жизненного цикла.", + "emptyActivity": "Нет недавней активности управления.", + "unknownActor": "Неизвестный участник", + "searchMaintenanceTitle": "Обслуживание индекса поиска", + "searchMaintenanceDescription": "Перестроить полный индекс поиска скиллов. Это действие доступно только суперадминистраторам и предназначено для полной перезагрузки после изменения правил поиска.", + "searchMaintenanceHint": "Это может занять время. Не запускайте повторно слишком часто.", + "searchRebuildAction": "Перестроить полный индекс поиска", + "searchRebuildRunning": "Перестроение...", + "searchRebuildConfirmTitle": "Перестроить полный индекс поиска?", + "searchRebuildConfirmDescription": "Система перестроит документы поиска для всех скиллов по текущим правилам индексации. Операция может занять некоторое время.", + "searchRebuildSuccessTitle": "Перестроение индекса поиска запущено", + "searchRebuildSuccessDescription": "Система перестраивает полный индекс поиска по текущим правилам.", + "searchRebuildErrorTitle": "Не удалось перестроить индекс поиска" + }, + "members": { + "title": "Управление участниками", + "addMember": "Добавить участника", + "addingMember": "Добавление...", + "addDialogTitle": "Добавить участника пространства имён", + "addDialogDescription": "Найдите пользователей платформы или введите ID пользователя напрямую. Добавленные пользователи сразу смогут работать в этом пространстве имён.", + "searchLabel": "Поиск пользователей", + "searchPlaceholder": "Поиск по имени пользователя, email или ID пользователя", + "searchHint": "Введите не менее 2 символов для поиска. В результатах будет ID пользователя, которого можно добавить напрямую.", + "searchAction": "Поиск", + "searchTooShort": "Ключевые слова поиска должны содержать не менее 2 символов", + "searchResultsTitle": "Кандидаты", + "searchEmpty": "Нет активных пользователей для добавления", + "selectCandidate": "Использовать этого пользователя", + "manualUserIdLabel": "Ввести ID пользователя вручную", + "manualUserIdPlaceholder": "Например: user-123 или usr_abcd", + "manualUserIdHint": "Если вы уже знаете ID пользователя, можете добавить его здесь напрямую.", + "userIdRequired": "Требуется ID пользователя", + "roleLabel": "Роль участника", + "roleOwner": "OWNER", + "roleAdmin": "ADMIN", + "roleMember": "MEMBER", + "saveRole": "Сохранить роль", + "savingRole": "Сохранение...", + "changeRole": "Изменить роль", + "colUserId": "ID пользователя", + "colUsername": "Имя пользователя", + "colEmail": "Электронная почта", + "colRole": "Роль", + "colJoinedAt": "Дата вступления", + "colActions": "Действия", + "remove": "Удалить", + "empty": "Нет участников", + "namespaceNotFound": "Пространство имён не найдено", + "globalReadOnly": "Это встроенное системное пространство имён. Состав участников можно только просматривать, но нельзя изменять.", + "frozenReadOnly": "Это пространство имён заморожено. Вы можете просматривать участников, но не можете добавлять, удалять или менять роли.", + "archivedReadOnly": "Это пространство имён архивировано. Вы можете просматривать участников, но не можете менять состав, пока оно не будет восстановлено.", + "memberReadOnly": "Вы обычный участник этого пространства имён. Список участников можно просматривать, но изменять его могут только OWNER или ADMIN.", + "addSuccessTitle": "Участник добавлен", + "addSuccessDescription": "Пользователь {{userId}} добавлен в это пространство имён.", + "addErrorTitle": "Не удалось добавить участника", + "updateRoleSuccessTitle": "Роль участника обновлена", + "updateRoleSuccessDescription": "Пользователь {{userId}} теперь {{role}}.", + "updateRoleErrorTitle": "Не удалось обновить роль участника", + "removeConfirmTitle": "Удалить участника", + "removeConfirmDescription": "После удаления пользователь {{userId}} потеряет доступ к этому пространству имён.", + "removeSuccessTitle": "Участник удалён", + "removeSuccessDescription": "Пользователь {{userId}} удалён из этого пространства имён.", + "removeErrorTitle": "Не удалось удалить участника", + "searchEmptyMemberHint": "Если пользователь уже в этом пространстве имён, он здесь не появится — проверьте список участников.", + "transferOwnership": "Передать владение", + "transferDialogTitle": "Передать владение пространством имён", + "transferDialogDescription": "Передайте владение другому участнику. Вы станете ADMIN.", + "transferWarning": "Это действие нельзя отменить. После передачи вы больше не будете владельцем.", + "transferNewOwnerLabel": "Новый владелец", + "transferSelectPlaceholder": "Выберите участника", + "transferNoCandidates": "Нет других участников, которым можно передать владение.", + "transferConfirmSlugPrompt": "Введите slug пространства имён для подтверждения", + "transferConfirmSlugHint": "Введите {{slug}} для подтверждения", + "transferConfirmAction": "Передать владение", + "transferring": "Передача...", + "transferSuccessTitle": "Владение передано", + "transferSuccessDescription": "{{userId}} теперь владелец этого пространства имён.", + "transferErrorTitle": "Не удалось передать владение", + "batchImport": "Пакетный импорт", + "batchDialogTitle": "Пакетный импорт участников", + "batchDialogDescription": "Загрузите CSV-файл, чтобы добавить нескольких участников сразу. В каждой строке должны быть ID пользователя и роль.", + "batchStepUpload": "Загрузить CSV", + "batchStepPreview": "Предпросмотр", + "batchStepResults": "Результаты", + "batchDownloadTemplate": "Скачать шаблон CSV", + "batchDropHint": "Перетащите CSV-файл сюда или нажмите, чтобы выбрать", + "batchFormatHint": "Поддерживается только формат .csv", + "batchParseError": "Не удалось разобрать CSV-файл", + "batchEmptyFile": "CSV-файл пуст или не содержит допустимых строк", + "batchPreviewTitle": "Предпросмотр ({{count}} строк)", + "batchColUserId": "ID пользователя", + "batchColRole": "Роль", + "batchColStatus": "Статус", + "batchValidationMissingUserId": "Отсутствует ID пользователя", + "batchValidationInvalidRole": "Недопустимая роль (должна быть MEMBER или ADMIN)", + "batchValidationDuplicate": "Дублирующийся ID пользователя", + "batchValidationValid": "Готово", + "batchValidRows": "{{valid}} допустимых, {{invalid}} недопустимых", + "batchSubmitting": "Импорт...", + "batchSubmit": "Импортировать {{count}} участников", + "batchResultTitle": "Результаты импорта", + "batchResultSummary": "{{success}} успешно, {{failure}} с ошибкой из {{total}} всего", + "batchResultSuccess": "Добавлен", + "batchResultAlreadyMember": "Уже участник", + "batchResultUserNotFound": "Пользователь не найден", + "batchResultInvalidRole": "Недопустимая роль", + "batchResultUnknownError": "Неизвестная ошибка", + "batchDone": "Готово", + "batchBack": "Назад" + }, + "namespaceEdit": { + "editButton": "Редактировать", + "dialogTitle": "Редактировать пространство имён", + "displayNameLabel": "Отображаемое имя", + "displayNameRequired": "Требуется отображаемое имя", + "descriptionLabel": "Описание", + "saveAction": "Сохранить", + "saving": "Сохранение...", + "saveSuccess": "Пространство имён успешно обновлено", + "saveErrorTitle": "Не удалось обновить пространство имён" + }, + "apiError": { + "unauthorized": "Сессия истекла, войдите снова", + "forbidden": "У вас нет прав для этого действия", + "notFound": "Запрошенный ресурс не найден", + "serverError": "Ошибка сервера, повторите попытку позже", + "networkError": "Сбой сетевого подключения, проверьте сеть", + "unknown": "Операция не выполнена" + }, + "copyButton": { + "copied": "Скопировано", + "copy": "Копировать" + }, + "createToken": { + "title": "Создать API-токен", + "description": "Создать новый API-токен для доступа через CLI или API", + "nameLabel": "Имя токена", + "namePlaceholder": "например: my-cli-token", + "nameRequired": "Укажите имя токена", + "nameTooLong": "Имя токена не более {{max}} символов", + "nameDuplicate": "У вас уже есть токен с таким именем", + "creating": "Создание...", + "create": "Создать", + "successTitle": "Токен создан", + "successDescription": "Скопируйте и сохраните токен сейчас. Он показывается и копируется только при создании. Если понадобится снова — создайте новый токен.", + "tokenLabel": "Токен", + "nameDisplay": "Имя", + "expirationLabel": "Срок действия", + "expirationHint": "По умолчанию токены не истекают. Можно задать автоматический срок действия.", + "expirationNever": "Без срока", + "expiration7d": "Истекает через 7 дней", + "expiration30d": "Истекает через 30 дней", + "expiration90d": "Истекает через 90 дней", + "expirationCustom": "Своя дата и время", + "expiresAtRequired": "Выберите свой срок действия", + "expiresAtDisplay": "Истекает", + "copyToken": "Копировать токен", + "copySuccess": "Токен скопирован в буфер обмена", + "copyFailed": "Не удалось скопировать токен. Повторите попытку." + }, + "dialog": { + "confirm": "Подтвердить", + "cancel": "Отмена", + "delete": "Удалить", + "close": "Закрыть" + }, + "error": { + "auth": { + "local": { + "invalidCredentials": "Неверное имя пользователя или пароль", + "accountDisabled": "Эта учётная запись отключена", + "accountPending": "Эта учётная запись ожидает активации", + "accountMerged": "Эта учётная запись объединена и больше не может использоваться для входа", + "locked": "Слишком много неудачных попыток. Повторите позже" + }, + "direct": { + "disabled": "Совместимость прямой аутентификации отключена", + "providerUnsupported": "Этот способ входа не поддерживается" + }, + "sessionBootstrap": { + "disabled": "Инициализация сессии отключена", + "providerUnsupported": "Этот способ SSO-инициализации не поддерживается", + "notAuthenticated": "Внешняя аутентифицированная сессия не найдена" + } + } + }, + "filePreview": { + "loadError": "Не удалось загрузить файл", + "tooLarge": "Файл слишком большой для предпросмотра", + "binaryFile": "Бинарные файлы нельзя просмотреть", + "unsupported": "Этот тип файла не поддерживается для предпросмотра", + "downloadFile": "Скачать файл", + "downloadHint": "Скачать {{name}}", + "copy": "Копировать содержимое", + "copySuccess": "Скопировано в буфер обмена", + "close": "Закрыть" + }, + "fileTree": { + "title": "Файлы" + }, + "footer": { + "resources": "Ресурсы", + "docs": "Документация", + "api": "API", + "community": "Сообщество", + "privacy": "Политика конфиденциальности", + "terms": "Условия использования", + "copyright": "© 2026 SkillHub. Все права защищены." + }, + "layout": { + "footerDescription": "Реестр скиллов: эффективное управление и распространение скиллов для разработчиков." + }, + "loginButton": { + "loading": "Загрузка...", + "loginWith": "Войти через {{name}}" + }, + "namespaceStatus": { + "active": "Активно", + "frozen": "Заморожено", + "archived": "В архиве", + "frozenHint": "Это пространство имён сейчас только для чтения: нельзя публиковать, рецензировать или менять участников.", + "archivedHint": "Это пространство имён скрыто из публичных точек входа и видно только участникам на панели.", + "immutableHint": "Это встроенное системное пространство имён: управление и состав участников менять нельзя." + }, + "notification": { + "title": "Уведомления", + "empty": "Нет уведомлений", + "markAllRead": "Отметить все прочитанными", + "deleteRead": "Удалить прочитанные", + "viewAll": "Все уведомления", + "unread": "Непрочитанные", + "all": "Все", + "publish": "Публикация", + "review": "Ревью", + "promotion": "Продвижение", + "report": "Жалоба", + "timeAgo": "{{time}} назад", + "preferences": { + "title": "Настройки уведомлений", + "description": "Управление предпочтениями уведомлений", + "publish": "Уведомления о публикации", + "publishDesc": "Уведомлять, когда скилл опубликован", + "review": "Уведомления о ревью", + "reviewDesc": "Уведомлять об отправке, одобрении или отклонении ревью", + "promotion": "Уведомления о продвижении", + "promotionDesc": "Уведомлять о заявке, одобрении или отклонении продвижения", + "report": "Уведомления о жалобах", + "reportDesc": "Уведомлять о подаче или разрешении жалобы" + } + }, + "nsReviews": { + "title": "Ревью пространства имён", + "loadingNamespace": "Загрузка сведений о пространстве имён", + "reviewsFor": "Задачи ревью для {{name}}", + "empty": "Нет записей ревью", + "version": "Версия {{version}}", + "tabPending": "Ожидают", + "tabApproved": "Одобрено", + "tabRejected": "Отклонено", + "sortLabel": "Порядок по времени", + "sortNewest": "Сначала новые", + "sortOldest": "Сначала старые", + "openReview": "Открыть ревью", + "pageSummary": "Всего записей: {{total}}, страница {{page}}", + "prevPage": "Назад", + "nextPage": "Вперёд", + "globalReadOnly": "Это встроенное системное пространство имён. История ревью видна, но новые действия ревью недоступны.", + "frozenReadOnly": "Это пространство имён заморожено. Историю ревью можно смотреть, но обрабатывать задачи нельзя.", + "archivedReadOnly": "Это пространство имён в архиве. Историю ревью можно смотреть, но обрабатывать задачи нельзя, пока оно не восстановлено." + }, + "pagination": { + "prev": "Назад", + "next": "Вперёд", + "pagePrefix": "Стр.", + "pageSuffix": "", + "goToPage": "Перейти на страницу {{page}}" + }, + "publish": { + "title": "Опубликовать скилл", + "subtitle": "Загрузить пакет скилла в SkillHub", + "reviewNotice": { + "title": "Уведомление о ревью", + "description": "Отправленные пакеты скиллов проходят ревью администратора перед публикацией." + }, + "namespace": "Пространство имён", + "selectNamespace": "Выберите пространство имён", + "visibility": "Видимость", + "visibilityOptions": { + "public": "Публичный", + "namespaceOnly": "Только пространство имён", + "loggedInUsersOnly": "Только вошедшие пользователи", + "private": "Приватный" + }, + "file": "Файл пакета скилла", + "removeSelectedFile": "Убрать выбранный файл", + "publishing": "Публикация...", + "confirm": "Подтвердить публикацию", + "success": "Успешно опубликовано", + "successDescription": "{{skill}} отправлен на ревью и станет доступен после одобрения администратором", + "publishedTitle": "Успешно опубликовано", + "publishedDescription": "{{skill}} уже доступен для скачивания", + "pendingReviewTitle": "Отправлено на ревью", + "pendingReviewDescription": "{{skill}} отправлен на ревью и станет доступен после одобрения администратором", + "error": "Публикация не удалась", + "timeoutTitle": "Истекло время публикации", + "timeoutDescription": "Запрос на публикацию занял слишком много времени. Позже проверьте список скиллов или повторите попытку.", + "versionExistsTitle": "Версия уже существует", + "versionExistsDescription": "Эта версия скилла уже опубликована. Обновите версию в SKILL.md, пересоберите пакет и загрузите снова.", + "precheckFailedTitle": "Проверка перед публикацией не пройдена", + "precheckFailedDescription": "В пакете, похоже, есть секрет, токен или пароль. Замените реальные учётные данные на плейсхолдеры и повторите попытку.", + "warningConfirmTitle": "Предупреждение перед публикацией", + "warningConfirmDescription": "Обнаружены следующие напоминания о рисках. Если вы их понимаете и всё равно хотите продолжить — можно опубликовать.", + "warningConfirmContinue": "Продолжить публикацию", + "warningConfirmCancel": "Вернуться и исправить", + "frontmatterFailedTitle": "Неверный формат SKILL.md", + "frontmatterFailedDescription": "Проверьте YAML frontmatter в начале SKILL.md. Если значение поля содержит двоеточие, заключите его в кавычки.", + "selectRequired": "Выберите пространство имён и файл", + "folderPackagingFailed": "Не удалось упаковать выбранную папку. Убедитесь, что она содержит файлы." + }, + "ratingInput": { + "yourRating": "Ваша оценка: {{score}} зв." + }, + "review": { + "detail": "Детали ревью", + "id": "ID ревью", + "backToList": "К списку", + "namespace": "Пространство имён / Slug", + "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": "Не удалось отклонить", + "approveDisabledScanning": "Сканирование безопасности ещё выполняется. Одобрение станет доступно после завершения.", + "notFound": "Задача ревью не найдена", + "skillDetailTitle": "Карточка скилла", + "skillDetailDescription": "Разделы ниже зафиксированы на ожидающей версии, привязанной к этой задаче ревью: {{skill}}", + "skillDetailError": "Не удалось загрузить карточку скилла для этого ревью.", + "activeReviewVersion": "Версия на ревью", + "downloadSkillZip": "Скачать ZIP скилла", + "noDocumentation": "В этой версии на ревью нет читаемого файла документации.", + "complianceDiffTitle": "Сравнение деклараций соответствия", + "complianceDiffDescription": "Сравните изменения соответствия между опубликованной версией {{baseVersion}} и ожидающей версией {{pendingVersion}}.", + "complianceDiffBaseVersion": "Базовая версия", + "complianceDiffPendingVersion": "Ожидающая версия", + "complianceDiffBaseDigest": "Базовый дайджест", + "complianceDiffPendingDigest": "Дайджест ожидающей версии", + "complianceDiffAdded": "Добавлено", + "complianceDiffRemoved": "Удалено", + "complianceDiffModified": "Изменено", + "complianceDiffAddedLabel": "Добавлено: {{count}}", + "complianceDiffRemovedLabel": "Удалено: {{count}}", + "complianceDiffModifiedLabel": "Изменено: {{count}}", + "complianceDiffViewDetails": "Подробнее", + "complianceDiffBaseRemoved": "Эта декларация есть в базовой версии, но удалена из ожидающей версии.", + "complianceDiffPendingAdded": "Эта декларация добавлена в ожидающую версию." + }, + "routeGuard": { + "forbiddenTitle": "У вас нет прав для просмотра этой страницы", + "forbiddenDescription": "Права вашей учётной записи изменились. Возврат на предыдущую страницу." + }, + "securityAudit": { + "title": "Аудит безопасности", + "dialogDescription": "Подробные результаты сканирования безопасности для этой версии.", + "findings": "Находки", + "findingsCount": "Находок: {{count}}", + "totalFindings": "Всего находок: {{count}}", + "scanDuration": "{{seconds}} с", + "statusScanning": "Сканирование", + "statusScanFailed": "Сканирование не удалось", + "remediation": "Рекомендации", + "viewDetails": "Подробности", + "verdict": { + "SAFE": "Безопасно", + "SUSPICIOUS": "Подозрительно", + "DANGEROUS": "Опасно", + "BLOCKED": "Высокий риск" + }, + "severity": { + "CRITICAL": "Критический", + "HIGH": "Высокий", + "MEDIUM": "Средний", + "LOW": "Низкий", + "INFO": "Инфо" + } + }, + "skillCard": { + "starred": "В избранном", + "starredAction": "Нажмите, чтобы убрать из избранного", + "unstarTitle": "Убрать из избранного", + "unstarDescription": "Убрать «{{name}}» из избранных скиллов?", + "unstarConfirm": "Убрать" + }, + "starButton": { + "starred": "В избранном", + "star": "В избранное" + }, + "subscribeButton": { + "subscribed": "Подписка оформлена", + "subscribe": "Подписаться" + }, + "toast": { + "success": "Успех", + "error": "Ошибка", + "warning": "Предупреждение", + "info": "Инфо" + }, + "token": { + "title": "API-токены", + "createNew": "Создать токен", + "copyHint": "Из соображений безопасности открытый текст токена можно скопировать только один раз при создании. Позже его нельзя просмотреть или скопировать снова. Если токен нужен — создайте новый.", + "empty": "Токены ещё не созданы", + "emptyHint": "Нажмите кнопку выше, чтобы создать первый токен", + "name": "Имя", + "prefix": "Префикс токена", + "createdAt": "Создан", + "lastUsed": "Последнее использование", + "expiresAt": "Истекает", + "neverExpires": "Без срока", + "actions": "Действия", + "copy": "Копировать", + "copySuccess": "Токен скопирован в буфер обмена", + "copyFailed": "Не удалось скопировать токен. Повторите попытку.", + "copyUnavailableTitle": "Открытый текст старого токена нельзя скопировать снова", + "copyUnavailableDescription": "Открытый текст токена показывается только один раз после создания. Если его нет — создайте новый токен.", + "editExpiration": "Изменить срок", + "editExpirationTitle": "Изменить срок действия токена", + "editExpirationDescription": "Задайте новый срок действия для токена «{{name}}».", + "saveExpiration": "Сохранить срок", + "updatingExpiration": "Сохранение...", + "updateExpirationSuccess": "Срок действия токена обновлён", + "updateExpirationFailed": "Не удалось обновить срок действия токена", + "delete": "Удалить", + "deleteTitle": "Удалить токен", + "deleteDescription": "Удалить токен «{{name}}»? Это действие нельзя отменить.", + "deleteSuccess": "Токен удалён", + "deleteFailed": "Не удалось удалить", + "loading": "Загрузка..." + }, + "upload": { + "dropHint": "Отпустите для загрузки...", + "dragHint": "Перетащите ZIP-файл сюда или нажмите, чтобы выбрать", + "formatHint": "Поддерживается только формат .zip", + "folderHint": "Или выберите папку для упаковки и загрузки" + }, + "user": { + "menu": { + "dashboard": "Панель", + "mySkills": "Мои скиллы", + "myNamespaces": "Мои пространства имён", + "namespacesAdmin": "Управление пространствами имён", + "governance": "Центр управления", + "stars": "Избранное", + "subscriptions": "Мои подписки", + "reviews": "Управление ревью", + "promotions": "Управление продвижением", + "reports": "Управление жалобами", + "users": "Пользователи", + "labels": "Метки", + "auditLog": "Журнал аудита", + "security": "Безопасность", + "profile": "Профиль", + "notifications": "Уведомления", + "accounts": "Объединение учётных записей", + "logout": "Выйти" + } + }, + "routeError": { + "title": "Что-то пошло не так", + "description": "На этой странице произошла ошибка. Можно вернуться назад или перезагрузить раздел.", + "reset": "Попробовать снова" + } +} diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e2846cae..f858ce9a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1212,7 +1212,8 @@ "upload": { "dropHint": "放开以上传文件...", "dragHint": "拖拽 ZIP 文件到此处,或点击选择", - "formatHint": "仅支持 .zip 格式" + "formatHint": "仅支持 .zip 格式", + "folderHint": "或选择文件夹,自动打包上传" }, "layout": { "footerDescription": "技能注册中心,为开发者提供高效的技能管理和分发平台。" @@ -1389,7 +1390,8 @@ "warningConfirmCancel": "返回修改", "frontmatterFailedTitle": "SKILL.md 格式有误", "frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。", - "selectRequired": "请选择命名空间和文件" + "selectRequired": "请选择命名空间和文件", + "folderPackagingFailed": "无法打包所选文件夹,请确认其中包含文件。" }, "toast": { "success": "成功", diff --git a/web/src/i18n/ru-locale.test.ts b/web/src/i18n/ru-locale.test.ts new file mode 100644 index 00000000..9f262ead --- /dev/null +++ b/web/src/i18n/ru-locale.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import en from './locales/en.json' +import ru from './locales/ru.json' + +function leafKeys(value: unknown, prefix = ''): string[] { + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + return Object.entries(value as Record).flatMap(([key, child]) => + leafKeys(child, prefix ? `${prefix}.${key}` : key), + ) + } + return [prefix] +} + +function placeholders(text: string): string[] { + return [...text.matchAll(/\{\{[^}]+\}\}/g)].map((match) => match[0]).sort() +} + +describe('russian locale', () => { + it('mirrors the english key tree', () => { + expect(leafKeys(ru).sort()).toEqual(leafKeys(en).sort()) + }) + + it('preserves interpolation placeholders', () => { + const enMap = Object.fromEntries(leafKeys(en).map((key) => { + const parts = key.split('.') + let cursor: unknown = en + for (const part of parts) { + cursor = (cursor as Record)[part] + } + return [key, String(cursor)] + })) + const mismatches: string[] = [] + for (const key of leafKeys(ru)) { + const parts = key.split('.') + let cursor: unknown = ru + for (const part of parts) { + cursor = (cursor as Record)[part] + } + if (placeholders(String(cursor)).join() !== placeholders(enMap[key] ?? '').join()) { + mismatches.push(key) + } + } + expect(mismatches).toEqual([]) + }) + + it('translates core navigation labels', () => { + expect(ru.nav.home).not.toBe(en.nav.home) + expect(ru.nav.home.length).toBeGreaterThan(0) + expect(ru.login.title.length).toBeGreaterThan(0) + }) +}) diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index c18e72c9..209aba28 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -144,7 +144,7 @@ export function DashboardPage() {
{skill.displayName}
diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index bcaeb16a..23770c85 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { UploadZone } from '@/features/publish/upload-zone' +import { packageFolderAsZip } from '@/features/publish/folder-zip' import { extractPrecheckWarnings, isFrontmatterFailureMessage, @@ -41,6 +42,7 @@ export function PublishPage() { const [visibility, setVisibility] = useState(prefill.visibility) const [warningDialogOpen, setWarningDialogOpen] = useState(false) const [precheckWarnings, setPrecheckWarnings] = useState([]) + const [isPackaging, setIsPackaging] = useState(false) const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const publishMutation = usePublishSkill() @@ -66,6 +68,18 @@ export function PublishPage() { setWarningDialogOpen(false) } + const handleFolderSelect = async (files: File[]) => { + setIsPackaging(true) + try { + const zip = await packageFolderAsZip(files) + handleFileSelect(zip) + } catch { + toast.error(t('publish.folderPackagingFailed')) + } finally { + setIsPackaging(false) + } + } + const publishSkill = async (confirmWarnings = false) => { if (!selectedFile || !namespaceSlug) { toast.error(t('publish.selectRequired')) @@ -201,7 +215,8 @@ export function PublishPage() { {selectedFile && (
diff --git a/web/src/pages/notifications.tsx b/web/src/pages/notifications.tsx index 891c5732..858b0718 100644 --- a/web/src/pages/notifications.tsx +++ b/web/src/pages/notifications.tsx @@ -12,6 +12,7 @@ import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' +import { formatRelativeTime } from '@/shared/lib/format-relative-time' const PAGE_SIZE = 20 @@ -29,19 +30,6 @@ function getCategoryKey(cat: Category): string { } } -function formatRelativeTime(dateStr: string, lang: string): string { - const diff = Date.now() - new Date(dateStr).getTime() - const minutes = Math.floor(diff / 60_000) - const hours = Math.floor(diff / 3_600_000) - const days = Math.floor(diff / 86_400_000) - const isChinese = lang.startsWith('zh') - if (minutes < 1) return isChinese ? '刚刚' : 'just now' - if (minutes < 60) return isChinese ? `${minutes}分钟` : `${minutes}m` - if (hours < 24) return isChinese ? `${hours}小时` : `${hours}h` - if (days < 30) return isChinese ? `${days}天` : `${days}d` - return new Date(dateStr).toLocaleDateString() -} - function CategoryBadge({ category }: { category: NotificationItem['category'] }) { const { t } = useTranslation() const colorMap: Record = { diff --git a/web/src/pages/reset-password.tsx b/web/src/pages/reset-password.tsx index ef4777de..229bf7bd 100644 --- a/web/src/pages/reset-password.tsx +++ b/web/src/pages/reset-password.tsx @@ -100,7 +100,7 @@ export function ResetPasswordPage() {

{t('resetPassword.successMessage')}

- + {t('resetPassword.backToLogin')}
diff --git a/web/src/pages/settings/security.tsx b/web/src/pages/settings/security.tsx index 3726ad7d..8e4dc005 100644 --- a/web/src/pages/settings/security.tsx +++ b/web/src/pages/settings/security.tsx @@ -72,7 +72,7 @@ export function SecuritySettingsPage() { clearSessionScopedQueries(queryClient) queryClient.setQueryData(['auth', 'me'], null) } - await navigate({ to: '/login', search: { returnTo: '' } }) + await navigate({ to: '/login' }) } catch (error) { if (error instanceof ApiError && error.status === 401) { setErrorMessage(t('security.invalidCurrentPassword')) diff --git a/web/src/shared/components/language-switcher.tsx b/web/src/shared/components/language-switcher.tsx index d28f54e5..8feae28a 100644 --- a/web/src/shared/components/language-switcher.tsx +++ b/web/src/shared/components/language-switcher.tsx @@ -24,9 +24,10 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) { const languages = [ { code: 'zh', name: '中文' }, { code: 'en', name: 'English' }, + { code: 'ru', name: 'Русский' }, ] - // 获取当前语言的主要代码(去掉地区代码) + // Primary language code only (strip region, e.g. ru-RU → ru). const currentLangCode = i18n.language?.split('-')[0] || 'zh' const currentLanguage = languages.find((lang) => lang.code === currentLangCode) || languages[0] diff --git a/web/src/shared/lib/api-error.ts b/web/src/shared/lib/api-error.ts index 218f38b1..2a75e719 100644 --- a/web/src/shared/lib/api-error.ts +++ b/web/src/shared/lib/api-error.ts @@ -30,6 +30,7 @@ function isAccountDisabledError(error: ApiError): boolean { const accountDisabledMessages = [ i18n.t('apiError.auth.accountDisabled'), i18n.getFixedT('en')('apiError.auth.accountDisabled'), + i18n.getFixedT('ru')('apiError.auth.accountDisabled'), i18n.getFixedT('zh')('apiError.auth.accountDisabled'), ] const normalizedServerMessage = (error.serverMessage ?? '').toLowerCase() @@ -43,6 +44,8 @@ function isAccountDisabledError(error: ApiError): boolean { || normalizedMessage.includes('disabled') || (error.serverMessage ?? '').includes('禁用') || error.message.includes('禁用') + || (error.serverMessage ?? '').toLowerCase().includes('отключ') + || error.message.toLowerCase().includes('отключ') } export function handleApiError(error: unknown): void { diff --git a/web/src/shared/lib/format-relative-time.ts b/web/src/shared/lib/format-relative-time.ts new file mode 100644 index 00000000..b0f02277 --- /dev/null +++ b/web/src/shared/lib/format-relative-time.ts @@ -0,0 +1,34 @@ +/** + * Formats a timestamp as a compact relative time string for notification UI. + * Mirrors the zh inline pattern; ru support added for the Russian locale. + */ +export function formatRelativeTime(dateStr: string, lang: string): string { + const diff = Date.now() - new Date(dateStr).getTime() + const minutes = Math.floor(diff / 60_000) + const hours = Math.floor(diff / 3_600_000) + const days = Math.floor(diff / 86_400_000) + const isChinese = lang.startsWith('zh') + const isRussian = lang.startsWith('ru') + + if (minutes < 1) { + if (isChinese) return '刚刚' + if (isRussian) return 'только что' + return 'just now' + } + if (minutes < 60) { + if (isChinese) return `${minutes}分钟` + if (isRussian) return `${minutes} мин` + return `${minutes}m` + } + if (hours < 24) { + if (isChinese) return `${hours}小时` + if (isRussian) return `${hours} ч` + return `${hours}h` + } + if (days < 30) { + if (isChinese) return `${days}天` + if (isRussian) return `${days} д` + return `${days}d` + } + return new Date(dateStr).toLocaleDateString(isRussian ? 'ru-RU' : undefined) +} diff --git a/web/src/shared/ui/select.test.ts b/web/src/shared/ui/select.test.ts index 0768fd3a..3f304500 100644 --- a/web/src/shared/ui/select.test.ts +++ b/web/src/shared/ui/select.test.ts @@ -40,6 +40,19 @@ describe('shared select contract', () => { expect(SELECT_ITEM_CLASS_NAME).toContain('rounded-md') }) + it('keeps long option lists inside the available viewport', () => { + expect(SELECT_CONTENT_CLASS_NAME).toContain( + 'max-h-[var(--radix-select-content-available-height)]' + ) + expect(SELECT_CONTENT_CLASS_NAME).toContain('overflow-y-auto') + expect(SELECT_CONTENT_CLASS_NAME).toContain('overflow-x-hidden') + }) + + it('does not move popper content with static translate utilities', () => { + expect(SELECT_CONTENT_CLASS_NAME).not.toContain('translate-y-1') + expect(SELECT_CONTENT_CLASS_NAME).not.toContain('translate-x-1') + }) + it('uses pointer cursors for expanded select interactions', () => { expect(SELECT_ITEM_CLASS_NAME).toContain('cursor-pointer') expect(SELECT_SCROLL_BUTTON_CLASS_NAME).toContain('cursor-pointer') diff --git a/web/src/shared/ui/select.tsx b/web/src/shared/ui/select.tsx index f45cc4f5..102e4279 100644 --- a/web/src/shared/ui/select.tsx +++ b/web/src/shared/ui/select.tsx @@ -12,7 +12,7 @@ export const SELECT_TRIGGER_CLASS_NAME = cn( ) export const SELECT_CONTENT_CLASS_NAME = cn( - 'z-50 overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md', + 'z-50 max-h-[var(--radix-select-content-available-height)] overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-md', // In-tree (no Portal): avoids React 19 removeChild races on route unmount. // No exit animations: delayed unmount still races commits when Content was portaled. 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', @@ -89,16 +89,15 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam const SelectContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, position = 'popper', ...props }, ref) => ( +>(({ className, children, position = 'popper', sideOffset = 4, ...props }, ref) => ( // No Portal: Content stays in the React tree with its trigger so route/Dialog // unmount cannot orphan a body/#skillhub-portals node (removeChild).