Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
XiaoSeS 2026-08-27 14:54:45 +08:00
commit 2bd1fa5244
88 changed files with 4609 additions and 145 deletions

View file

@ -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:

View file

@ -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
View 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).

View 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()

View file

@ -0,0 +1 @@
requests>=2.25

View 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,
)
)

View file

@ -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"

View file

@ -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"

View file

@ -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
);
}

View file

@ -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)

View file

@ -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(

View file

@ -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,

View file

@ -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) {

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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()))
);
}
}

View file

@ -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;
}

View file

@ -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));
}
}

View file

@ -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);

View file

@ -0,0 +1,2 @@
ALTER TABLE scan_task_outbox
ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb;

View file

@ -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

View 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=Имя пользователя: 364 символа, только буквы, цифры или подчёркивания
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=Ожидающие заявки на продвижение не поддерживают сортировку по времени ревью

View file

@ -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=服务器内部错误

View file

@ -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);
}
}

View file

@ -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",

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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");
}
}

View file

@ -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;
}
}

View file

@ -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,

View file

@ -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;

View file

@ -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())));
}
}

View file

@ -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;
}
}

View file

@ -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");

View file

@ -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);
}

View file

@ -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; }
}

View file

@ -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);
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.domain.security;
public enum ScanTaskOutboxStatus {
PENDING,
SENDING,
SENT,
FAILED
}

View file

@ -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;
}

View file

@ -12,6 +12,8 @@ public interface SecurityAuditRepository {
Optional<SecurityAudit> findByScanId(String scanId);
boolean existsByTaskIdAndScannedAtIsNotNull(String taskId);
boolean existsBySkillVersionId(Long skillVersionId);
/**

View file

@ -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);
}
}
}

View file

@ -0,0 +1,2 @@
/** Security scanning domain model and durable task dispatch ports. */
package com.iflytek.skillhub.domain.security;

View file

@ -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));
}
}

View file

@ -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());
}
}

View file

@ -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);
}

View file

@ -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()

View file

@ -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
View 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
View 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;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

View file

@ -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;

View file

@ -123,7 +123,6 @@ export function Layout() {
) : (
<Link
to="/login"
search={{ returnTo: '' }}
className="hover:opacity-80 transition-opacity"
>
{t('nav.login')}

View file

@ -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,

View file

@ -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')
})
})

View file

@ -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))
}

View file

@ -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.
*/

View 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'
)
})
})

View 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' })
}

View file

@ -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>
)
}

View file

@ -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')
})
})

View file

@ -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',

View file

@ -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', () => {

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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": "成功",

View 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)
})
})

View file

@ -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>

View file

@ -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">

View file

@ -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> = {

View file

@ -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>

View file

@ -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'))

View file

@ -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]

View file

@ -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 {

View 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)
}

View file

@ -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')

View file

@ -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}