mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
commit
2bd1fa5244
88 changed files with 4609 additions and 145 deletions
13
README.md
13
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:
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ Skill,欢迎分享给 SkillHub 社区,与大家一起丰富开放、实用
|
|||
|
||||
- 📖 **[用户指南](https://iflytek.github.io/skillhub/)** — 技能发布、搜索、CLI 使用等用户操作指南
|
||||
- 🛠️ **[开发者文档](https://zread.ai/iflytek/skillhub)** — 架构设计、API 参考、本地开发、部署运维等技术文档
|
||||
- 🐍 **[Python 示例](./examples/python)** — 使用 REST API 在 Python 中搜索、下载和发布技能
|
||||
|
||||
## 核心特性
|
||||
|
||||
|
|
|
|||
85
examples/python/README.md
Normal file
85
examples/python/README.md
Normal file
|
|
@ -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=<your-api-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).
|
||||
90
examples/python/example_usage.py
Normal file
90
examples/python/example_usage.py
Normal file
|
|
@ -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=<your-api-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 <zip_path> <namespace>")
|
||||
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()
|
||||
1
examples/python/requirements.txt
Normal file
1
examples/python/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
requests>=2.25
|
||||
244
examples/python/skillhub_client.py
Normal file
244
examples/python/skillhub_client.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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='<html>SkillHub</html>'
|
||||
;;
|
||||
*/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"
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> userNsRoles) {
|
||||
return listSkills(page, limit, sort, false, userId, userNsRoles);
|
||||
}
|
||||
|
||||
public ClawHubSkillListResponse listSkills(int page,
|
||||
int limit,
|
||||
String sort,
|
||||
boolean includeLabels,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
String sortBy = sort != null ? sort : "newest";
|
||||
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
|
||||
"",
|
||||
|
|
@ -206,8 +220,15 @@ public class ClawHubCompatAppService {
|
|||
userNsRoles
|
||||
);
|
||||
|
||||
Map<Long, List<SkillLabelDto>> labelsBySkillId = includeLabels
|
||||
? skillLabelProjectionService.labelsBySkillIds(
|
||||
response.items().stream().map(SkillSummaryResponse::id).toList())
|
||||
: Map.of();
|
||||
|
||||
List<ClawHubSkillListResponse.SkillListItem> 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<SkillLabelDto> 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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String> include,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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)
|
||||
|
|
|
|||
|
|
@ -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<Skill> 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<Long> namespaceIds = skills.stream()
|
||||
.map(Skill::getNamespaceId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, Namespace> namespacesById = namespaceIds.isEmpty()
|
||||
? Map.of()
|
||||
: namespaceRepository.findByIdIn(namespaceIds).stream()
|
||||
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
|
||||
Skill skill = skills.stream()
|
||||
.min(Comparator.<Skill>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<Long, Namespace> 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(
|
||||
|
|
|
|||
|
|
@ -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<SkillLabelDto> 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,
|
||||
|
|
|
|||
|
|
@ -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<String> labels,
|
||||
@Parameter(description = "Optional response expansions. Supported value: labels")
|
||||
@RequestParam(name = "include", required = false) List<String> 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<Long, NamespaceRole> 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<Long, List<SkillLabelDto>> labelsBySkillId = skillLabelProjectionService.labelsBySkillIds(
|
||||
response.items().stream().map(SkillSummaryResponse::id).toList());
|
||||
|
||||
List<SkillSummaryResponse> 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) {
|
||||
|
|
|
|||
|
|
@ -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<String> SUPPORTED = Set.of(LABELS);
|
||||
|
||||
private IncludeOptions() {
|
||||
}
|
||||
|
||||
public static boolean includesLabels(List<String> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SkillLabelDto> 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<SkillLabelDto> labels) {
|
||||
return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount,
|
||||
starCount, ratingAvg, ratingCount, namespace, updatedAt, canSubmitPromotion, headlineVersion,
|
||||
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
@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<Long, List<SkillLabelDto>> labelsBySkillIds(List<Long> skillIds) {
|
||||
if (skillIds == null || skillIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
List<Long> distinctSkillIds = skillIds.stream()
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (distinctSkillIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
List<SkillLabel> assignments = skillLabelService.listSkillLabelsBySkillIds(distinctSkillIds);
|
||||
if (assignments.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
List<Long> labelIds = assignments.stream()
|
||||
.map(SkillLabel::getLabelId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, LabelDefinition> definitionsById = labelDefinitionService.listByIds(labelIds).stream()
|
||||
.collect(Collectors.toMap(LabelDefinition::getId, Function.identity()));
|
||||
Map<Long, List<LabelTranslation>> 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<Long, List<LabelTranslation>> translationsByLabelId) {
|
||||
return new SkillLabelDto(
|
||||
definition.getSlug(),
|
||||
definition.getType().name(),
|
||||
labelLocalizationService.resolveDisplayName(
|
||||
definition.getSlug(),
|
||||
translationsByLabelId.getOrDefault(definition.getId(), List.of()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ScanTaskConsumer.ScanTaskPayload> {
|
||||
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<ScanTaskConsumer.Sc
|
|||
ObjectStorageService objectStorageService,
|
||||
MessageObservationSupport messageObservationSupport) {
|
||||
super(redissonClient, streamKey, groupName, messageObservationSupport);
|
||||
this.redissonClient = redissonClient;
|
||||
this.securityScanner = securityScanner;
|
||||
this.securityScanService = securityScanService;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
|
|
@ -72,6 +75,7 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
reclaimInterval,
|
||||
messageObservationSupport
|
||||
);
|
||||
this.redissonClient = redissonClient;
|
||||
this.securityScanner = securityScanner;
|
||||
this.securityScanService = securityScanService;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
|
|
@ -128,17 +132,49 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
|
||||
@Override
|
||||
protected void processBusiness(ScanTaskPayload payload) {
|
||||
if (securityScanService.isTaskAlreadyProcessed(payload.taskId())) {
|
||||
log.info("Skipping already processed security scan task: taskId={}, versionId={}", payload.taskId(), payload.versionId());
|
||||
return;
|
||||
}
|
||||
RLock processingLock = redissonClient.getLock("skillhub:scan:processing:" + payload.taskId());
|
||||
boolean acquired = false;
|
||||
try {
|
||||
acquired = processingLock.tryLock();
|
||||
if (!acquired) {
|
||||
log.info("Skipping concurrently processed security scan task: taskId={}, versionId={}",
|
||||
payload.taskId(), payload.versionId());
|
||||
payload.skipCleanup();
|
||||
// A normal return is treated as success by AbstractStreamConsumer and ACKs
|
||||
// the Redis entry. Requeue through the common failure path instead, so a
|
||||
// reclaimed duplicate cannot erase the only durable delivery while the active
|
||||
// scanner still owns the task lock.
|
||||
throw new ConcurrentScanInProgressException(payload.taskId());
|
||||
}
|
||||
if (securityScanService.isTaskAlreadyProcessed(payload.taskId())) {
|
||||
return;
|
||||
}
|
||||
executeScan(payload);
|
||||
} finally {
|
||||
if (acquired && processingLock.isHeldByCurrentThread()) {
|
||||
processingLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void executeScan(ScanTaskPayload payload) {
|
||||
String skillPath = resolveWorkingSkillPath(payload);
|
||||
SecurityScanRequest request = new SecurityScanRequest(
|
||||
payload.taskId(),
|
||||
payload.versionId(),
|
||||
skillPath,
|
||||
Map.of()
|
||||
);
|
||||
payload.taskId(), payload.versionId(), skillPath, Map.of());
|
||||
SecurityScanResponse response = securityScanner.scan(request);
|
||||
securityScanService.processScanResult(payload.versionId(), payload.scannerType(), response);
|
||||
}
|
||||
|
||||
private static final class ConcurrentScanInProgressException extends RuntimeException {
|
||||
private ConcurrentScanInProgressException(String taskId) {
|
||||
super("Security scan is already in progress: taskId=" + taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void markCompleted(ScanTaskPayload payload) {
|
||||
cleanupTempPath(payload.cleanupPath());
|
||||
|
|
@ -259,6 +295,7 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
private final ScannerType scannerType;
|
||||
private final int retryCount;
|
||||
private String workingSkillPath;
|
||||
private boolean cleanupEnabled = true;
|
||||
|
||||
protected ScanTaskPayload(String taskId, Long versionId, String skillPath, String bundleKey, ScannerType scannerType) {
|
||||
this(taskId, versionId, skillPath, bundleKey, scannerType, 0);
|
||||
|
|
@ -307,9 +344,16 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
}
|
||||
|
||||
protected String cleanupPath() {
|
||||
if (!cleanupEnabled) {
|
||||
return null;
|
||||
}
|
||||
return workingSkillPath != null ? workingSkillPath : skillPath;
|
||||
}
|
||||
|
||||
protected void skipCleanup() {
|
||||
cleanupEnabled = false;
|
||||
}
|
||||
|
||||
protected String workingSkillPath() {
|
||||
return workingSkillPath;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
package com.iflytek.skillhub.task;
|
||||
|
||||
import com.iflytek.skillhub.domain.security.ScanTaskOutbox;
|
||||
import com.iflytek.skillhub.domain.security.ScanTaskOutboxRepository;
|
||||
import com.iflytek.skillhub.domain.security.ScanTaskProducer;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "skillhub.security.scanner", name = "enabled", havingValue = "true")
|
||||
public class ScanTaskOutboxDispatcher {
|
||||
private static final Logger log = LoggerFactory.getLogger(ScanTaskOutboxDispatcher.class);
|
||||
|
||||
private final ScanTaskOutboxRepository repository;
|
||||
private final ScanTaskProducer producer;
|
||||
private final SkillVersionRepository versionRepository;
|
||||
private final Clock clock;
|
||||
private final int batchSize;
|
||||
private final int maxAttempts;
|
||||
private final Duration lease;
|
||||
private final Duration maxBackoff;
|
||||
|
||||
public ScanTaskOutboxDispatcher(ScanTaskOutboxRepository repository,
|
||||
ScanTaskProducer producer,
|
||||
SkillVersionRepository versionRepository,
|
||||
Clock clock,
|
||||
@Value("${skillhub.security.outbox.batch-size:50}") int batchSize,
|
||||
@Value("${skillhub.security.outbox.max-attempts:10}") int maxAttempts,
|
||||
@Value("${skillhub.security.outbox.lease:PT2M}") Duration lease,
|
||||
@Value("${skillhub.security.outbox.max-backoff:PT5M}") Duration maxBackoff) {
|
||||
this.repository = repository;
|
||||
this.producer = producer;
|
||||
this.versionRepository = versionRepository;
|
||||
this.clock = clock;
|
||||
this.batchSize = batchSize;
|
||||
if (maxAttempts < 1) {
|
||||
throw new IllegalArgumentException("maxAttempts must be at least 1");
|
||||
}
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.lease = lease;
|
||||
this.maxBackoff = maxBackoff;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${skillhub.security.outbox.dispatch-interval-ms:5000}")
|
||||
@Transactional
|
||||
public void dispatch() {
|
||||
Instant now = Instant.now(clock);
|
||||
for (ScanTaskOutbox outbox : repository.findDispatchable(now, batchSize)) {
|
||||
if (!outbox.claim(now, lease)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
producer.publishScanTask(outbox.toScanTask());
|
||||
outbox.markSent(Instant.now(clock));
|
||||
repository.save(outbox);
|
||||
} catch (Exception e) {
|
||||
handlePublishFailure(outbox, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handlePublishFailure(ScanTaskOutbox outbox, Exception error) {
|
||||
Instant now = Instant.now(clock);
|
||||
int nextAttempt = outbox.getRetryCount() + 1;
|
||||
if (nextAttempt >= 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE scan_task_outbox
|
||||
ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
179
server/skillhub-app/src/main/resources/messages_ru.properties
Normal file
179
server/skillhub-app/src/main/resources/messages_ru.properties
Normal file
|
|
@ -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=Ожидающие заявки на продвижение не поддерживают сортировку по времени ревью
|
||||
|
|
@ -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=服务器内部错误
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Long, List<SkillLabelDto>> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> 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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -38,6 +38,13 @@ public class SkillLabelService {
|
|||
return skillLabelRepository.findBySkillId(skillId);
|
||||
}
|
||||
|
||||
public List<SkillLabel> listSkillLabelsBySkillIds(List<Long> skillIds) {
|
||||
if (skillIds == null || skillIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return skillLabelRepository.findBySkillIdIn(skillIds);
|
||||
}
|
||||
|
||||
public List<SkillLabel> listByLabelId(Long labelId) {
|
||||
return skillLabelRepository.findByLabelId(labelId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, String> 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; }
|
||||
}
|
||||
|
|
@ -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<ScanTaskOutbox> findDispatchable(Instant now, int limit);
|
||||
int deleteSentBefore(Instant cutoff);
|
||||
int deleteByVersionId(Long versionId);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.domain.security;
|
||||
|
||||
public enum ScanTaskOutboxStatus {
|
||||
PENDING,
|
||||
SENDING,
|
||||
SENT,
|
||||
FAILED
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ public interface SecurityAuditRepository {
|
|||
|
||||
Optional<SecurityAudit> findByScanId(String scanId);
|
||||
|
||||
boolean existsByTaskIdAndScannedAtIsNotNull(String taskId);
|
||||
|
||||
boolean existsBySkillVersionId(Long skillVersionId);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<SecurityAudit> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
/** Security scanning domain model and durable task dispatch ports. */
|
||||
package com.iflytek.skillhub.domain.security;
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ScanTaskOutbox> 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ScanTaskOutbox, Long>, 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<ScanTaskOutbox> 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);
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@
|
|||
<meta name="google" content="notranslate" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' ws: wss: http://localhost:* https://localhost:*; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' ws: wss: http://localhost:* https://localhost:*; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
|
||||
/>
|
||||
<title>SkillHub</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<!-- Self-hosted fonts (see /fonts/fonts.css); no external CDN so first paint
|
||||
isn't blocked on fonts.googleapis.com, which is slow/unreachable on some
|
||||
networks (#716). -->
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- translate=no: Chrome auto-translate mutates #root and races React 19 insertBefore. -->
|
||||
|
|
|
|||
101
web/public/fonts/LICENSE.md
Normal file
101
web/public/fonts/LICENSE.md
Normal file
|
|
@ -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.
|
||||
```
|
||||
109
web/public/fonts/fonts.css
Normal file
109
web/public/fonts/fonts.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
BIN
web/public/fonts/inter-latin-400-normal.woff2
Normal file
BIN
web/public/fonts/inter-latin-400-normal.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/inter-latin-500-normal.woff2
Normal file
BIN
web/public/fonts/inter-latin-500-normal.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/inter-latin-600-normal.woff2
Normal file
BIN
web/public/fonts/inter-latin-600-normal.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/inter-latin-700-normal.woff2
Normal file
BIN
web/public/fonts/inter-latin-700-normal.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/inter-latin-ext-400-normal.woff2
Normal file
BIN
web/public/fonts/inter-latin-ext-400-normal.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/inter-latin-ext-500-normal.woff2
Normal file
BIN
web/public/fonts/inter-latin-ext-500-normal.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/inter-latin-ext-600-normal.woff2
Normal file
BIN
web/public/fonts/inter-latin-ext-600-normal.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/inter-latin-ext-700-normal.woff2
Normal file
BIN
web/public/fonts/inter-latin-ext-700-normal.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/jetbrains-mono-latin-400-normal.woff2
Normal file
BIN
web/public/fonts/jetbrains-mono-latin-400-normal.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/jetbrains-mono-latin-500-normal.woff2
Normal file
BIN
web/public/fonts/jetbrains-mono-latin-500-normal.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2
Normal file
BIN
web/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2
Normal file
BIN
web/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2
Normal file
Binary file not shown.
6
web/public/oidc-logo.svg
Normal file
6
web/public/oidc-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
6
web/src/api/generated/schema.d.ts
vendored
6
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -123,7 +123,6 @@ export function Layout() {
|
|||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
search={{ returnTo: '' }}
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{t('nav.login')}
|
||||
|
|
|
|||
|
|
@ -183,8 +183,8 @@ const skillsRoute = createRoute({
|
|||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'login',
|
||||
validateSearch: (search: Record<string, unknown>): { returnTo: string; reason?: string } => ({
|
||||
returnTo: typeof search.returnTo === 'string' ? search.returnTo : '',
|
||||
validateSearch: (search: Record<string, unknown>): { returnTo?: string; reason?: string } => ({
|
||||
returnTo: typeof search.returnTo === 'string' && search.returnTo ? search.returnTo : undefined,
|
||||
reason: typeof search.reason === 'string' ? search.reason : undefined,
|
||||
}),
|
||||
component: LoginPage,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<AuthMethod[]>({
|
||||
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<AuthMethod[]>(getAuthMethodsQueryOptions(returnTo))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
89
web/src/features/publish/folder-zip.test.ts
Normal file
89
web/src/features/publish/folder-zip.test.ts
Normal file
|
|
@ -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<Uint8Array> {
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
189
web/src/features/publish/folder-zip.ts
Normal file
189
web/src/features/publish/folder-zip.ts
Normal file
|
|
@ -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<ArrayBuffer>.
|
||||
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<ZipEntry[]> {
|
||||
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 `<folder>.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<File> {
|
||||
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' })
|
||||
}
|
||||
|
|
@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'upload-zone rounded-xl p-10 text-center cursor-pointer transition-all duration-300',
|
||||
isDragActive && 'border-primary bg-primary/5 scale-[1.01]',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-2xl bg-secondary/60 flex items-center justify-center">
|
||||
<svg
|
||||
className={cn(
|
||||
'w-7 h-7 upload-zone-icon transition-colors',
|
||||
isDragActive && 'text-primary'
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{isDragActive ? (
|
||||
<p className="text-sm text-primary font-medium">{t('upload.dropHint')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-foreground">{t('upload.dragHint')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('upload.formatHint')}</p>
|
||||
</>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'upload-zone rounded-xl p-10 text-center cursor-pointer transition-all duration-300',
|
||||
isDragActive && 'border-primary bg-primary/5 scale-[1.01]',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-2xl bg-secondary/60 flex items-center justify-center">
|
||||
<svg
|
||||
className={cn(
|
||||
'w-7 h-7 upload-zone-icon transition-colors',
|
||||
isDragActive && 'text-primary'
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{isDragActive ? (
|
||||
<p className="text-sm text-primary font-medium">{t('upload.dropHint')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-foreground">{t('upload.dragHint')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('upload.formatHint')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onFolderSelect && (
|
||||
<div className="text-center">
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={handleFolderChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('upload.folderHint')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1650
web/src/i18n/locales/ru.json
Normal file
1650
web/src/i18n/locales/ru.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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": "成功",
|
||||
|
|
|
|||
51
web/src/i18n/ru-locale.test.ts
Normal file
51
web/src/i18n/ru-locale.test.ts
Normal file
|
|
@ -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<string, unknown>).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<string, unknown>)[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<string, unknown>)[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)
|
||||
})
|
||||
})
|
||||
|
|
@ -144,7 +144,7 @@ export function DashboardPage() {
|
|||
<Link
|
||||
key={skill.id}
|
||||
to="/space/$namespace/$slug"
|
||||
params={{ namespace: skill.namespace, slug: encodeURIComponent(skill.slug) }}
|
||||
params={{ namespace: skill.namespace, slug: skill.slug }}
|
||||
className="rounded-lg border border-border/60 px-3 py-3 transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<div className="truncate text-sm font-medium">{skill.displayName}</div>
|
||||
|
|
|
|||
|
|
@ -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<string>(prefill.visibility)
|
||||
const [warningDialogOpen, setWarningDialogOpen] = useState(false)
|
||||
const [precheckWarnings, setPrecheckWarnings] = useState<string[]>([])
|
||||
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() {
|
|||
<Label className="text-sm font-semibold font-heading">{t('publish.file')}</Label>
|
||||
<UploadZone
|
||||
onFileSelect={handleFileSelect}
|
||||
disabled={publishMutation.isPending}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
disabled={publishMutation.isPending || isPackaging}
|
||||
/>
|
||||
{selectedFile && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/30 px-4 py-3">
|
||||
|
|
|
|||
|
|
@ -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<NotificationItem['category'], string> = {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export function ResetPasswordPage() {
|
|||
<p className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||
{t('resetPassword.successMessage')}
|
||||
</p>
|
||||
<Link to="/login" search={{ returnTo: '' }} className="block text-center font-medium text-primary hover:underline">
|
||||
<Link to="/login" className="block text-center font-medium text-primary hover:underline">
|
||||
{t('resetPassword.backToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
34
web/src/shared/lib/format-relative-time.ts
Normal file
34
web/src/shared/lib/format-relative-time.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ 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).
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
translate="no"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
SELECT_CONTENT_CLASS_NAME,
|
||||
position === 'popper'
|
||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue