mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
Compare commits
66 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11a51c75f | ||
|
|
56ed2dcadd | ||
|
|
f993ad6533 | ||
|
|
37e3c63236 | ||
|
|
eeb63613f2 | ||
|
|
ee0f0763db | ||
|
|
7beb1be356 | ||
|
|
04bb414b37 | ||
|
|
dc31bb97f4 | ||
|
|
fbf6887e9d | ||
|
|
e80fb986f7 | ||
|
|
eba2762b5b | ||
|
|
0221c17113 | ||
|
|
c91c2ca408 | ||
|
|
71fbc8357a | ||
|
|
c32bced109 | ||
|
|
c825d896a4 | ||
|
|
2e78f79e83 | ||
|
|
7476c9e0d2 | ||
|
|
1544ae4775 | ||
|
|
ec9689dbc8 | ||
|
|
41a389432d | ||
|
|
b0c4a154fd | ||
|
|
f43c047a6b | ||
|
|
a3d1b4c9c5 | ||
|
|
126f01d75e | ||
|
|
1331667496 | ||
|
|
bacfd58aa0 | ||
|
|
36967794d1 | ||
|
|
26f49e6819 | ||
|
|
7fc1df5043 | ||
|
|
7e37935da8 | ||
|
|
412514b299 | ||
|
|
0587c55f8b | ||
|
|
16306dd4f4 | ||
|
|
95e630c096 | ||
|
|
3b5d4381a9 | ||
|
|
4344ec6b22 | ||
|
|
243e9b68f4 | ||
|
|
3522bad295 | ||
|
|
d7e8c51775 | ||
|
|
470e79d6d2 | ||
|
|
7599dd0ca9 | ||
|
|
5a95278528 | ||
|
|
907d8eff90 | ||
|
|
91d0ae1504 | ||
|
|
1c3e9be9e9 | ||
|
|
954dfce7a4 | ||
|
|
d5c6411ce6 | ||
|
|
b807fb3ee1 | ||
|
|
f846da230c | ||
|
|
1b7a6d5544 | ||
|
|
9fa6c52a4d | ||
|
|
183729613c | ||
|
|
e8cab7389f | ||
|
|
67d39f04f6 | ||
|
|
84feb38931 | ||
|
|
7247defd5d | ||
|
|
a9e7f43e5a | ||
|
|
4fe6948f87 | ||
|
|
639e081ca7 | ||
|
|
2d50437e4f | ||
|
|
ae23d1a051 | ||
|
|
68f120c5e1 | ||
|
|
833270bb31 | ||
|
|
35c080b65c |
198 changed files with 12283 additions and 383 deletions
8
.github/workflows/publish-images.yml
vendored
8
.github/workflows/publish-images.yml
vendored
|
|
@ -13,9 +13,6 @@ permissions:
|
|||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
DOCKER_PLATFORMS: linux/amd64,linux/arm64
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -31,16 +28,19 @@ jobs:
|
|||
- name: server
|
||||
context: ./server
|
||||
dockerfile: ./server/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
image: ghcr.io/${{ github.repository_owner }}/skillhub-server
|
||||
mirror_image: skillhub-server
|
||||
- name: web
|
||||
context: ./web
|
||||
dockerfile: ./web/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
image: ghcr.io/${{ github.repository_owner }}/skillhub-web
|
||||
mirror_image: skillhub-web
|
||||
- name: scanner
|
||||
context: ./scanner
|
||||
dockerfile: ./scanner/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
image: ghcr.io/${{ github.repository_owner }}/skillhub-scanner
|
||||
mirror_image: skillhub-scanner
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ jobs:
|
|||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ env.DOCKER_PLATFORMS }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
|
|
|||
65
.github/workflows/riscv64-images.yml
vendored
Normal file
65
.github/workflows/riscv64-images.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
name: RISC-V Images
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/riscv64-images.yml'
|
||||
- '.github/workflows/publish-images.yml'
|
||||
- 'server/**'
|
||||
- 'web/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.name }} (linux/riscv64)
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: server
|
||||
context: ./server
|
||||
dockerfile: ./server/Dockerfile
|
||||
- name: web
|
||||
context: ./web
|
||||
dockerfile: ./web/Dockerfile
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: riscv64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build RISC-V image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/riscv64
|
||||
load: true
|
||||
tags: skillhub-${{ matrix.name }}:riscv64-ci
|
||||
cache-from: type=gha,scope=riscv64-${{ matrix.name }}
|
||||
cache-to: type=gha,mode=max,scope=riscv64-${{ matrix.name }}
|
||||
|
||||
- name: Verify image architecture and runtime
|
||||
shell: bash
|
||||
run: |
|
||||
image="skillhub-${{ matrix.name }}:riscv64-ci"
|
||||
test "$(docker image inspect "$image" --format '{{.Architecture}}')" = riscv64
|
||||
case "${{ matrix.name }}" in
|
||||
server)
|
||||
docker run --rm --platform linux/riscv64 --entrypoint java "$image" -version
|
||||
;;
|
||||
web)
|
||||
docker run --rm --platform linux/riscv64 --entrypoint nginx "$image" -v
|
||||
;;
|
||||
esac
|
||||
|
|
@ -29,5 +29,16 @@ project spaces.
|
|||
|
||||
## Reporting
|
||||
|
||||
Report conduct issues privately to the maintainers through a private maintainer
|
||||
channel. Do not use public issues for personal or sensitive reports.
|
||||
Report conduct issues privately to
|
||||
[ifly_opensource@iflytek.com](mailto:ifly_opensource@iflytek.com) with the subject
|
||||
`SkillHub Code of Conduct report`. Do not use public issues for personal, sensitive,
|
||||
or confidential reports.
|
||||
|
||||
Reports are handled under the iFLYTEK community
|
||||
[incident resolution procedures](https://github.com/iflytek/community/blob/master/code-of-conduct/coc-incident-resolution-procedures.md).
|
||||
Information is shared only with people who need it to review the report, protect
|
||||
participants, or comply with law. Retaliation for a good-faith report is prohibited.
|
||||
|
||||
People materially affected by a conduct decision may request an impartial review
|
||||
through the appeal process in the
|
||||
[Content Safety Policy](docs/CONTENT_SAFETY.md#appeals).
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -64,6 +64,19 @@ 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
|
||||
|
||||
## Governance and Safety
|
||||
|
||||
- **[Privacy and Data Governance](docs/PRIVACY_AND_DATA_GOVERNANCE.md)** —
|
||||
Data categories, operator responsibilities, retention, portability, and incident
|
||||
handling for public and self-hosted instances
|
||||
- **[Content Safety](docs/CONTENT_SAFETY.md)** — Package safety expectations,
|
||||
review and reporting controls, appeals, and child-safety responsibilities
|
||||
- **[Code of Conduct](CODE_OF_CONDUCT.md)** — Community standards and the private
|
||||
reporting channel
|
||||
- **[Security Policy](https://github.com/iflytek/.github/blob/main/SECURITY.md)** —
|
||||
Private vulnerability reporting and coordinated disclosure
|
||||
|
||||
## Highlights
|
||||
|
||||
|
|
@ -236,7 +249,9 @@ frontend schema, and fails if the checked-in SDK is stale.
|
|||
Published runtime images are built by GitHub Actions and pushed to GHCR.
|
||||
This is the supported path for anyone who wants a ready-to-use local
|
||||
environment without building the backend or frontend on their machine.
|
||||
Published images target both `linux/amd64` and `linux/arm64`.
|
||||
Published server and web images target `linux/amd64`, `linux/arm64`, and
|
||||
`linux/riscv64`; the scanner image currently targets `linux/amd64` and
|
||||
`linux/arm64`.
|
||||
|
||||
**Quick deployment with curl:**
|
||||
|
||||
|
|
@ -408,6 +423,18 @@ Run it against a local backend:
|
|||
./scripts/smoke-test.sh http://localhost:8080
|
||||
```
|
||||
|
||||
Local Compose and staging runs can keep using one backend URL. For an ingress
|
||||
deployment where the public URL exposes application APIs but keeps Actuator on
|
||||
the backend service, set a separate Actuator target:
|
||||
|
||||
```bash
|
||||
ACTUATOR_BASE_URL=http://skillhub-server:8080 \
|
||||
./scripts/smoke-test.sh https://skillhub.example.com
|
||||
```
|
||||
|
||||
The health check requires an Actuator JSON response, so an HTML SPA fallback is
|
||||
reported as a routing or target error instead of a successful health response.
|
||||
|
||||
Admin label-management smoke checks run only when current admin credentials are
|
||||
supplied explicitly:
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ Skill,欢迎分享给 SkillHub 社区,与大家一起丰富开放、实用
|
|||
|
||||
- 📖 **[用户指南](https://iflytek.github.io/skillhub/)** — 技能发布、搜索、CLI 使用等用户操作指南
|
||||
- 🛠️ **[开发者文档](https://zread.ai/iflytek/skillhub)** — 架构设计、API 参考、本地开发、部署运维等技术文档
|
||||
- 🐍 **[Python 示例](./examples/python)** — 使用 REST API 在 Python 中搜索、下载和发布技能
|
||||
|
||||
## 核心特性
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ skillhub list
|
|||
|
||||
# Publish skill
|
||||
skillhub publish ./my-skill --namespace myspace
|
||||
|
||||
# Synchronize a team workspace
|
||||
skillhub sync pull --namespace myspace
|
||||
```
|
||||
|
||||
## 🌐 Registry Configuration
|
||||
|
|
@ -229,11 +232,45 @@ For a custom path or an unsupported Agent directory, use `--dir` to specify the
|
|||
"namespace": "global",
|
||||
"slug": "pdf-parser",
|
||||
"version": "1.0.0",
|
||||
"fingerprint": "sha256:...",
|
||||
"source": "skillhub",
|
||||
"agent": "codex",
|
||||
"installedAt": "2026-04-28T06:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 Namespace Workspaces
|
||||
|
||||
Use namespace synchronization when an Agent workspace should maintain all installable skills from one team space.
|
||||
|
||||
```bash
|
||||
# Pull new and updated skills into ./.agents/skills
|
||||
skillhub sync pull --namespace team-a
|
||||
|
||||
# Use an explicit workspace directory
|
||||
skillhub sync pull --namespace team-a --dir ./.claude/skills
|
||||
|
||||
# Check without downloading
|
||||
skillhub sync pull --namespace team-a --check
|
||||
|
||||
# Show local edits and remote updates
|
||||
skillhub sync status --namespace team-a --json
|
||||
skillhub sync diff --namespace team-a
|
||||
|
||||
# Remove only unchanged SkillHub-managed skills that no longer exist remotely
|
||||
skillhub sync pull --namespace team-a --prune
|
||||
|
||||
# Validate and upload every local skill for review
|
||||
skillhub sync push --all --namespace team-a --dry-run
|
||||
skillhub sync push --all --namespace team-a --submit-review
|
||||
```
|
||||
|
||||
The default workspace is `<cwd>/.agents/skills`. Pull never overwrites local changes unless `--force` is supplied. Remote removals are reported as `orphaned` and are retained unless `--prune` is supplied. Both destructive cases still require explicit flags.
|
||||
|
||||
Workspace push is non-overwriting: an existing namespace/slug/version is reported as a conflict, including versions that are still uploaded or pending review. Other skills in the same `--all` run continue processing.
|
||||
|
||||
Namespace sync writes `.skillhub/namespace-sync.json` in the workspace and per-skill `.skillhub/metadata.json` files. These files contain the registry coordinate, published version, aggregate fingerprint, and file hashes used by `status` and `diff`.
|
||||
|
||||
## 📋 Local Management
|
||||
|
||||
### List Installed Skills
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@astron-team/skillhub",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"description": "Manage and install skills for AI coding agents",
|
||||
"keywords": [
|
||||
"skillhub",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,30 @@ export interface PublishResponse {
|
|||
slug: string
|
||||
version: string
|
||||
visibility: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface NamespaceSyncItem {
|
||||
namespace: string
|
||||
slug: string
|
||||
version: string
|
||||
versionId: number
|
||||
fingerprint: string
|
||||
updatedAt: string
|
||||
visibility: string
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
export interface NamespaceSyncResponse {
|
||||
items: NamespaceSyncItem[]
|
||||
nextCursor?: string | null
|
||||
}
|
||||
|
||||
export interface SubmitReviewResponse {
|
||||
skillId: number
|
||||
versionId: number
|
||||
action: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface DryRunResponse {
|
||||
|
|
@ -80,6 +104,12 @@ export class SkillHubClient {
|
|||
return this.getJson(`/skills/${namespace}/${slug}/resolve${params}`)
|
||||
}
|
||||
|
||||
async listNamespaceSkills(namespace: string, cursor?: string, limit = 100): Promise<NamespaceSyncResponse> {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (cursor) params.set('cursor', cursor)
|
||||
return this.getJson(`/namespaces/${encodeURIComponent(namespace)}/skills?${params}`)
|
||||
}
|
||||
|
||||
async downloadUrl(namespace: string, slug: string, version?: string): Promise<string> {
|
||||
if (version) {
|
||||
return `${this.registry}/api/cli/v1/skills/${namespace}/${slug}/versions/${version}/download`
|
||||
|
|
@ -105,10 +135,17 @@ export class SkillHubClient {
|
|||
return this.deleteJson(`/skills/${namespace}/${slug}`)
|
||||
}
|
||||
|
||||
async publish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<PublishResponse> {
|
||||
async publish(
|
||||
namespace: string,
|
||||
file: Blob,
|
||||
visibility: string,
|
||||
fileName = 'skill.zip',
|
||||
rejectExistingVersion = false
|
||||
): Promise<PublishResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, fileName)
|
||||
formData.append('visibility', visibility)
|
||||
if (rejectExistingVersion) formData.append('rejectExistingVersion', 'true')
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish`, {
|
||||
|
|
@ -122,10 +159,17 @@ export class SkillHubClient {
|
|||
return this.handleJsonResponse<PublishResponse>(response)
|
||||
}
|
||||
|
||||
async validatePublish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<DryRunResponse> {
|
||||
async validatePublish(
|
||||
namespace: string,
|
||||
file: Blob,
|
||||
visibility: string,
|
||||
fileName = 'skill.zip',
|
||||
rejectExistingVersion = false
|
||||
): Promise<DryRunResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, fileName)
|
||||
formData.append('visibility', visibility)
|
||||
if (rejectExistingVersion) formData.append('rejectExistingVersion', 'true')
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish/validate`, {
|
||||
|
|
@ -139,6 +183,28 @@ export class SkillHubClient {
|
|||
return this.handleJsonResponse<DryRunResponse>(response)
|
||||
}
|
||||
|
||||
async submitReview(
|
||||
namespace: string,
|
||||
slug: string,
|
||||
version: string,
|
||||
targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY'
|
||||
): Promise<SubmitReviewResponse> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(
|
||||
`${this.registry}/api/v1/skills/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/submit-review`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...this.headers(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version, targetVisibility })
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
|
||||
}
|
||||
return this.handleJsonResponse<SubmitReviewResponse>(response)
|
||||
}
|
||||
|
||||
private async getJson<T>(path: string): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,15 @@ export const commands = {
|
|||
'skillhub install pdf-parser --scope project --agent codex'
|
||||
]
|
||||
},
|
||||
sync: {
|
||||
summary: 'Synchronize and maintain namespace workspaces',
|
||||
usage: 'skillhub sync <pull|status|diff|push> [options]',
|
||||
examples: [
|
||||
'skillhub sync pull --namespace team-a',
|
||||
'skillhub sync status --namespace team-a --json',
|
||||
'skillhub sync push --all --namespace team-a --submit-review'
|
||||
]
|
||||
},
|
||||
list: {
|
||||
summary: 'List local installs',
|
||||
usage: 'skillhub list [--agent <profile>] [--dir <path>] [--registry <url>] [--json]',
|
||||
|
|
|
|||
179
cli/src/commands/sync.ts
Normal file
179
cli/src/commands/sync.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { join, resolve } from 'node:path'
|
||||
import { ConfigStore } from '../stores/config-store'
|
||||
import { CredentialsStore } from '../stores/credentials-store'
|
||||
import { SkillHubClient } from '../clients/skillhub-client'
|
||||
import { resolveRegistry, resolveToken } from '../services/registry-service'
|
||||
import {
|
||||
discoverSkillDirectories,
|
||||
inspectNamespaceWorkspace,
|
||||
pullNamespace,
|
||||
pushSkills,
|
||||
type PullResult,
|
||||
type PushResultItem,
|
||||
type SyncStatusEntry
|
||||
} from '../services/sync-service'
|
||||
import { CliError } from '../shared/errors'
|
||||
import { EXIT } from '../shared/constants'
|
||||
|
||||
export interface SyncCommonOptions {
|
||||
namespace?: string
|
||||
dir?: string
|
||||
registry?: string
|
||||
token?: string
|
||||
json?: boolean
|
||||
}
|
||||
|
||||
export interface SyncPullOptions extends SyncCommonOptions {
|
||||
check?: boolean
|
||||
prune?: boolean
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export interface SyncPushOptions extends SyncCommonOptions {
|
||||
all?: boolean
|
||||
visibility?: string
|
||||
dryRun?: boolean
|
||||
submitReview?: boolean
|
||||
}
|
||||
|
||||
export async function syncPullCommand(options: SyncPullOptions): Promise<string> {
|
||||
const context = await resolveSyncContext(options)
|
||||
const result = await pullNamespace({
|
||||
...context,
|
||||
check: Boolean(options.check),
|
||||
prune: Boolean(options.prune),
|
||||
force: Boolean(options.force)
|
||||
})
|
||||
const output = renderPullResult(result, Boolean(options.json), Boolean(options.check))
|
||||
if (result.failures.length > 0) {
|
||||
process.stdout.write(`${output}\n`)
|
||||
throw new CliError('namespace sync completed with failures', EXIT.generic, {
|
||||
namespace: context.namespace,
|
||||
failures: result.failures
|
||||
})
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export async function syncStatusCommand(options: SyncCommonOptions): Promise<string> {
|
||||
const context = await resolveSyncContext(options)
|
||||
const result = await inspectNamespaceWorkspace(context)
|
||||
return renderStatusEntries(context.namespace, context.rootDir, result.entries, Boolean(options.json))
|
||||
}
|
||||
|
||||
export async function syncDiffCommand(options: SyncCommonOptions): Promise<string> {
|
||||
const context = await resolveSyncContext(options)
|
||||
const result = await inspectNamespaceWorkspace(context)
|
||||
const changed = result.entries.filter(entry => entry.status !== 'up-to-date')
|
||||
if (options.json) {
|
||||
return JSON.stringify({ ok: true, namespace: context.namespace, rootDir: context.rootDir, items: changed })
|
||||
}
|
||||
if (changed.length === 0) return `No differences for namespace ${context.namespace}.`
|
||||
return changed.flatMap(entry => {
|
||||
const lines = [`${entry.status.padEnd(16)} ${entry.slug}`]
|
||||
for (const path of entry.changedFiles) lines.push(` ${path}`)
|
||||
if (entry.reason) lines.push(` ${entry.reason}`)
|
||||
return lines
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
export async function syncPushCommand(path: string | undefined, options: SyncPushOptions): Promise<string> {
|
||||
const context = await resolveSyncContext(options)
|
||||
if (path && options.all) {
|
||||
throw new CliError('path cannot be combined with --all', EXIT.usage)
|
||||
}
|
||||
if (!path && !options.all) {
|
||||
throw new CliError('provide a skill path or pass --all', EXIT.usage)
|
||||
}
|
||||
|
||||
const visibility = normalizeVisibility(options.visibility ?? 'namespace-only')
|
||||
if (options.submitReview && visibility === 'PRIVATE') {
|
||||
throw new CliError('--submit-review requires public or namespace-only visibility', EXIT.usage)
|
||||
}
|
||||
const paths = options.all
|
||||
? await discoverSkillDirectories(context.rootDir)
|
||||
: [resolve(path!)]
|
||||
if (paths.length === 0) {
|
||||
throw new CliError(`no skill directories found in ${context.rootDir}`, EXIT.filesystem, { path: context.rootDir })
|
||||
}
|
||||
|
||||
const results = await pushSkills({
|
||||
client: context.client,
|
||||
namespace: context.namespace,
|
||||
paths,
|
||||
visibility,
|
||||
dryRun: Boolean(options.dryRun),
|
||||
submitReview: Boolean(options.submitReview)
|
||||
})
|
||||
const output = renderPushResults(context.namespace, results, Boolean(options.json), Boolean(options.dryRun))
|
||||
if (results.some(item => item.action === 'failed')) {
|
||||
process.stdout.write(`${output}\n`)
|
||||
throw new CliError('one or more skills failed to push', EXIT.validation, {
|
||||
namespace: context.namespace,
|
||||
failed: results.filter(item => item.action === 'failed')
|
||||
})
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
async function resolveSyncContext(options: SyncCommonOptions): Promise<{
|
||||
client: SkillHubClient
|
||||
registry: string
|
||||
token: string
|
||||
namespace: string
|
||||
rootDir: string
|
||||
}> {
|
||||
const configStore = new ConfigStore()
|
||||
const credentialsStore = new CredentialsStore()
|
||||
const registry = resolveRegistry(options, process.env, await configStore.read())
|
||||
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
|
||||
if (!token) {
|
||||
throw new CliError('authentication required for namespace sync', EXIT.auth, { next: 'run `skillhub login`' })
|
||||
}
|
||||
const namespace = options.namespace ?? 'global'
|
||||
const rootDir = resolve(options.dir ?? join(process.cwd(), '.agents', 'skills'))
|
||||
return { client: new SkillHubClient(registry, token), registry, token, namespace, rootDir }
|
||||
}
|
||||
|
||||
function renderPullResult(result: PullResult, json: boolean, check: boolean): string {
|
||||
if (json) {
|
||||
return JSON.stringify({ ok: result.failures.length === 0, check, ...result })
|
||||
}
|
||||
const lines = [
|
||||
`${check ? 'Checked' : 'Synchronized'} ${result.namespace} in ${result.rootDir}`,
|
||||
...result.actions.map(item => `${item.action.padEnd(10)} ${item.slug}`),
|
||||
...result.entries
|
||||
.filter(entry => !result.actions.some(action => action.slug === entry.slug))
|
||||
.map(entry => `${entry.status.padEnd(16)} ${entry.slug}`),
|
||||
...result.failures.map(item => `failed ${item.slug}: ${item.message}`)
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function renderStatusEntries(namespace: string, rootDir: string, entries: SyncStatusEntry[], json: boolean): string {
|
||||
if (json) return JSON.stringify({ ok: true, namespace, rootDir, items: entries })
|
||||
if (entries.length === 0) return `No installable skills found in namespace ${namespace}.`
|
||||
return entries.map(entry => {
|
||||
const versions = entry.remoteVersion
|
||||
? ` local=${entry.localVersion ?? '-'} remote=${entry.remoteVersion}`
|
||||
: ` local=${entry.localVersion ?? '-'}`
|
||||
return `${entry.status.padEnd(16)} ${entry.slug}${versions}`
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
function renderPushResults(namespace: string, results: PushResultItem[], json: boolean, dryRun: boolean): string {
|
||||
if (json) return JSON.stringify({ ok: results.every(item => item.action !== 'failed'), namespace, dryRun, items: results })
|
||||
return results.map(item => {
|
||||
const coordinate = item.slug ? `${namespace}/${item.slug}${item.version ? `@${item.version}` : ''}` : item.path
|
||||
const detail = item.errors?.length ? `: ${item.errors.join('; ')}` : ''
|
||||
return `${item.action.padEnd(16)} ${coordinate}${detail}`
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
function normalizeVisibility(value: string): 'PUBLIC' | 'NAMESPACE_ONLY' | 'PRIVATE' {
|
||||
const normalized = value.toUpperCase().replace(/-/g, '_')
|
||||
if (normalized !== 'PUBLIC' && normalized !== 'NAMESPACE_ONLY' && normalized !== 'PRIVATE') {
|
||||
throw new CliError('visibility must be public, namespace-only, or private', EXIT.usage)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
// Generated by scripts/generate-pkg-info.ts - do not edit by hand.
|
||||
export const PKG_NAME = "@astron-team/skillhub"
|
||||
export const PKG_VERSION = "0.1.9"
|
||||
export const PKG_VERSION = "0.1.10"
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ import { logoutCommand } from './commands/logout'
|
|||
import { publishCommand, type PublishCommandOptions } from './commands/publish'
|
||||
import { removeCommand, type RemoveCommandOptions } from './commands/remove'
|
||||
import { searchCommand } from './commands/search'
|
||||
import { syncDiffCommand, syncPullCommand, syncPushCommand, syncStatusCommand, type SyncCommonOptions, type SyncPullOptions, type SyncPushOptions } from './commands/sync'
|
||||
import { updateCommand } from './commands/update'
|
||||
import { versionCommand } from './commands/version'
|
||||
import { whoamiCommand } from './commands/whoami'
|
||||
import { EXIT } from './shared/constants'
|
||||
import { CliError } from './shared/errors'
|
||||
import { renderError } from './shared/output'
|
||||
|
||||
|
|
@ -245,6 +247,37 @@ cli
|
|||
return runCommand(() => installCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json))
|
||||
})
|
||||
|
||||
cli
|
||||
.command('sync <action> [path]', 'Synchronize and maintain a namespace workspace')
|
||||
.option('--namespace <slug>', 'Namespace', { default: 'global' })
|
||||
.option('--dir <path>', 'Skill workspace directory')
|
||||
.option('--check', 'Show changes without downloading')
|
||||
.option('--prune', 'Remove managed local skills missing remotely')
|
||||
.option('--force', 'Overwrite local changes')
|
||||
.option('--all', 'Push every skill directory in the workspace')
|
||||
.option('--visibility <v>', 'Visibility (public|namespace-only|private)', { default: 'namespace-only' })
|
||||
.option('--dry-run', 'Validate without uploading')
|
||||
.option('--submit-review', 'Submit an uploaded version for review when required')
|
||||
.option('--registry <url>', 'Registry URL')
|
||||
.option('--token <token>', 'API token')
|
||||
.option('--json', 'Output JSON')
|
||||
.action((action: string, path: string | undefined, options: SyncPullOptions & SyncPushOptions) => {
|
||||
const command = action === 'pull'
|
||||
? () => syncPullCommand(options)
|
||||
: action === 'status'
|
||||
? () => syncStatusCommand(options as SyncCommonOptions)
|
||||
: action === 'diff'
|
||||
? () => syncDiffCommand(options as SyncCommonOptions)
|
||||
: action === 'push'
|
||||
? () => syncPushCommand(path, options)
|
||||
: () => Promise.reject(new CliError(
|
||||
`unknown sync action: ${action}`,
|
||||
EXIT.usage,
|
||||
{ next: 'use pull, status, diff, or push' }
|
||||
))
|
||||
return runCommand(command, Boolean(options.json))
|
||||
})
|
||||
|
||||
cli
|
||||
.command('list', 'List local installs')
|
||||
.option('--agent <profile>', 'Filter by agent (repeatable)')
|
||||
|
|
|
|||
|
|
@ -111,21 +111,31 @@ function findEndOfCentralDirectory(view: DataView): number {
|
|||
* Returns the archive as a Blob.
|
||||
* Pure JS implementation using fflate — no system commands needed.
|
||||
*/
|
||||
export async function createZip(dirPath: string): Promise<Blob> {
|
||||
export interface CreateZipOptions {
|
||||
exclude?: (relativePath: string) => boolean
|
||||
}
|
||||
|
||||
export async function createZip(dirPath: string, options: CreateZipOptions = {}): Promise<Blob> {
|
||||
const entries: Record<string, Uint8Array> = {}
|
||||
await collectFiles(dirPath, dirPath, entries)
|
||||
await collectFiles(dirPath, dirPath, entries, options)
|
||||
const zipped = zipSync(entries, { level: 6 })
|
||||
return new Blob([zipped.buffer as ArrayBuffer], { type: 'application/zip' })
|
||||
}
|
||||
|
||||
async function collectFiles(basePath: string, currentPath: string, entries: Record<string, Uint8Array>): Promise<void> {
|
||||
async function collectFiles(
|
||||
basePath: string,
|
||||
currentPath: string,
|
||||
entries: Record<string, Uint8Array>,
|
||||
options: CreateZipOptions
|
||||
): Promise<void> {
|
||||
const items = await readdir(currentPath, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
const fullPath = join(currentPath, item.name)
|
||||
const relPath = relative(basePath, fullPath)
|
||||
const relPath = relative(basePath, fullPath).split('\\').join('/')
|
||||
if (options.exclude?.(relPath)) continue
|
||||
if (item.isDirectory()) {
|
||||
entries[relPath + '/'] = new Uint8Array(0)
|
||||
await collectFiles(basePath, fullPath, entries)
|
||||
await collectFiles(basePath, fullPath, entries, options)
|
||||
} else if (item.isFile()) {
|
||||
entries[relPath] = new Uint8Array(await readFile(fullPath))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { EXIT } from '../shared/constants'
|
|||
import { extractZip } from '../platform/archive'
|
||||
import { readBoundedResponseBody } from '../platform/download'
|
||||
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
|
||||
import { snapshotSkillDirectory } from './skill-fingerprint'
|
||||
import type { AgentCandidate } from '../agents/types'
|
||||
import type { ResolveResponse } from '../clients/skillhub-client'
|
||||
|
||||
export interface InstallOptions {
|
||||
registry: string
|
||||
|
|
@ -18,6 +20,7 @@ export interface InstallOptions {
|
|||
targets: AgentCandidate[]
|
||||
force: boolean
|
||||
home?: string | undefined
|
||||
resolved?: ResolveResponse | undefined
|
||||
}
|
||||
|
||||
async function preflightInstallTargets(
|
||||
|
|
@ -55,7 +58,7 @@ async function preflightInstallTargets(
|
|||
export async function installSkill(options: InstallOptions): Promise<{ installed: Array<{ agent: string; dir: string }> }> {
|
||||
const preparedTargets = await preflightInstallTargets(options.targets, options.slug, options.force)
|
||||
const client = new SkillHubClient(options.registry, options.token)
|
||||
const resolved = await client.resolve(options.namespace, options.slug, options.version)
|
||||
const resolved = options.resolved ?? await client.resolve(options.namespace, options.slug, options.version)
|
||||
const response = await client.download(options.namespace, options.slug, resolved.version)
|
||||
const buffer = await readBoundedResponseBody(response)
|
||||
|
||||
|
|
@ -71,6 +74,7 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
|
|||
await extractZip(buffer, tempDir)
|
||||
|
||||
const installedAt = new Date().toISOString()
|
||||
const snapshot = await snapshotSkillDirectory(tempDir)
|
||||
const metaDir = join(tempDir, '.skillhub')
|
||||
await mkdir(metaDir, { recursive: true })
|
||||
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
|
||||
|
|
@ -78,6 +82,9 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
|
|||
namespace: options.namespace,
|
||||
slug: options.slug,
|
||||
version: resolved.version,
|
||||
fingerprint: resolved.fingerprint,
|
||||
files: snapshot.files,
|
||||
source: 'skillhub',
|
||||
agent: target.agent,
|
||||
installedAt
|
||||
}, null, 2))
|
||||
|
|
@ -89,14 +96,30 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
|
|||
})
|
||||
}
|
||||
|
||||
if (await pathExists(skillDir) && options.force) {
|
||||
await store.removeTargetsByInstallDir(skillDir)
|
||||
await rm(skillDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const backupDir = `${skillDir}.skillhub-backup-${process.pid}-${Date.now()}`
|
||||
let backupCreated = false
|
||||
try {
|
||||
if (await pathExists(skillDir)) {
|
||||
await rename(skillDir, backupDir)
|
||||
backupCreated = true
|
||||
}
|
||||
await rename(tempDir, skillDir)
|
||||
movedIntoPlace = true
|
||||
|
||||
await store.replaceTargetAtInstallDir(options.registry, options.namespace, options.slug, resolved.version, {
|
||||
agent: target.agent,
|
||||
rootDir: target.rootDir,
|
||||
installDir: skillDir,
|
||||
installedAt
|
||||
}, resolved.fingerprint)
|
||||
|
||||
if (backupCreated) await rm(backupDir, { recursive: true, force: true }).catch(() => {})
|
||||
} catch (error) {
|
||||
if (movedIntoPlace) {
|
||||
await rm(skillDir, { recursive: true, force: true }).catch(() => {})
|
||||
movedIntoPlace = false
|
||||
}
|
||||
if (backupCreated) await rename(backupDir, skillDir).catch(() => {})
|
||||
if (!options.force && await pathExists(skillDir)) {
|
||||
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
|
||||
path: skillDir,
|
||||
|
|
@ -105,14 +128,6 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
|
|||
}
|
||||
throw error
|
||||
}
|
||||
movedIntoPlace = true
|
||||
|
||||
await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, {
|
||||
agent: target.agent,
|
||||
rootDir: target.rootDir,
|
||||
installDir: skillDir,
|
||||
installedAt
|
||||
})
|
||||
} finally {
|
||||
if (!movedIntoPlace) {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
||||
|
|
|
|||
54
cli/src/services/skill-fingerprint.ts
Normal file
54
cli/src/services/skill-fingerprint.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { createHash } from 'node:crypto'
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { join, relative } from 'node:path'
|
||||
|
||||
export interface SkillSnapshot {
|
||||
fingerprint: string
|
||||
files: Record<string, string>
|
||||
}
|
||||
|
||||
export async function snapshotSkillDirectory(skillDir: string): Promise<SkillSnapshot> {
|
||||
const paths = await listSkillFiles(skillDir)
|
||||
const files: Record<string, string> = {}
|
||||
const aggregate = createHash('sha256')
|
||||
|
||||
for (const path of paths) {
|
||||
const content = await readFile(join(skillDir, path))
|
||||
const fileHash = createHash('sha256').update(content).digest('hex')
|
||||
files[path] = fileHash
|
||||
aggregate.update(`${path}:${fileHash}\n`, 'utf8')
|
||||
}
|
||||
|
||||
return { fingerprint: `sha256:${aggregate.digest('hex')}`, files }
|
||||
}
|
||||
|
||||
export function diffSkillFiles(
|
||||
baseline: Record<string, string> | undefined,
|
||||
current: Record<string, string>
|
||||
): string[] {
|
||||
if (!baseline) return []
|
||||
const paths = new Set([...Object.keys(baseline), ...Object.keys(current)])
|
||||
return [...paths]
|
||||
.filter(path => baseline[path] !== current[path])
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
async function listSkillFiles(root: string): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
|
||||
async function walk(current: string): Promise<void> {
|
||||
const entries = await readdir(current, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.name === '.skillhub') continue
|
||||
const absolute = join(current, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await walk(absolute)
|
||||
} else if (entry.isFile()) {
|
||||
files.push(relative(root, absolute).split('\\').join('/'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root)
|
||||
return files.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
353
cli/src/services/sync-service.ts
Normal file
353
cli/src/services/sync-service.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { readdir, readFile, rename, rm, stat } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
import { SkillHubClient, type NamespaceSyncItem } from '../clients/skillhub-client'
|
||||
import { installSkill } from './install-service'
|
||||
import { diffSkillFiles, snapshotSkillDirectory } from './skill-fingerprint'
|
||||
import { InventoryStore } from '../stores/inventory-store'
|
||||
import { SyncWorkspaceStore, type NamespaceSyncState } from '../stores/sync-workspace-store'
|
||||
import { createZip, isZipFile } from '../platform/archive'
|
||||
import { pathExists } from '../platform/paths'
|
||||
|
||||
export type SyncStatus = 'up-to-date' | 'update-available' | 'local-changed' | 'orphaned' | 'not-installed'
|
||||
|
||||
export interface SkillSyncMetadata {
|
||||
registry: string
|
||||
namespace: string
|
||||
slug: string
|
||||
version: string
|
||||
fingerprint: string
|
||||
files?: Record<string, string>
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface SyncStatusEntry {
|
||||
namespace: string
|
||||
slug: string
|
||||
status: SyncStatus
|
||||
localVersion?: string
|
||||
remoteVersion?: string
|
||||
changedFiles: string[]
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface PullResult {
|
||||
namespace: string
|
||||
rootDir: string
|
||||
entries: SyncStatusEntry[]
|
||||
actions: Array<{ slug: string; action: 'installed' | 'updated' | 'pruned' }>
|
||||
failures: Array<{ slug: string; message: string }>
|
||||
}
|
||||
|
||||
export interface PushResultItem {
|
||||
path: string
|
||||
slug?: string
|
||||
version?: string
|
||||
status?: string
|
||||
action: 'validated' | 'uploaded' | 'submitted-review' | 'failed'
|
||||
errors?: string[]
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export async function listAllNamespaceSkills(
|
||||
client: SkillHubClient,
|
||||
namespace: string
|
||||
): Promise<NamespaceSyncItem[]> {
|
||||
const items: NamespaceSyncItem[] = []
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const page = await client.listNamespaceSkills(namespace, cursor, 100)
|
||||
items.push(...page.items)
|
||||
cursor = page.nextCursor ?? undefined
|
||||
} while (cursor)
|
||||
return items
|
||||
}
|
||||
|
||||
export async function inspectNamespaceWorkspace(options: {
|
||||
client: SkillHubClient
|
||||
registry: string
|
||||
namespace: string
|
||||
rootDir: string
|
||||
remoteItems?: NamespaceSyncItem[]
|
||||
}): Promise<{ entries: SyncStatusEntry[]; remoteItems: NamespaceSyncItem[] }> {
|
||||
const remoteItems = options.remoteItems ?? await listAllNamespaceSkills(options.client, options.namespace)
|
||||
const managed = await scanManagedSkills(options.rootDir, options.registry, options.namespace)
|
||||
const entries: SyncStatusEntry[] = []
|
||||
const remoteSlugs = new Set(remoteItems.map(item => item.slug))
|
||||
|
||||
for (const remote of remoteItems) {
|
||||
const skillDir = join(options.rootDir, remote.slug)
|
||||
const metadata = managed.get(remote.slug)
|
||||
if (!(await pathExists(skillDir))) {
|
||||
entries.push(baseEntry(remote, 'not-installed'))
|
||||
continue
|
||||
}
|
||||
if (!metadata) {
|
||||
entries.push({ ...baseEntry(remote, 'local-changed'), reason: 'directory is not managed by SkillHub' })
|
||||
continue
|
||||
}
|
||||
|
||||
const snapshot = await snapshotSkillDirectory(skillDir)
|
||||
if (snapshot.fingerprint !== metadata.fingerprint) {
|
||||
entries.push({
|
||||
...baseEntry(remote, 'local-changed'),
|
||||
localVersion: metadata.version,
|
||||
changedFiles: diffSkillFiles(metadata.files, snapshot.files)
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (metadata.fingerprint !== remote.fingerprint) {
|
||||
entries.push({
|
||||
...baseEntry(remote, 'update-available'),
|
||||
localVersion: metadata.version
|
||||
})
|
||||
continue
|
||||
}
|
||||
entries.push({ ...baseEntry(remote, 'up-to-date'), localVersion: metadata.version })
|
||||
}
|
||||
|
||||
for (const [slug, metadata] of managed) {
|
||||
if (!remoteSlugs.has(slug)) {
|
||||
const snapshot = await snapshotSkillDirectory(join(options.rootDir, slug))
|
||||
const orphan: SyncStatusEntry = {
|
||||
namespace: options.namespace,
|
||||
slug,
|
||||
status: 'orphaned',
|
||||
localVersion: metadata.version,
|
||||
changedFiles: diffSkillFiles(metadata.files, snapshot.files)
|
||||
}
|
||||
if (snapshot.fingerprint !== metadata.fingerprint) orphan.reason = 'local changes detected'
|
||||
entries.push(orphan)
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.slug.localeCompare(right.slug))
|
||||
return { entries, remoteItems }
|
||||
}
|
||||
|
||||
export async function pullNamespace(options: {
|
||||
client: SkillHubClient
|
||||
registry: string
|
||||
token: string
|
||||
namespace: string
|
||||
rootDir: string
|
||||
check: boolean
|
||||
prune: boolean
|
||||
force: boolean
|
||||
}): Promise<PullResult> {
|
||||
const inspected = await inspectNamespaceWorkspace(options)
|
||||
const result: PullResult = {
|
||||
namespace: options.namespace,
|
||||
rootDir: options.rootDir,
|
||||
entries: inspected.entries,
|
||||
actions: [],
|
||||
failures: []
|
||||
}
|
||||
if (options.check) return result
|
||||
|
||||
const remoteBySlug = new Map(inspected.remoteItems.map(item => [item.slug, item]))
|
||||
for (const entry of inspected.entries) {
|
||||
if (entry.status === 'up-to-date' || entry.status === 'orphaned') continue
|
||||
if (entry.status === 'local-changed' && !options.force) {
|
||||
result.failures.push({ slug: entry.slug, message: entry.reason ?? 'local changes detected; pass --force to overwrite' })
|
||||
continue
|
||||
}
|
||||
const remote = remoteBySlug.get(entry.slug)
|
||||
if (!remote) continue
|
||||
try {
|
||||
await installSkill({
|
||||
registry: options.registry,
|
||||
token: options.token,
|
||||
namespace: options.namespace,
|
||||
slug: remote.slug,
|
||||
version: remote.version,
|
||||
resolved: {
|
||||
namespace: remote.namespace,
|
||||
slug: remote.slug,
|
||||
version: remote.version,
|
||||
versionId: remote.versionId,
|
||||
fingerprint: remote.fingerprint,
|
||||
downloadUrl: remote.downloadUrl
|
||||
},
|
||||
targets: [{ agent: 'workspace', rootDir: options.rootDir, scope: 'project', source: 'explicit' }],
|
||||
force: entry.status !== 'not-installed' || options.force
|
||||
})
|
||||
result.actions.push({ slug: entry.slug, action: entry.status === 'not-installed' ? 'installed' : 'updated' })
|
||||
} catch (error) {
|
||||
result.failures.push({ slug: entry.slug, message: error instanceof Error ? error.message : 'install failed' })
|
||||
}
|
||||
}
|
||||
|
||||
if (options.prune) {
|
||||
for (const entry of inspected.entries.filter(item => item.status === 'orphaned')) {
|
||||
if (entry.reason && !options.force) {
|
||||
result.failures.push({ slug: entry.slug, message: 'orphan has local changes; pass --force to prune' })
|
||||
continue
|
||||
}
|
||||
const installDir = join(options.rootDir, entry.slug)
|
||||
const backupDir = `${installDir}.skillhub-prune-${process.pid}-${Date.now()}`
|
||||
try {
|
||||
await rename(installDir, backupDir)
|
||||
try {
|
||||
await new InventoryStore().removeTargetsByInstallDir(installDir)
|
||||
} catch (error) {
|
||||
await rename(backupDir, installDir).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
await rm(backupDir, { recursive: true, force: true }).catch(() => {})
|
||||
result.actions.push({ slug: entry.slug, action: 'pruned' })
|
||||
} catch (error) {
|
||||
result.failures.push({
|
||||
slug: entry.slug,
|
||||
message: error instanceof Error ? error.message : 'prune failed'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.failures.length === 0) {
|
||||
const state: NamespaceSyncState = {
|
||||
registry: options.registry,
|
||||
namespace: options.namespace,
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
skills: Object.fromEntries(inspected.remoteItems.map(item => [item.slug, {
|
||||
version: item.version,
|
||||
fingerprint: item.fingerprint
|
||||
}]))
|
||||
}
|
||||
await new SyncWorkspaceStore(options.rootDir).write(state)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function pushSkills(options: {
|
||||
client: SkillHubClient
|
||||
namespace: string
|
||||
paths: string[]
|
||||
visibility: 'PUBLIC' | 'NAMESPACE_ONLY' | 'PRIVATE'
|
||||
dryRun: boolean
|
||||
submitReview: boolean
|
||||
}): Promise<PushResultItem[]> {
|
||||
const results: PushResultItem[] = []
|
||||
for (const path of options.paths) {
|
||||
try {
|
||||
const archive = await prepareArchive(path)
|
||||
const validation = await options.client.validatePublish(
|
||||
options.namespace, archive.blob, options.visibility, archive.fileName, true)
|
||||
if (!validation.valid) {
|
||||
const failed: PushResultItem = {
|
||||
path,
|
||||
action: 'failed',
|
||||
errors: validation.errors,
|
||||
warnings: validation.warnings
|
||||
}
|
||||
if (validation.resolvedSlug) failed.slug = validation.resolvedSlug
|
||||
if (validation.resolvedVersion) failed.version = validation.resolvedVersion
|
||||
results.push(failed)
|
||||
continue
|
||||
}
|
||||
if (options.dryRun) {
|
||||
const validated: PushResultItem = {
|
||||
path,
|
||||
action: 'validated',
|
||||
warnings: validation.warnings
|
||||
}
|
||||
if (validation.resolvedSlug) validated.slug = validation.resolvedSlug
|
||||
if (validation.resolvedVersion) validated.version = validation.resolvedVersion
|
||||
results.push(validated)
|
||||
continue
|
||||
}
|
||||
|
||||
const published = await options.client.publish(
|
||||
options.namespace, archive.blob, options.visibility, archive.fileName, true)
|
||||
let action: PushResultItem['action'] = 'uploaded'
|
||||
let status = published.status
|
||||
if (options.submitReview && published.status === 'PENDING_REVIEW') {
|
||||
action = 'submitted-review'
|
||||
} else if (options.submitReview && published.status === 'UPLOADED') {
|
||||
if (options.visibility === 'PRIVATE') {
|
||||
throw new Error('--submit-review requires public or namespace-only visibility')
|
||||
}
|
||||
const review = await options.client.submitReview(
|
||||
options.namespace,
|
||||
published.slug,
|
||||
published.version,
|
||||
options.visibility
|
||||
)
|
||||
action = 'submitted-review'
|
||||
status = review.status
|
||||
}
|
||||
results.push({ path, slug: published.slug, version: published.version, status, action })
|
||||
} catch (error) {
|
||||
results.push({ path, action: 'failed', errors: [error instanceof Error ? error.message : 'push failed'] })
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function discoverSkillDirectories(rootDir: string): Promise<string[]> {
|
||||
if (!(await pathExists(rootDir))) return []
|
||||
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||
const paths: string[] = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === '.skillhub') continue
|
||||
const path = join(rootDir, entry.name)
|
||||
if (await pathExists(join(path, 'SKILL.md'))) paths.push(path)
|
||||
}
|
||||
return paths.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
async function prepareArchive(path: string): Promise<{ blob: Blob; fileName: string }> {
|
||||
const pathStat = await stat(path)
|
||||
if (pathStat.isDirectory()) {
|
||||
return {
|
||||
blob: await createZip(path, { exclude: relativePath => relativePath === '.skillhub' || relativePath.startsWith('.skillhub/') }),
|
||||
fileName: `${basename(path)}.zip`
|
||||
}
|
||||
}
|
||||
if (pathStat.isFile() && await isZipFile(path)) {
|
||||
return { blob: new Blob([await readFile(path)], { type: 'application/zip' }), fileName: basename(path) }
|
||||
}
|
||||
throw new Error(`path must be a skill directory or zip archive: ${path}`)
|
||||
}
|
||||
|
||||
async function scanManagedSkills(
|
||||
rootDir: string,
|
||||
registry: string,
|
||||
namespace: string
|
||||
): Promise<Map<string, SkillSyncMetadata>> {
|
||||
const managed = new Map<string, SkillSyncMetadata>()
|
||||
if (!(await pathExists(rootDir))) return managed
|
||||
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === '.skillhub') continue
|
||||
const metadataPath = join(rootDir, entry.name, '.skillhub', 'metadata.json')
|
||||
if (!(await pathExists(metadataPath))) continue
|
||||
try {
|
||||
const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) as SkillSyncMetadata
|
||||
if (metadata.source === 'skillhub'
|
||||
&& normalizeRegistry(metadata.registry) === normalizeRegistry(registry)
|
||||
&& metadata.namespace === namespace
|
||||
&& metadata.slug === entry.name) {
|
||||
managed.set(entry.name, metadata)
|
||||
}
|
||||
} catch {
|
||||
// Corrupt metadata is treated as an unmanaged local directory.
|
||||
}
|
||||
}
|
||||
return managed
|
||||
}
|
||||
|
||||
function baseEntry(remote: NamespaceSyncItem, status: SyncStatus): SyncStatusEntry {
|
||||
return {
|
||||
namespace: remote.namespace,
|
||||
slug: remote.slug,
|
||||
status,
|
||||
remoteVersion: remote.version,
|
||||
changedFiles: []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRegistry(registry: string): string {
|
||||
return registry.replace(/\/+$/, '')
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ export interface InventoryItem {
|
|||
namespace: string
|
||||
slug: string
|
||||
version: string
|
||||
fingerprint?: string
|
||||
targets: InventoryTarget[]
|
||||
}
|
||||
|
||||
|
|
@ -117,17 +118,19 @@ export class InventoryStore {
|
|||
namespace: string,
|
||||
slug: string,
|
||||
version: string,
|
||||
target: InventoryTarget
|
||||
target: InventoryTarget,
|
||||
fingerprint?: string
|
||||
): Promise<void> {
|
||||
const inventory = await this.read()
|
||||
let item = inventory.items.find(
|
||||
const existing = inventory.items.find(
|
||||
i => i.registry === registry && i.namespace === namespace && i.slug === slug
|
||||
)
|
||||
if (!item) {
|
||||
item = { registry, namespace, slug, version, targets: [] }
|
||||
const item: InventoryItem = existing ?? { registry, namespace, slug, version, targets: [] }
|
||||
if (!existing) {
|
||||
inventory.items.push(item)
|
||||
}
|
||||
item.version = version
|
||||
if (fingerprint !== undefined) item.fingerprint = fingerprint
|
||||
const existingIdx = item.targets.findIndex(t => t.installDir === target.installDir)
|
||||
if (existingIdx >= 0) {
|
||||
item.targets[existingIdx] = target
|
||||
|
|
@ -165,4 +168,30 @@ export class InventoryStore {
|
|||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
async replaceTargetAtInstallDir(
|
||||
registry: string,
|
||||
namespace: string,
|
||||
slug: string,
|
||||
version: string,
|
||||
target: InventoryTarget,
|
||||
fingerprint?: string
|
||||
): Promise<void> {
|
||||
const inventory = await this.read()
|
||||
for (const item of inventory.items) {
|
||||
item.targets = item.targets.filter(existing => existing.installDir !== target.installDir)
|
||||
}
|
||||
inventory.items = inventory.items.filter(item => item.targets.length > 0)
|
||||
|
||||
let item = inventory.items.find(candidate =>
|
||||
candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug)
|
||||
if (!item) {
|
||||
item = { registry, namespace, slug, version, targets: [] }
|
||||
inventory.items.push(item)
|
||||
}
|
||||
item.version = version
|
||||
if (fingerprint !== undefined) item.fingerprint = fingerprint
|
||||
item.targets.push(target)
|
||||
await this.writeAtomic(inventory)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
cli/src/stores/sync-workspace-store.ts
Normal file
39
cli/src/stores/sync-workspace-store.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathExists } from '../platform/paths'
|
||||
|
||||
export interface NamespaceSyncStateSkill {
|
||||
version: string
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export interface NamespaceSyncState {
|
||||
registry: string
|
||||
namespace: string
|
||||
lastSyncAt: string
|
||||
skills: Record<string, NamespaceSyncStateSkill>
|
||||
}
|
||||
|
||||
export class SyncWorkspaceStore {
|
||||
readonly path: string
|
||||
|
||||
constructor(rootDir: string) {
|
||||
this.path = join(rootDir, '.skillhub', 'namespace-sync.json')
|
||||
}
|
||||
|
||||
async read(): Promise<NamespaceSyncState | null> {
|
||||
if (!(await pathExists(this.path))) return null
|
||||
return JSON.parse(await readFile(this.path, 'utf8')) as NamespaceSyncState
|
||||
}
|
||||
|
||||
async write(state: NamespaceSyncState): Promise<void> {
|
||||
await mkdir(dirname(this.path), { recursive: true })
|
||||
const tempPath = `${this.path}.${process.pid}.${Date.now()}.tmp`
|
||||
try {
|
||||
await writeFile(tempPath, JSON.stringify(state, null, 2))
|
||||
await rename(tempPath, this.path)
|
||||
} finally {
|
||||
await rm(tempPath, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -102,12 +102,14 @@ export interface CapturedPublish {
|
|||
fileName: string
|
||||
/** Visibility string from the multipart form field. */
|
||||
visibility: string
|
||||
rejectExistingVersion: boolean
|
||||
}
|
||||
|
||||
export interface CapturedValidate {
|
||||
namespace: string
|
||||
fileName: string
|
||||
visibility: string
|
||||
rejectExistingVersion: boolean
|
||||
}
|
||||
|
||||
/** Last resolve GET: useful for verifying --version is forwarded as ?version=. */
|
||||
|
|
@ -125,6 +127,13 @@ export interface CapturedDelete {
|
|||
token: string | null
|
||||
}
|
||||
|
||||
export interface CapturedReview {
|
||||
namespace: string
|
||||
slug: string
|
||||
version: string
|
||||
targetVisibility: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -137,6 +146,7 @@ interface FakeRegistryOptions {
|
|||
skills?: FakeSkill[]
|
||||
/** Response to return for publish/validate (dry-run) requests. */
|
||||
dryRunResponse?: { valid: boolean; errors: string[]; warnings: string[]; resolvedSlug: string | null; resolvedVersion: string | null }
|
||||
publishStatus?: string
|
||||
/**
|
||||
* Per-endpoint failure injection. When set for an endpoint, that endpoint
|
||||
* ignores all other logic and returns the specified failure (or throws for
|
||||
|
|
@ -150,6 +160,8 @@ interface FakeRegistryOptions {
|
|||
deleteRemote?: FailureMode
|
||||
publish?: FailureMode
|
||||
validate?: FailureMode
|
||||
namespaceSync?: FailureMode
|
||||
submitReview?: FailureMode
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,7 +202,8 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
resolve: CapturedResolve | null
|
||||
delete: CapturedDelete | null
|
||||
validate: CapturedValidate | null
|
||||
} = { publish: null, resolve: null, delete: null, validate: null }
|
||||
review: CapturedReview | null
|
||||
} = { publish: null, resolve: null, delete: null, validate: null, review: null }
|
||||
|
||||
// If any endpoint is configured with 'network' failure mode, we need a real
|
||||
// TCP-level failure. Start a connection-dropping server and return its URL
|
||||
|
|
@ -263,6 +276,31 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
const namespaceSyncMatch = path.match(/^\/api\/cli\/v1\/namespaces\/([^/]+)\/skills$/)
|
||||
if (namespaceSyncMatch && req.method === 'GET') {
|
||||
if (options.failures?.namespaceSync) return failureResponse(options.failures.namespaceSync)
|
||||
const authErr = checkAuth(req)
|
||||
if (authErr) return authErr
|
||||
const namespace = namespaceSyncMatch[1]!
|
||||
const skills = (options.skills ?? []).filter(skill => skill.namespace === namespace)
|
||||
return Response.json({
|
||||
code: 0,
|
||||
data: {
|
||||
items: skills.map((skill, index) => ({
|
||||
namespace,
|
||||
slug: skill.slug,
|
||||
version: skill.version ?? '1.0.0',
|
||||
versionId: skill.versionId ?? index + 1,
|
||||
fingerprint: skill.fingerprint ?? 'deadbeef',
|
||||
updatedAt: '2026-08-18T00:00:00Z',
|
||||
visibility: 'NAMESPACE_ONLY',
|
||||
downloadUrl: buildDownloadUrl(baseUrl, namespace, skill.slug, skill.version ?? '1.0.0')
|
||||
})),
|
||||
nextCursor: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Route: /api/cli/v1/skills/:namespace/:slug/...
|
||||
// ------------------------------------------------------------------ //
|
||||
|
|
@ -377,7 +415,12 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
if (fileField instanceof File) {
|
||||
fileName = fileField.name || fileName
|
||||
}
|
||||
state.validate = { namespace, fileName, visibility }
|
||||
state.validate = {
|
||||
namespace,
|
||||
fileName,
|
||||
visibility,
|
||||
rejectExistingVersion: form.get('rejectExistingVersion') === 'true'
|
||||
}
|
||||
|
||||
const dryRunData = options.dryRunResponse ?? {
|
||||
valid: true,
|
||||
|
|
@ -410,7 +453,12 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
}
|
||||
|
||||
// Record for test assertions.
|
||||
state.publish = { namespace, fileName, visibility }
|
||||
state.publish = {
|
||||
namespace,
|
||||
fileName,
|
||||
visibility,
|
||||
rejectExistingVersion: form.get('rejectExistingVersion') === 'true'
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
code: 0,
|
||||
|
|
@ -418,12 +466,30 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
namespace,
|
||||
slug: fileName.replace(/\.zip$/, ''),
|
||||
version: '1.0.0',
|
||||
visibility
|
||||
visibility,
|
||||
status: options.publishStatus ?? 'PENDING_REVIEW'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const submitReviewMatch = path.match(/^\/api\/v1\/skills\/([^/]+)\/([^/]+)\/submit-review$/)
|
||||
if (submitReviewMatch && req.method === 'POST') {
|
||||
if (options.failures?.submitReview) return failureResponse(options.failures.submitReview)
|
||||
const authErr = checkAuth(req)
|
||||
if (authErr) return authErr
|
||||
return req.json().then(body => {
|
||||
const request = body as { version: string; targetVisibility: string }
|
||||
const namespace = submitReviewMatch[1]!
|
||||
const slug = submitReviewMatch[2]!
|
||||
state.review = { namespace, slug, version: request.version, targetVisibility: request.targetVisibility }
|
||||
return Response.json({
|
||||
code: 0,
|
||||
data: { skillId: 1, versionId: 1, action: 'SUBMIT_REVIEW', status: 'PENDING_REVIEW' }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Fallthrough
|
||||
// ------------------------------------------------------------------ //
|
||||
|
|
|
|||
187
cli/test/integration/sync-command.test.ts
Normal file
187
cli/test/integration/sync-command.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { startFakeRegistry, type FakeSkill } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
|
||||
function makeSkill(body: string): { zipBytes: Uint8Array; fingerprint: string } {
|
||||
const content = strToU8(body)
|
||||
const fileHash = createHash('sha256').update(content).digest('hex')
|
||||
const fingerprint = `sha256:${createHash('sha256').update(`SKILL.md:${fileHash}\n`).digest('hex')}`
|
||||
return { zipBytes: zipSync({ 'SKILL.md': content }), fingerprint }
|
||||
}
|
||||
|
||||
describe('sync command', () => {
|
||||
test('pull installs a namespace incrementally and writes workspace metadata', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
const first = makeSkill('---\nname: first\ndescription: First\nversion: 1.0.0\n---\n')
|
||||
const second = makeSkill('---\nname: second\ndescription: Second\nversion: 1.0.0\n---\n')
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'token',
|
||||
skills: [
|
||||
{ namespace: 'team-a', slug: 'first', ...first },
|
||||
{ namespace: 'team-a', slug: 'second', ...second }
|
||||
]
|
||||
})
|
||||
|
||||
try {
|
||||
const pulled = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
|
||||
expect(pulled.exitCode).toBe(0)
|
||||
expect(JSON.parse(pulled.stdout).actions).toHaveLength(2)
|
||||
const metadata = JSON.parse(await readFile(join(skillsDir, 'first', '.skillhub', 'metadata.json'), 'utf8'))
|
||||
expect(metadata).toMatchObject({
|
||||
source: 'skillhub', namespace: 'team-a', slug: 'first', fingerprint: first.fingerprint
|
||||
})
|
||||
expect(await readFile(join(skillsDir, '.skillhub', 'namespace-sync.json'), 'utf8')).toContain('team-a')
|
||||
|
||||
const secondPull = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(secondPull.exitCode).toBe(0)
|
||||
expect(JSON.parse(secondPull.stdout).actions).toHaveLength(0)
|
||||
expect(JSON.parse(secondPull.stdout).entries.every((item: { status: string }) => item.status === 'up-to-date')).toBe(true)
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('status detects local changes and pull does not overwrite without force', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'token',
|
||||
skills: [{ namespace: 'team-a', slug: 'demo', ...fixture }]
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
await writeFile(join(skillsDir, 'demo', 'SKILL.md'), '# local change\n')
|
||||
|
||||
const status = await runCli([
|
||||
'sync', 'status', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(JSON.parse(status.stdout).items[0].status).toBe('local-changed')
|
||||
|
||||
const pull = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(pull.exitCode).toBe(1)
|
||||
expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')).toBe('# local change\n')
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('prune removes only unchanged managed orphan skills', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
const skills: FakeSkill[] = [{ namespace: 'team-a', slug: 'demo', ...fixture }]
|
||||
const registry = await startFakeRegistry({ token: 'token', skills })
|
||||
|
||||
try {
|
||||
await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
skills.splice(0, skills.length)
|
||||
|
||||
const pruned = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--prune',
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(pruned.exitCode).toBe(0)
|
||||
expect(JSON.parse(pruned.stdout).actions).toContainEqual({ slug: 'demo', action: 'pruned' })
|
||||
expect(await Bun.file(join(skillsDir, 'demo')).exists()).toBe(false)
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('push all validates packages and submits an uploaded version for review', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
const skillDir = join(skillsDir, 'demo')
|
||||
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"must":"not be uploaded"}')
|
||||
const registry = await startFakeRegistry({ token: 'token', publishStatus: 'UPLOADED' })
|
||||
|
||||
try {
|
||||
const pushed = await runCli([
|
||||
'sync', 'push', '--all', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--submit-review', '--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
|
||||
expect(pushed.exitCode).toBe(0)
|
||||
expect(JSON.parse(pushed.stdout).items[0].action).toBe('submitted-review')
|
||||
expect(registry.received.publish?.visibility).toBe('NAMESPACE_ONLY')
|
||||
expect(registry.received.publish?.rejectExistingVersion).toBe(true)
|
||||
expect(registry.received.review).toMatchObject({
|
||||
namespace: 'team-a', slug: 'demo', version: '1.0.0', targetVisibility: 'NAMESPACE_ONLY'
|
||||
})
|
||||
} finally {
|
||||
registry.stop()
|
||||
await rm(skillsDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('push dry-run uses strict validation without uploading', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillDir = join(env.cwd, 'demo')
|
||||
await mkdir(skillDir, { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
const registry = await startFakeRegistry({ token: 'token' })
|
||||
|
||||
try {
|
||||
const result = await runCli([
|
||||
'sync', 'push', skillDir, '--namespace', 'team-a', '--dry-run',
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(JSON.parse(result.stdout).items[0].action).toBe('validated')
|
||||
expect(registry.received.validate?.rejectExistingVersion).toBe(true)
|
||||
expect(registry.received.publish).toBeNull()
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('pull refuses to replace an unmanaged conflicting directory', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
await mkdir(join(skillsDir, 'demo'), { recursive: true })
|
||||
await writeFile(join(skillsDir, 'demo', 'local.txt'), 'keep')
|
||||
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'token', skills: [{ namespace: 'team-a', slug: 'demo', ...fixture }]
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(await readFile(join(skillsDir, 'demo', 'local.txt'), 'utf8')).toBe('keep')
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -15,7 +15,8 @@ describe('SkillHubClient', () => {
|
|||
namespace: 'team',
|
||||
slug: 'custom-skill',
|
||||
version: '1.0.0',
|
||||
visibility: 'PRIVATE'
|
||||
visibility: 'PRIVATE',
|
||||
status: 'UPLOADED'
|
||||
}
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
|
|
@ -188,6 +189,34 @@ describe('SkillHubClient', () => {
|
|||
expect(capturedUrl).toContain('?version=2.0.0')
|
||||
})
|
||||
|
||||
test('listNamespaceSkills forwards cursor and limit', async () => {
|
||||
let capturedUrl = ''
|
||||
const fetchImpl = (async (input: URL | RequestInfo) => {
|
||||
capturedUrl = String(input)
|
||||
return Response.json({ data: { items: [], nextCursor: null } })
|
||||
}) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
|
||||
await client.listNamespaceSkills('team a', 'cursor-value', 50)
|
||||
|
||||
expect(capturedUrl).toContain('/api/cli/v1/namespaces/team%20a/skills')
|
||||
expect(capturedUrl).toContain('cursor=cursor-value')
|
||||
expect(capturedUrl).toContain('limit=50')
|
||||
})
|
||||
|
||||
test('submitReview posts the lifecycle request with bearer auth', async () => {
|
||||
const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
expect(String(input)).toContain('/api/v1/skills/team-a/demo/submit-review')
|
||||
expect(init?.headers).toMatchObject({ Authorization: 'Bearer token', 'Content-Type': 'application/json' })
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ version: '1.0.0', targetVisibility: 'NAMESPACE_ONLY' })
|
||||
return Response.json({ data: { skillId: 1, versionId: 2, action: 'SUBMIT_REVIEW', status: 'PENDING_REVIEW' } })
|
||||
}) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
|
||||
await expect(client.submitReview('team-a', 'demo', '1.0.0', 'NAMESPACE_ONLY'))
|
||||
.resolves.toMatchObject({ status: 'PENDING_REVIEW' })
|
||||
})
|
||||
|
||||
// --- handleJsonResponse() non-2xx classification ---
|
||||
|
||||
test('whoami() preserves public fields and ignores unknown fields on a structured 401', async () => {
|
||||
|
|
|
|||
|
|
@ -228,6 +228,27 @@ describe('installSkill', () => {
|
|||
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
|
||||
})
|
||||
|
||||
test('force restores the old installation when inventory persistence fails', async () => {
|
||||
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
|
||||
const skillDir = join(rootDir, 'demo')
|
||||
await mkdir(skillDir, { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
|
||||
const invalidHome = join(rootDir, 'home-is-a-file')
|
||||
await writeFile(invalidHome, 'not a directory')
|
||||
|
||||
await expect(installSkill({
|
||||
registry: 'http://registry.test',
|
||||
namespace: 'global',
|
||||
slug: 'demo',
|
||||
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
|
||||
force: true,
|
||||
home: invalidHome
|
||||
})).rejects.toThrow()
|
||||
|
||||
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old')
|
||||
})
|
||||
|
||||
test('rejects downloads whose content-length exceeds the package limit', async () => {
|
||||
globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), {
|
||||
status: 200,
|
||||
|
|
|
|||
23
cli/test/unit/services/skill-fingerprint.test.ts
Normal file
23
cli/test/unit/services/skill-fingerprint.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createTempHome } from '../../helpers/temp-env'
|
||||
import { diffSkillFiles, snapshotSkillDirectory } from '../../../src/services/skill-fingerprint'
|
||||
|
||||
describe('skill fingerprint', () => {
|
||||
test('ignores SkillHub metadata and reports changed files', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillDir = join(env.cwd, 'demo')
|
||||
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '# one\n')
|
||||
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"ignored":true}')
|
||||
|
||||
const baseline = await snapshotSkillDirectory(skillDir)
|
||||
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"ignored":false}')
|
||||
expect((await snapshotSkillDirectory(skillDir)).fingerprint).toBe(baseline.fingerprint)
|
||||
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '# two\n')
|
||||
const current = await snapshotSkillDirectory(skillDir)
|
||||
expect(diffSkillFiles(baseline.files, current.files)).toEqual(['SKILL.md'])
|
||||
})
|
||||
})
|
||||
|
|
@ -71,6 +71,7 @@ services:
|
|||
SKILLHUB_API_UPSTREAM: http://server:8080
|
||||
SKILLHUB_WEB_API_BASE_URL: ""
|
||||
SKILLHUB_PUBLIC_BASE_URL: ""
|
||||
SKILLHUB_TRUST_FORWARDED_PROTO: "false"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -136,7 +136,8 @@ skillhub/
|
|||
|
||||
- 开发路径:`make dev-all`。前后端在宿主机运行,`docker-compose.yml` 只负责 PostgreSQL、Redis、MinIO。
|
||||
- 交付路径:GitHub Actions 构建并发布 `server` / `web` 镜像;用户通过 `compose.release.yml` 在本地一键拉起前后端容器和基础服务。
|
||||
- 发布镜像为多架构 manifest,至少覆盖 `linux/amd64` 与 `linux/arm64`。
|
||||
- 发布镜像为多架构 manifest:`server` / `web` 覆盖 `linux/amd64`、`linux/arm64` 与
|
||||
`linux/riscv64`;`scanner` 暂保持 `linux/amd64` 与 `linux/arm64`。
|
||||
|
||||
单机运行时统一入口:
|
||||
- `http://localhost/` → Web 容器(Nginx)
|
||||
|
|
@ -169,7 +170,8 @@ skillhub/
|
|||
- 数据库迁移:Flyway
|
||||
- 认证:Spring Security OAuth2 Client(一期 GitHub)
|
||||
- 镜像发布:GitHub Actions 推送至 GHCR,默认维护 `edge` 与语义化版本标签
|
||||
- 运行时兼容:发布镜像默认输出 `linux/amd64` + `linux/arm64` 多架构 manifest
|
||||
- 运行时兼容:`server` / `web` 发布镜像默认输出 `linux/amd64` + `linux/arm64` +
|
||||
`linux/riscv64` 多架构 manifest,`scanner` 暂保持 `linux/amd64` + `linux/arm64`
|
||||
|
||||
## 11. Repository / Query Boundary 约定
|
||||
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@
|
|||
| avatar_url | varchar(512) | |
|
||||
| status | enum | `ACTIVE` / `PENDING` / `DISABLED` / `MERGED` |
|
||||
| merged_to_user_id | varchar(128) | 合并目标用户 ID,仅 MERGED 状态有值 |
|
||||
| system_account | boolean | 系统服务账号,禁止交互式 Web/OAuth 登录 |
|
||||
| created_at | datetime | |
|
||||
| updated_at | datetime | |
|
||||
|
||||
|
|
@ -252,8 +253,10 @@
|
|||
- `ACTIVE`:正常使用
|
||||
- `PENDING`:等待管理员审批(AccessPolicy 返回 PENDING_APPROVAL 时创建)
|
||||
- `DISABLED`:管理员封禁,登录后拒绝所有操作,返回 403
|
||||
- `MERGED`:已合并到其他账号,保留记录不物理删除,登录时自动跳转到合并目标账号
|
||||
- `MERGED`:已合并到其他账号,保留记录不物理删除;登录直接拒绝,不向调用方泄露合并目标
|
||||
- 授权层在每次请求时检查用户状态,非 `ACTIVE` 用户拒绝所有写操作
|
||||
- system account 可按独立 Token Policy 使用非交互凭证,但不能通过本地密码或外部 OAuth
|
||||
建立普通用户 Session
|
||||
|
||||
### identity_binding
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ astron:
|
|||
- `DENY`:抛出 `OAuth2AccessDeniedException`,由 `failureHandler` 重定向到 `/access-denied` 页面。不创建用户,不建立 Session。
|
||||
- `PENDING_APPROVAL`:创建 `user_account`(status=`PENDING`),但不建立业务 Session。抛出 `AccountPendingException`,由 `failureHandler` 重定向到 `/pending-approval` 页面(纯静态提示页,无需登录态)。管理员在后台审批后状态变为 `ACTIVE`,用户下次 OAuth 登录才会正常建立 Session。
|
||||
|
||||
安全边界:PENDING / DISABLED 用户绝不会拥有有效的业务 Session,从根源上杜绝"待审批账号已认证"的风险。
|
||||
安全边界:PENDING / DISABLED / MERGED 用户和 system account 绝不会通过交互式登录获得
|
||||
业务 Session。外部身份命中这些账号时,在更新用户资料或加载角色前直接拒绝。
|
||||
|
||||
### 2.3 扩展性
|
||||
|
||||
|
|
@ -361,7 +362,8 @@ public class OAuthClaimsExtractor {
|
|||
合并操作规则:
|
||||
- 合并操作写入审计日志
|
||||
- 合并后原 user_account 标记为 `MERGED`,保留记录不物理删除
|
||||
- 预留扩展位:未来可配置 `astron.identity.auto-merge-on-verified-email=true` 开启基于已验证邮箱的自动合并
|
||||
- 不提供按 email 自动合并;即使 Provider 声明 email 已验证,也不能替代对两个账号控制权
|
||||
的分别证明。未来绑定/合并必须使用显式、可审计的重新认证流程。
|
||||
|
||||
## 5. CLI 认证(OAuth Device Flow + 平台凭证)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
- 单机交付环境:`docker compose --env-file .env.release -f compose.release.yml up -d`
|
||||
- 前端和后端都运行在容器内
|
||||
- 使用 GitHub Actions 发布到 GHCR 的镜像
|
||||
- 默认发布 `linux/amd64` 与 `linux/arm64` 多架构镜像
|
||||
- 默认发布多架构镜像:`server` / `web` 覆盖 `linux/amd64`、`linux/arm64` 与
|
||||
`linux/riscv64`,`scanner` 暂保持 `linux/amd64` 与 `linux/arm64`
|
||||
- PostgreSQL、Redis 与应用容器一起通过 Compose 启动
|
||||
|
||||
不再维护本地构建整套 demo 容器的中间模式,也不再保留 `docker-compose.prod.yml`。
|
||||
|
|
@ -205,10 +206,41 @@ Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Se
|
|||
- `ghcr.io/iflytek/skillhub-server`
|
||||
- `ghcr.io/iflytek/skillhub-web`
|
||||
5. 写入 `edge` / `vX.Y.Z` / `latest` / `sha-*` 标签
|
||||
6. 同时发布 `linux/amd64` 与 `linux/arm64` manifest,避免 Apple Silicon / ARM 主机依赖模拟层
|
||||
6. 同时发布多架构 manifest:`server` / `web` 覆盖 `linux/amd64`、`linux/arm64` 与
|
||||
`linux/riscv64`,`scanner` 暂保持 `linux/amd64` 与 `linux/arm64`
|
||||
|
||||
## 7 配置管理
|
||||
|
||||
### 7.1 请求限流配置
|
||||
|
||||
限流默认开启。未配置分类覆盖时,各接口使用代码中 `@RateLimit` 声明的默认值,现有部署无需调整。
|
||||
|
||||
可通过环境变量关闭全部限流,或按分类覆盖额度和时间窗口:
|
||||
|
||||
```bash
|
||||
SKILLHUB_RATELIMIT_ENABLED=false
|
||||
SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_ANONYMOUS=100
|
||||
SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_AUTHENTICATED=300
|
||||
SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_WINDOW_SECONDS=60
|
||||
```
|
||||
|
||||
支持的配置字段为 `authenticated`、`anonymous` 和 `window-seconds`。分类名称来自接口的
|
||||
`@RateLimit(category = "...")`,例如 `search`、`download`、`publish` 和 `resolve`。只设置其中一个字段时,
|
||||
其他字段仍回退到接口默认值。
|
||||
|
||||
Docker Compose 用户需要显式传入变量,宿主机环境变量不会自动注入容器:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
environment:
|
||||
SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_ANONYMOUS: "100"
|
||||
SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_AUTHENTICATED: "300"
|
||||
SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_WINDOW_SECONDS: "60"
|
||||
```
|
||||
|
||||
修改后重启 server 容器生效。超过额度时接口返回 HTTP `429`;该配置只调整阈值,不改变 Redis 限流算法或响应格式。
|
||||
|
||||
前端运行时配置通过 `web/runtime-config.js.template` 注入。与认证兼容层相关的新变量如下:
|
||||
|
||||
- `SKILLHUB_WEB_AUTH_DIRECT_ENABLED`
|
||||
|
|
|
|||
85
docs/2026-08-13-personal-namespace-provisioning.md
Normal file
85
docs/2026-08-13-personal-namespace-provisioning.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# 注册时自动创建个人命名空间
|
||||
|
||||
## 背景
|
||||
|
||||
自建部署里常见的诉求:每个新账号都应该有一块属于自己的地盘,可以直接发布技能,
|
||||
而不必先向管理员申请命名空间、也不必把半成品塞进 `global`。
|
||||
|
||||
## 一、自动创建个人命名空间
|
||||
|
||||
### 「私有」在当前模型里的含义
|
||||
|
||||
命名空间没有可见性字段——只有 `GLOBAL` 和 `TEAM` 两种类型,
|
||||
技能的可见性是技能自己的属性。因此这里的「私有命名空间」= **一个只有本人为成员的 TEAM 命名空间**。
|
||||
本人拿到的是 `OWNER` 角色(比 `ADMIN` 更强:可以改设置、管成员、删除)。
|
||||
|
||||
如果要做到「别人搜不到这个命名空间」,那是独立的 namespace visibility 特性,不在本次范围内。
|
||||
|
||||
### 触发时机
|
||||
|
||||
在账号**第一次变得可用**时触发,共三处,均发布 `UserActivatedEvent`:
|
||||
|
||||
| 入口 | 位置 |
|
||||
|------|------|
|
||||
| 本地注册 | `LocalAuthService.register` |
|
||||
| 外部身份首次登录 | `IdentityBindingService.bindOrCreate`(仅 `initialStatus == ACTIVE`) |
|
||||
| 管理员审批 / 解封 | `AdminUserAppService.updateUserStatus`(仅从非 ACTIVE 转为 ACTIVE) |
|
||||
|
||||
第三处不可省略:开启了准入审批的部署里,用户在 OAuth 首次尝试时就以 `PENDING` 建号,
|
||||
真正可用是在管理员审批那一刻。
|
||||
|
||||
### 为什么走事件 + AFTER_COMMIT
|
||||
|
||||
`PersonalNamespaceProvisioningListener` 用 `@TransactionalEventListener`
|
||||
(默认 AFTER_COMMIT)并在自己的事务里建命名空间。原因是数据库约束:
|
||||
|
||||
```
|
||||
namespace.created_by REFERENCES user_account(id)
|
||||
namespace_member.user_id REFERENCES user_account(id)
|
||||
```
|
||||
|
||||
- 如果**加入注册事务**:命名空间创建失败(例如 slug 竞态撞唯一约束)会把注册一起回滚,
|
||||
用户会因为「命名空间没建成」而登不上来。
|
||||
- 如果在注册事务中**用 `REQUIRES_NEW` 挂起**:新事务看不到尚未提交的 `user_account` 行,
|
||||
外键检查会阻塞在外层事务的行锁上,形成互等。
|
||||
|
||||
放到提交之后就同时避开了这两点:账号已经落库,建命名空间失败只损失一个命名空间,
|
||||
监听器捕获异常并记 WARN。
|
||||
|
||||
监听器**不加 `@Async`**:命名空间要在用户下一个请求到达前就绪。
|
||||
|
||||
### 命名模板
|
||||
|
||||
两个模板,占位符语法 `${...}`:
|
||||
|
||||
| 占位符 | 取值 |
|
||||
|--------|------|
|
||||
| `${username}` | 认证路径提供的用户名;缺失时依次回落到邮箱前缀、用户 ID |
|
||||
| `${email_prefix}` | 邮箱 `@` 之前的部分 |
|
||||
| `${user_id}` | 平台内部用户 ID |
|
||||
|
||||
未知占位符原样保留,让拼错的名字暴露出来,而不是静默消失。
|
||||
|
||||
slug 模板渲染后按 `SlugValidator` 的规则归一化:转小写、
|
||||
字母数字以外的字符变连字符、去掉首尾与重复连字符。
|
||||
**注意下划线不合法**——`${username}_space` 会得到 `alice-space`。
|
||||
冲突处理:候选 slug 若非法(保留字如 `admin`、长度不足)或已被占用,
|
||||
依次尝试 `-2`、`-3`……最多 64 次;全部失败则跳过并记 WARN。
|
||||
`admin` 这类保留字因此自然落到 `admin-2`。
|
||||
|
||||
幂等:用户若已经拥有任意非 GLOBAL 命名空间,直接跳过。
|
||||
解封会再次发布 `UserActivatedEvent`,靠这条保证不会重复发一个命名空间。
|
||||
|
||||
## 二、配置
|
||||
|
||||
| 位置 | 项 | 默认 |
|
||||
|------|-----|------|
|
||||
| `application.yml` | `skillhub.namespace.personal-provisioning.enabled` | `false` |
|
||||
| 配置文件/环境变量 | 启用开关 | `true` |
|
||||
|
||||
默认只对新激活账号生效,不回填已有账号;如需关闭可设置环境变量。
|
||||
|
||||
模板刻意**不放在 `application.yml`**:它们含 `${...}`,
|
||||
Spring 会当成属性占位符去解析(Boot 3.2 / Framework 6.1 尚不支持转义 `\${`)。
|
||||
模板默认值固定为 `personal-${random}` 和 `${username}-个人空间`,
|
||||
如需关闭可设置 `SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED=false`。
|
||||
220
docs/CONTENT_SAFETY.md
Normal file
220
docs/CONTENT_SAFETY.md
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
# Content Safety Policy
|
||||
|
||||
Last updated: August 18, 2026
|
||||
|
||||
## Purpose and scope
|
||||
|
||||
SkillHub accepts, stores, reviews, and distributes agent skill packages. Packages
|
||||
can contain instructions, scripts, documentation, examples, images, and other files
|
||||
that influence an AI agent or execute on a user's computer. Profiles, namespace
|
||||
descriptions, reviews, reports, ratings, and release notes also contain user-supplied
|
||||
content.
|
||||
|
||||
This document describes the project's content-safety expectations, available
|
||||
technical and governance controls, and the responsibilities of people who publish,
|
||||
review, operate, and install skills.
|
||||
|
||||
The SkillHub maintainers do not operate or moderate every independently hosted
|
||||
instance. Each operator must assess its users, jurisdiction, deployment model, and
|
||||
risk; publish enforceable rules and a reporting channel; configure appropriate
|
||||
controls; and staff its own review, appeal, and emergency processes.
|
||||
|
||||
## Baseline rules
|
||||
|
||||
SkillHub instances should not knowingly publish or distribute content or packages
|
||||
that:
|
||||
|
||||
- violate applicable law or another person's intellectual-property, privacy, or
|
||||
other rights;
|
||||
- sexually exploit or endanger children, including child sexual abuse material;
|
||||
- credibly threaten, harass, or promote violence or hateful abuse against people;
|
||||
- expose personal, confidential, or authentication data without authorization;
|
||||
- contain malware, credential theft, destructive payloads, unauthorized access,
|
||||
persistence, evasion, or instructions intended to defeat security controls;
|
||||
- impersonate people or organizations, facilitate fraud, or intentionally present
|
||||
deceptive or materially misleading claims;
|
||||
- secretly collect, transmit, or use data beyond the skill's documented purpose;
|
||||
- conceal important external services, downloads, commands, permissions, or side
|
||||
effects from reviewers and users; or
|
||||
- bypass an instance's review, scanning, namespace, visibility, or access-control
|
||||
rules.
|
||||
|
||||
Context matters. Legitimate security research, education, documentation, and
|
||||
defensive automation can discuss or test risky behavior without promoting harm.
|
||||
Reviewers should consider purpose, provenance, permissions, likely impact, and
|
||||
applicable law instead of relying on keywords alone.
|
||||
|
||||
## Content and package risks
|
||||
|
||||
A skill may instruct an agent to read or modify files, run commands, call external
|
||||
services, install dependencies, browse websites, or handle sensitive inputs.
|
||||
Documentation and examples may be inaccurate or omit important consequences.
|
||||
Images and archives may contain hidden payloads. Ratings or social signals do not
|
||||
prove that a package is safe, lawful, accurate, or suitable for a particular use.
|
||||
|
||||
Publishers must accurately describe required permissions, external recipients,
|
||||
dependencies, expected side effects, supported environments, and known limitations.
|
||||
Installers must review package contents and apply least privilege before execution.
|
||||
|
||||
## Available technical and governance controls
|
||||
|
||||
SkillHub includes controls that an operator can combine according to its risk:
|
||||
|
||||
- package limits and file-type validation, including size, file-count, extension,
|
||||
and selected file-signature checks;
|
||||
- a configurable security scanner that can inspect uploaded packages and produce
|
||||
findings for reviewers;
|
||||
- namespace and platform review workflows with approve, reject, withdraw, and
|
||||
promotion decisions;
|
||||
- user reports and administrator actions such as hiding, archiving, rejecting, or
|
||||
yanking content and versions;
|
||||
- platform and namespace RBAC for publishing and governance actions;
|
||||
- public, namespace-only, and private visibility; and
|
||||
- audit records and notifications for relevant governance activity.
|
||||
|
||||
The implementation and operating guidance are documented in the
|
||||
[scanner guide](security-scanning.md),
|
||||
[review guide](skillhub/en/guide/review.md), and
|
||||
[security architecture](../document/docs/04-developer/architecture/security.md).
|
||||
|
||||
## Important limitations
|
||||
|
||||
These controls reduce risk but do not certify a package as safe or compliant:
|
||||
|
||||
- scanner operation is configurable, and an operator can run SkillHub without an
|
||||
enabled scanner;
|
||||
- optional LLM-backed analysis depends on the service selected by the operator and
|
||||
can create additional privacy and reliability risks;
|
||||
- static, behavioral, metadata, and model-based analysis can produce false
|
||||
positives and false negatives;
|
||||
- a successfully scanned package can still be misleading, vulnerable, or
|
||||
malicious in context, or unsuitable for a specific environment;
|
||||
- privileged publication paths require operator governance and periodic review;
|
||||
and
|
||||
- independently hosted instances can configure different review, visibility, and
|
||||
enforcement practices.
|
||||
|
||||
Operators must disclose which controls are active. A package that fails scanning
|
||||
or review should remain unavailable for ordinary installation until the failure
|
||||
is resolved through a documented process. Scanner failure must not be treated as
|
||||
proof that a package is safe.
|
||||
|
||||
## Publisher responsibilities
|
||||
|
||||
Before submitting a skill, a publisher should:
|
||||
|
||||
- inspect every included file and remove secrets, personal data, build artifacts,
|
||||
and unrelated binaries;
|
||||
- document commands, network destinations, external downloads, required
|
||||
permissions, and persistent changes;
|
||||
- pin or constrain dependencies where practical and preserve their license notices;
|
||||
- provide evidence for security, compliance, or standards claims instead of relying
|
||||
on labels alone;
|
||||
- test failure and rollback behavior in an isolated environment;
|
||||
- avoid manipulative instructions designed to override system, user, or operator
|
||||
safety controls; and
|
||||
- update or withdraw a package when a material risk is discovered.
|
||||
|
||||
## Operator safeguards
|
||||
|
||||
Before opening an instance to publishers or installers, an operator should:
|
||||
|
||||
- define permitted and prohibited content, reviewers, escalation owners, and
|
||||
emergency contacts;
|
||||
- enable scanning and human review appropriate to the instance's exposure and
|
||||
package risk;
|
||||
- restrict direct-publish and governance roles, log their use, and review them
|
||||
regularly;
|
||||
- isolate scanning and package inspection from production secrets and sensitive
|
||||
networks;
|
||||
- rate-limit uploads, downloads, reports, and automated activity;
|
||||
- preserve only the evidence needed for review and protect reporter identities;
|
||||
- provide a visible reporting channel and an impartial appeal route; and
|
||||
- train reviewers to handle malware, privacy, child-safety, fraud, and
|
||||
intellectual-property reports safely.
|
||||
|
||||
## Reporting
|
||||
|
||||
For a skill or profile visible in a SkillHub instance, use that instance's report
|
||||
feature or contact the operator identified in its published policies. Include the
|
||||
package coordinate and version, the reason for concern, the time observed, and the
|
||||
minimum context needed to investigate. Do not execute a suspected malicious package
|
||||
or resend illegal, exploitative, personal, or confidential material through an
|
||||
unprotected channel.
|
||||
|
||||
For abusive or harassing conduct on SkillHub project-managed community surfaces,
|
||||
report privately to
|
||||
[ifly_opensource@iflytek.com](mailto:ifly_opensource@iflytek.com) under the
|
||||
[Code of Conduct](../CODE_OF_CONDUCT.md). Report upstream security vulnerabilities
|
||||
privately to [security@iflytek.com](mailto:security@iflytek.com) under the
|
||||
[iFLYTEK organization security policy](https://github.com/iflytek/.github/blob/main/SECURITY.md).
|
||||
Do not disclose vulnerabilities or personal data in a public issue.
|
||||
|
||||
## Review, action, and notice
|
||||
|
||||
An operator's documented process should:
|
||||
|
||||
1. triage imminent danger, child-safety concerns, credible malware, exposed
|
||||
credentials, and active security incidents for urgent specialist handling;
|
||||
2. preserve only the evidence needed for a proportionate review;
|
||||
3. assess the package, context, applicable rule, law, provenance, permissions, and
|
||||
likely user impact;
|
||||
4. take proportionate action, such as rejecting a version, hiding or yanking a
|
||||
package, restricting an account, revoking a token, or escalating to an authorized
|
||||
specialist;
|
||||
5. record the rule, evidence, and rationale and notify affected people when lawful
|
||||
and safe; and
|
||||
6. provide an appeal route and use confirmed incidents to improve controls.
|
||||
|
||||
SkillHub's review guide recommends completing routine package reviews within 24
|
||||
hours to avoid blocking publishers. That recommendation is not a historical average
|
||||
for safety reports and is not an emergency-response guarantee. The upstream project
|
||||
does not yet have enough comparable safety reports to publish a meaningful average
|
||||
assessment or action time. Each operator must publish targets appropriate to its
|
||||
risk, staffing, and legal obligations, with an urgent path for imminent harm and
|
||||
child safety.
|
||||
|
||||
## Appeals
|
||||
|
||||
A publisher, reporter, account holder, or other person materially affected by a
|
||||
governance decision should be able to request review through the instance
|
||||
operator's private channel. The request should identify the original decision and
|
||||
give a reason for review, such as significant new evidence, a material procedural
|
||||
error, a conflict of interest, or a clearly disproportionate action.
|
||||
|
||||
Appeals should be handled by a person who did not make the original decision and
|
||||
has no conflict of interest. The reviewer may uphold, modify, or reverse the action,
|
||||
or require a new investigation. Temporary protective measures may remain in place
|
||||
while needed to protect people, systems, evidence, or legal obligations.
|
||||
|
||||
For Code of Conduct decisions on project-managed community surfaces, send an appeal
|
||||
to [ifly_opensource@iflytek.com](mailto:ifly_opensource@iflytek.com) with the
|
||||
subject `SkillHub Code of Conduct appeal`. Include the original case reference,
|
||||
the outcome being challenged, and the reason for review. Appeal information must
|
||||
be limited to people who need it. Retaliation for a good-faith report or appeal
|
||||
is prohibited.
|
||||
|
||||
## Children and young people
|
||||
|
||||
SkillHub is a general-purpose developer and enterprise collaboration tool, not a
|
||||
child-directed service. An operator that permits use by children or processes their
|
||||
data must perform an age-appropriate risk assessment, use any legally required
|
||||
parental or guardian consent, minimize collection and profiling, restrict contact
|
||||
and high-risk package capabilities, provide child-accessible notices and reporting,
|
||||
and route serious concerns to trained personnel and appropriate authorities.
|
||||
|
||||
If those protections cannot be provided, the instance should not be offered to
|
||||
children. The project Code of Conduct separately protects community participation
|
||||
from harassment regardless of age.
|
||||
|
||||
## Privacy and policy review
|
||||
|
||||
Package inspection, reports, audit logs, and investigations can expose sensitive
|
||||
information. They must follow the
|
||||
[Privacy and Data Governance Policy](PRIVACY_AND_DATA_GOVERNANCE.md) and the
|
||||
instance's own privacy notice and retention schedule.
|
||||
|
||||
Material changes to this policy are made through the repository's public review
|
||||
process. Operators should periodically test their controls, review incident trends,
|
||||
and update their policy when the product, threat model, law, or operating context
|
||||
changes.
|
||||
187
docs/PRIVACY_AND_DATA_GOVERNANCE.md
Normal file
187
docs/PRIVACY_AND_DATA_GOVERNANCE.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# Privacy and Data Governance Policy
|
||||
|
||||
Last updated: August 18, 2026
|
||||
|
||||
## Purpose and scope
|
||||
|
||||
SkillHub is open-source software for publishing, reviewing, discovering, and
|
||||
installing reusable agent skill packages. This document describes the project's
|
||||
privacy and data-governance expectations and the controls available to people who
|
||||
operate SkillHub instances.
|
||||
|
||||
The SkillHub maintainers publish source code and project infrastructure. They do
|
||||
not operate or control every independently hosted instance. The organization or
|
||||
person operating an instance determines why and how personal data is processed in
|
||||
that environment and is responsible for publishing an instance-specific privacy
|
||||
notice, selecting lawful processing grounds, handling data-subject requests, and
|
||||
complying with applicable law.
|
||||
|
||||
The public SkillHub service also publishes an in-product
|
||||
[privacy notice](https://skill.xfyun.cn/privacy). This project document supplements
|
||||
that notice for source-code reviewers and self-hosted operators. It is not legal
|
||||
advice and does not certify that every deployment is automatically compliant with
|
||||
any law.
|
||||
|
||||
## Applicable law
|
||||
|
||||
Privacy and data-protection obligations depend on the operator's legal entity,
|
||||
where the instance and its users are located, the people it serves, the data in
|
||||
skill packages, and the infrastructure and integrations selected by the operator.
|
||||
|
||||
Before processing personal data, each operator must:
|
||||
|
||||
- identify and document the domestic and international laws that apply;
|
||||
- determine and record the lawful basis for each material processing purpose;
|
||||
- complete any required privacy, child-safety, security, or transfer assessment;
|
||||
- reflect those obligations in notices, contracts, procedures, and configuration;
|
||||
and
|
||||
- avoid or redesign processing that cannot be operated lawfully.
|
||||
|
||||
Open-source availability and configurable controls support implementation. They
|
||||
do not replace an operator's legal analysis or operational responsibilities.
|
||||
|
||||
## Data the software can process
|
||||
|
||||
The exact data depends on the authentication, storage, email, observability,
|
||||
scanner, and deployment options selected by the operator. A SkillHub instance can
|
||||
process:
|
||||
|
||||
- account and identity data, such as username, email address, avatar, OAuth
|
||||
provider identifiers, account status, platform roles, and namespace membership;
|
||||
- authentication and security data, such as session identifiers, password hashes,
|
||||
API token metadata, login events, IP addresses, device or browser information,
|
||||
and password-reset records;
|
||||
- skill package content, including `SKILL.md`, scripts, documentation, images,
|
||||
examples, license files, archives, and version metadata;
|
||||
- collaboration and governance data, such as namespaces, reviews, review comments,
|
||||
promotion requests, reports, ratings, stars, notifications, and audit records;
|
||||
- usage and operational data, such as searches, downloads, request identifiers,
|
||||
timestamps, errors, metrics, traces, application logs, and security findings;
|
||||
and
|
||||
- configuration and connection data for object storage, identity providers, email,
|
||||
monitoring, and optional scanning services.
|
||||
|
||||
A skill package, review comment, profile, log entry, or security report can contain
|
||||
personal, confidential, or authentication data even when a field is not labelled
|
||||
as personal data. Operators and publishers must classify data according to its
|
||||
actual content and use.
|
||||
|
||||
## Roles and responsibilities
|
||||
|
||||
For an independently operated instance, the instance operator normally decides the
|
||||
purposes and means of processing and must document its role under applicable law.
|
||||
Publishers and namespace administrators are responsible for the content they upload
|
||||
and the access decisions they make. External identity, storage, email, monitoring,
|
||||
and scanner providers may process data under their own terms and assigned roles.
|
||||
|
||||
The upstream SkillHub maintainers generally cannot access, correct, export, or
|
||||
delete data held by an independently operated instance. Requests concerning an
|
||||
instance must go to the operator identified in that instance's privacy notice.
|
||||
|
||||
## Purpose limitation and data minimization
|
||||
|
||||
Data should be collected only when needed to authenticate users, enforce access
|
||||
rules, publish and distribute skill packages, operate review and governance
|
||||
workflows, secure and troubleshoot the instance, and meet documented legal duties.
|
||||
|
||||
Operators, administrators, and publishers should:
|
||||
|
||||
- avoid placing secrets or unnecessary personal data in skill packages, README
|
||||
files, examples, namespace profiles, reviews, or report details;
|
||||
- use pseudonymous or organization-scoped identifiers where practical;
|
||||
- configure the shortest retention and least visibility needed for each purpose;
|
||||
- redact sensitive values before sending packages or findings to an external
|
||||
scanner, model, log sink, or support channel;
|
||||
- restrict privileged roles and review them regularly; and
|
||||
- document the source, purpose, recipients, lawful basis, and retention period for
|
||||
each material category of personal data.
|
||||
|
||||
## Storage, access, and isolation
|
||||
|
||||
SkillHub supports authenticated access, platform and namespace RBAC, public,
|
||||
namespace-only and private visibility, audit logs, hashed API tokens, PostgreSQL,
|
||||
Redis, and local or S3-compatible object storage. These capabilities are building
|
||||
blocks, not a secure deployment by themselves.
|
||||
|
||||
Operators are responsible for:
|
||||
|
||||
- disabling development authentication and replacing example credentials before
|
||||
exposing an instance;
|
||||
- using HTTPS for external traffic and protected networks for internal services;
|
||||
- applying least-privilege roles to users, services, databases, caches, and object
|
||||
stores;
|
||||
- encrypting sensitive data and backups according to their threat model and legal
|
||||
obligations;
|
||||
- storing secrets in an appropriate secrets manager rather than source code, skill
|
||||
packages, client-side configuration, or logs;
|
||||
- testing namespace and object-storage isolation for their configuration;
|
||||
- restricting and monitoring access to packages, audit data, logs, traces, backups,
|
||||
and security findings; and
|
||||
- applying supported security updates and maintaining a recovery process.
|
||||
|
||||
## External services and international transfers
|
||||
|
||||
OAuth providers, S3-compatible storage, email services, monitoring systems,
|
||||
mirrors, and optional scanner integrations can receive data from a SkillHub
|
||||
instance. An operator that enables an external service must assess its privacy and
|
||||
security terms, hosting locations, retention, subprocessors, training-data rules,
|
||||
and cross-border transfer mechanism.
|
||||
|
||||
The optional scanner can process uploaded skill archives and findings. If an
|
||||
operator enables an external or LLM-backed scanner, that disclosure must be covered
|
||||
by the instance's privacy notice and data-flow review. A service does not become
|
||||
private merely because SkillHub can integrate with it.
|
||||
|
||||
## Retention, deletion, and portability
|
||||
|
||||
The open-source project does not impose one retention period on independently
|
||||
operated instances. Each operator must publish periods that are no longer than
|
||||
necessary for its purposes and legal duties.
|
||||
|
||||
A deletion process should cover account and namespace records, package objects,
|
||||
reviews, reports, ratings, notifications, security findings, caches, audit records,
|
||||
logs, traces, exports, and backups. Where immediate backup deletion is not
|
||||
practical, deleted data should be isolated from normal use and expire under a
|
||||
documented schedule. Operators must also account for copies already disclosed to
|
||||
external providers or downloaded by authorized users.
|
||||
|
||||
Operators should provide authenticated channels for access, correction, deletion,
|
||||
restriction, objection, and portability requests where applicable. Requests should
|
||||
be verified, recorded, completed within legally required time limits, and denied
|
||||
only on a documented basis.
|
||||
|
||||
Skill packages can be downloaded as archives and installed through the CLI. Public
|
||||
APIs expose package and metadata workflows. Operators should separately document
|
||||
which instance records can be imported or exported, their non-proprietary formats,
|
||||
version compatibility, and any PII excluded from an export.
|
||||
|
||||
## Security and incident handling
|
||||
|
||||
Security vulnerabilities in the upstream project must be reported under the public
|
||||
[iFLYTEK organization security policy](https://github.com/iflytek/.github/blob/main/SECURITY.md)
|
||||
and its detailed
|
||||
[community security policy](https://github.com/iflytek/community/blob/master/SECURITY.md).
|
||||
Send vulnerability details privately to
|
||||
[security@iflytek.com](mailto:security@iflytek.com), not in a public issue.
|
||||
|
||||
Instance operators remain responsible for monitoring their environments,
|
||||
maintaining an incident-response plan, preserving proportionate evidence, rotating
|
||||
affected credentials, applying fixes, and notifying users or authorities where
|
||||
required.
|
||||
|
||||
## Project and instance contacts
|
||||
|
||||
- Report an upstream security vulnerability privately to
|
||||
[security@iflytek.com](mailto:security@iflytek.com).
|
||||
- Send questions about this project policy to
|
||||
[ifly_opensource@iflytek.com](mailto:ifly_opensource@iflytek.com). Do not include
|
||||
personal data or confidential incident details in a public GitHub issue.
|
||||
- Contact the operator named in an instance's privacy notice for data-subject
|
||||
requests or incidents involving that instance.
|
||||
|
||||
## Governance and changes
|
||||
|
||||
Privacy-impacting changes should be reviewed for data minimization, access and
|
||||
namespace boundaries, package visibility, external disclosures, retention,
|
||||
logging, deletion, and portability. Material changes to this document are made
|
||||
through the repository's public review process, and the file history records them.
|
||||
51
docs/RISCV64.md
Normal file
51
docs/RISCV64.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# RISC-V (`linux/riscv64`) support
|
||||
|
||||
## Current scope
|
||||
|
||||
RISC-V support is incremental. The SkillHub server and web images have
|
||||
`linux/riscv64` build and runtime paths. The security scanner and the complete
|
||||
Docker Compose deployment are not yet supported on RISC-V.
|
||||
|
||||
| Component | `linux/riscv64` status | Notes |
|
||||
| --- | --- | --- |
|
||||
| `skillhub-server` | Supported | The architecture-neutral Java 21 JAR is built on the Buildx host and copied into the target-architecture Eclipse Temurin runtime. |
|
||||
| `skillhub-web` | Supported | Static assets are built on the Buildx host and served by a target-architecture Nginx runtime. |
|
||||
| `skillhub-scanner` | Not yet verified | Its Python dependency tree still needs a native-extension and runtime audit. |
|
||||
| PostgreSQL 16 and Redis 7 | Upstream images available | Keep these images explicitly pinned and verify them on the target board before production use. |
|
||||
| Complete Compose stack | Unsupported | `compose.release.yml` starts the unverified scanner, so do not deploy it unchanged on RISC-V. |
|
||||
|
||||
## Build the supported images
|
||||
|
||||
Buildx can create both images from an AMD64 or ARM64 host. Register a RISC-V
|
||||
QEMU handler before running these commands when the host is not RISC-V:
|
||||
|
||||
```bash
|
||||
docker run --privileged --rm tonistiigi/binfmt --install riscv64
|
||||
docker buildx create --use --name skillhub-riscv64
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/riscv64 \
|
||||
--file server/Dockerfile \
|
||||
--tag skillhub-server:riscv64 \
|
||||
--load \
|
||||
server
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/riscv64 \
|
||||
--file web/Dockerfile \
|
||||
--tag skillhub-web:riscv64 \
|
||||
--load \
|
||||
web
|
||||
```
|
||||
|
||||
The release workflow publishes `linux/amd64`, `linux/arm64`, and
|
||||
`linux/riscv64` variants for `skillhub-server` and `skillhub-web`. The scanner
|
||||
remains limited to its existing AMD64/ARM64 platform list.
|
||||
|
||||
## Verification boundary
|
||||
|
||||
The pull-request workflow builds both supported target images, checks their OCI
|
||||
architecture metadata, and executes the Java and Nginx runtimes under RISC-V
|
||||
emulation. This is a component-image guardrail, not a full-stack integration
|
||||
test. A native RISC-V smoke test with PostgreSQL, Redis, object storage, and a
|
||||
verified scanner remains required before claiming complete deployment support.
|
||||
14
docs/skillhub/package-lock.json
generated
14
docs/skillhub/package-lock.json
generated
|
|
@ -2065,9 +2065,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2123,9 +2123,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2143,7 +2143,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.17",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
},
|
||||
"overrides": {
|
||||
"vite": "^6.4.3",
|
||||
"postcss": "^8.5.10",
|
||||
"postcss": "^8.5.23",
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,17 @@ description: 常见问题解答
|
|||
3. 是否包含必需的 SKILL.md
|
||||
4. SKILL.md frontmatter 格式是否正确
|
||||
|
||||
### 使用 CLI 安装技能时报 `namespace not found`?
|
||||
|
||||
多数情况是 CLI 没有指向你自己的 SkillHub 实例,或命名空间格式不对:
|
||||
|
||||
1. **配置 registry 并登录**:用环境变量或 `--registry` 指向你的实例,例如
|
||||
`clawhub --registry https://skillhub.your-company.com install <skill>`;登录需要先在 Web 控制台生成 API Token。
|
||||
2. **命名空间 slug 格式**:全局命名空间的技能直接用名字(如 `my-skill`);团队命名空间要用 `team--skill` 的形式(`@team/skill` → `team--skill`)。
|
||||
3. 最稳妥的方式是直接在 SkillHub Web 界面点技能的「安装」按钮,复制其中已经带好正确 registry 与命名空间的命令。
|
||||
|
||||
> SkillHub 同时提供 `clawhub` 和 `skillhub` 两种 CLI,用法见各自 README;通过 OpenClaw 对话安装技能时,底层同样调用 CLI。
|
||||
|
||||
## 开发相关
|
||||
|
||||
### 如何扩展 OAuth Provider?
|
||||
|
|
|
|||
|
|
@ -22,6 +22,17 @@ description: 常见问题诊断和解决方案
|
|||
- Redis 连接失败
|
||||
- 环境变量缺失
|
||||
|
||||
### PostgreSQL 容器启动报 `operation not permitted`(写 `postmaster.pid` / `pg_wal` 失败)
|
||||
|
||||
SkillHub 默认的 Compose / `runtime.sh` 使用 Docker named volume(`postgres_data`),通常不需要手工处理宿主机目录权限。这个错误更多出现在你把 PostgreSQL 数据目录改成宿主机 bind mount 时,例如 `/data/skillhub/postgres:/var/lib/postgresql/data`。
|
||||
|
||||
排查顺序:
|
||||
|
||||
1. 优先恢复为 Docker named volume,或直接使用官方 `runtime.sh` 部署脚本,避免手写 compose 时漏配权限。
|
||||
2. 如果必须使用 bind mount,先确认当前镜像中的 `postgres` 用户 UID/GID:`docker run --rm postgres:16-alpine id postgres`,再按实际 UID/GID 调整数据目录属主,例如 `chown -R <uid>:<gid> <数据目录>`。不要固定假设所有环境都是 `999:999`。
|
||||
3. 在 RHEL/CentOS 上检查 SELinux;在启用 AppArmor、rootless Docker、NFS/CIFS/NAS 等环境时,也要确认宿主文件系统是否允许 PostgreSQL 需要的写入、锁和权限变更。
|
||||
4. 不建议把 PostgreSQL `PGDATA` 放在不支持完整 POSIX 权限语义的网络文件系统上;生产环境优先使用本地盘、Docker named volume、块存储或外部 PostgreSQL。
|
||||
|
||||
## 上传失败
|
||||
|
||||
### 技能包上传失败
|
||||
|
|
|
|||
|
|
@ -34,6 +34,16 @@ Check:
|
|||
3. Whether required SKILL.md is included
|
||||
4. Whether SKILL.md frontmatter format is correct
|
||||
|
||||
### CLI install reports `namespace not found`?
|
||||
|
||||
Usually the CLI is not pointing at your own SkillHub instance, or the namespace format is wrong:
|
||||
|
||||
1. **Set the registry and log in**: point at your instance via an environment variable or `--registry`, e.g. `clawhub --registry https://skillhub.your-company.com install <skill>`. Logging in requires an API Token generated in the web console first.
|
||||
2. **Namespace slug format**: skills in the global namespace use the bare name (e.g. `my-skill`); team namespaces use the `team--skill` form (`@team/skill` → `team--skill`).
|
||||
3. The most reliable way is to click the **Install** button on the skill's page in the SkillHub web UI and copy the command, which already includes the correct registry and namespace.
|
||||
|
||||
> SkillHub ships both a `clawhub` and a `skillhub` CLI (see their respective READMEs); installing a skill through an OpenClaw conversation calls the CLI under the hood as well.
|
||||
|
||||
## Development Related
|
||||
|
||||
### How to extend OAuth Provider?
|
||||
|
|
|
|||
|
|
@ -22,6 +22,17 @@ description: Common problem diagnosis and solutions
|
|||
- Redis connection failed
|
||||
- Environment variables missing
|
||||
|
||||
### PostgreSQL container fails to start with `operation not permitted` (cannot write `postmaster.pid` / `pg_wal`)
|
||||
|
||||
SkillHub's default Compose / `runtime.sh` deployment uses a Docker named volume (`postgres_data`), so you normally do not need to manage host directory permissions manually. This error is more common after changing PostgreSQL storage to a host bind mount, for example `/data/skillhub/postgres:/var/lib/postgresql/data`.
|
||||
|
||||
Recommended checks:
|
||||
|
||||
1. Prefer switching back to a Docker named volume, or use the official `runtime.sh` deployment script to avoid permission gaps from hand-written compose files.
|
||||
2. If you must use a bind mount, first check the `postgres` UID/GID in the image you run: `docker run --rm postgres:16-alpine id postgres`. Then change the data directory owner to the actual UID/GID, for example `chown -R <uid>:<gid> <data-dir>`. Do not assume every environment is `999:999`.
|
||||
3. On RHEL/CentOS, check SELinux. If AppArmor, rootless Docker, NFS/CIFS/NAS, or another restricted filesystem is involved, also verify that PostgreSQL can write, lock files, and change permissions as required.
|
||||
4. Avoid putting PostgreSQL `PGDATA` on network filesystems that do not provide full POSIX permission semantics. For production, prefer local disks, Docker named volumes, block storage, or an external PostgreSQL service.
|
||||
|
||||
## Upload Failed
|
||||
|
||||
### Skill Package Upload Failed
|
||||
|
|
|
|||
85
examples/python/README.md
Normal file
85
examples/python/README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# SkillHub Python Examples
|
||||
|
||||
A minimal, dependency-light (`requests`-only) Python client and runnable
|
||||
examples for the SkillHub REST API. Use it to **search, inspect, download,
|
||||
and publish** skills from Python — the same operations the ClawHub CLI
|
||||
performs, without shelling out to the CLI.
|
||||
|
||||
> These are reference examples, not (yet) an officially published pip
|
||||
> package. See [iflytek/skillhub#701](https://github.com/iflytek/skillhub/issues/701)
|
||||
> for the discussion on whether to ship a full published SDK.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
|------|------------|
|
||||
| [`skillhub_client.py`](./skillhub_client.py) | A small `SkillHubClient` class wrapping the REST API |
|
||||
| [`example_usage.py`](./example_usage.py) | Runnable script: search → resolve → download, and publish |
|
||||
| [`requirements.txt`](./requirements.txt) | The only dependency: `requests` |
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Point at your SkillHub instance
|
||||
export SKILLHUB_URL=https://skill.example.com
|
||||
# Only needed for write operations (publish / star / rate)
|
||||
export SKILLHUB_TOKEN=<your-api-token>
|
||||
```
|
||||
|
||||
Generate an API token from the SkillHub web UI (**Settings → API Tokens**) or
|
||||
via `POST /api/v1/tokens`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```python
|
||||
from skillhub_client import SkillHubClient
|
||||
|
||||
client = SkillHubClient() # reads SKILLHUB_URL / SKILLHUB_TOKEN from env
|
||||
|
||||
# Search public skills
|
||||
results = client.search(keyword="email", size=5)
|
||||
|
||||
# Inspect and resolve a version
|
||||
detail = client.get_skill("my-namespace", "my-skill")
|
||||
resolved = client.resolve("my-namespace", "my-skill", tag="stable")
|
||||
|
||||
# Download the latest package (returns the written file path)
|
||||
path = client.download("my-namespace", "my-skill")
|
||||
|
||||
# Publish a skill package (requires a token)
|
||||
client.publish("./my-skill.zip", namespace="my-namespace")
|
||||
```
|
||||
|
||||
Or run the end-to-end script:
|
||||
|
||||
```bash
|
||||
python example_usage.py # search + inspect + download
|
||||
python example_usage.py publish ./my-skill.zip my-namespace
|
||||
```
|
||||
|
||||
## Supported operations
|
||||
|
||||
| Method | Endpoint | Auth |
|
||||
|--------|----------|------|
|
||||
| `search(keyword, namespace, page, size)` | `GET /api/v1/skills` | — |
|
||||
| `get_skill(namespace, slug)` | `GET /api/v1/skills/{ns}/{slug}` | — |
|
||||
| `list_versions(namespace, slug)` | `GET /api/v1/skills/{ns}/{slug}/versions` | — |
|
||||
| `resolve(namespace, slug, version, tag)` | `GET /api/v1/skills/{ns}/{slug}/resolve` | — |
|
||||
| `download(namespace, slug, version, dest)` | `GET /api/v1/skills/{ns}/{slug}[/versions/{v}]/download` | — |
|
||||
| `whoami()` | `GET /api/v1/whoami` | Bearer |
|
||||
| `publish(zip_path, namespace, request_id)` | `POST /api/v1/publish` | Bearer |
|
||||
| `star(namespace, slug)` | `POST /api/v1/skills/{ns}/{slug}/star` | Bearer |
|
||||
| `rate(namespace, slug, score)` | `POST /api/v1/skills/{ns}/{slug}/rating` | Bearer |
|
||||
|
||||
The client unwraps the unified `{code, msg, data}` response envelope
|
||||
automatically and raises `SkillHubError` on a non-zero business code.
|
||||
|
||||
## Notes
|
||||
|
||||
- Write operations accept an optional `request_id` (a UUID) that is sent as
|
||||
the `X-Request-Id` header for idempotency.
|
||||
- For the full API surface (namespaces, reviews, promotion, tags), see the
|
||||
[Developer Docs → API](https://iflytek.github.io/skillhub/) and
|
||||
[`document/docs/04-developer/api`](../../document/docs/04-developer/api).
|
||||
90
examples/python/example_usage.py
Normal file
90
examples/python/example_usage.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Runnable examples for the SkillHub Python client.
|
||||
|
||||
Configure the target registry via environment variables:
|
||||
|
||||
export SKILLHUB_URL=https://skill.example.com
|
||||
export SKILLHUB_TOKEN=<your-api-token> # only needed for write operations
|
||||
|
||||
Then run:
|
||||
|
||||
python example_usage.py # search + inspect + download
|
||||
python example_usage.py publish ./my-skill.zip my-namespace
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from skillhub_client import SkillHubClient, SkillHubError
|
||||
|
||||
|
||||
def _pick(obj, *keys, default=None):
|
||||
"""Best-effort field access across slightly different response shapes."""
|
||||
for key in keys:
|
||||
if isinstance(obj, dict) and obj.get(key) is not None:
|
||||
return obj[key]
|
||||
return default
|
||||
|
||||
|
||||
def demo_read(client: SkillHubClient) -> None:
|
||||
print(f"Searching {client.base_url} for skills matching 'email'...\n")
|
||||
result = client.search(keyword="email", size=5)
|
||||
|
||||
# The search payload may expose the list under 'items' or 'results'.
|
||||
items = _pick(result, "items", "results", default=result if isinstance(result, list) else [])
|
||||
if not items:
|
||||
print("No skills found. Try a different keyword or registry.")
|
||||
return
|
||||
|
||||
for skill in items:
|
||||
name = _pick(skill, "name", "slug", default="(unnamed)")
|
||||
ns = _pick(skill, "namespace", default="")
|
||||
version = _pick(skill, "version", "latestVersion", default="?")
|
||||
downloads = _pick(skill, "downloadCount", "downloads", default=0)
|
||||
coord = f"{ns}/{name}" if ns else name
|
||||
print(f" - {coord} v{version} ({downloads} downloads)")
|
||||
|
||||
# Download the first result's latest package.
|
||||
first = items[0]
|
||||
ns = _pick(first, "namespace", default="")
|
||||
slug = _pick(first, "slug", "name")
|
||||
if ns and slug:
|
||||
print(f"\nResolving latest version of {ns}/{slug}...")
|
||||
resolved = client.resolve(ns, slug)
|
||||
version = _pick(resolved, "version", default=None)
|
||||
print(f" resolved version: {version}")
|
||||
|
||||
dest = client.download(ns, slug, version=version)
|
||||
size = os.path.getsize(dest)
|
||||
print(f" downloaded -> {dest} ({size} bytes)")
|
||||
|
||||
|
||||
def demo_publish(client: SkillHubClient, zip_path: str, namespace: str) -> None:
|
||||
if not client.token:
|
||||
sys.exit("Publishing requires SKILLHUB_TOKEN to be set.")
|
||||
print(f"Publishing {zip_path} to namespace '{namespace}'...")
|
||||
result = client.publish(zip_path, namespace)
|
||||
print(f" published: {result}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
client = SkillHubClient()
|
||||
except ValueError as exc:
|
||||
sys.exit(str(exc))
|
||||
|
||||
args = sys.argv[1:]
|
||||
try:
|
||||
if args and args[0] == "publish":
|
||||
if len(args) != 3:
|
||||
sys.exit("usage: python example_usage.py publish <zip_path> <namespace>")
|
||||
demo_publish(client, args[1], args[2])
|
||||
else:
|
||||
demo_read(client)
|
||||
except SkillHubError as exc:
|
||||
sys.exit(f"API error: {exc}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
examples/python/requirements.txt
Normal file
1
examples/python/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
requests>=2.25
|
||||
244
examples/python/skillhub_client.py
Normal file
244
examples/python/skillhub_client.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"""A minimal Python client for the SkillHub REST API.
|
||||
|
||||
This is a dependency-light reference client (only ``requests``) that mirrors
|
||||
the operations the ClawHub CLI performs: search, inspect, resolve, download
|
||||
and publish skills. It is meant as a copy-pasteable starting point for Python
|
||||
integrations, not (yet) an officially published package.
|
||||
|
||||
API reference: https://iflytek.github.io/skillhub/ (Developer Docs -> API)
|
||||
|
||||
Endpoints used (see docs/04-developer/api):
|
||||
Public (no auth):
|
||||
GET /api/v1/skills?keyword=&namespace=&page=&size=
|
||||
GET /api/v1/skills/{namespace}/{slug}
|
||||
GET /api/v1/skills/{namespace}/{slug}/versions
|
||||
GET /api/v1/skills/{namespace}/{slug}/resolve?version=&tag=
|
||||
GET /api/v1/skills/{namespace}/{slug}/download
|
||||
GET /api/v1/skills/{namespace}/{slug}/versions/{version}/download
|
||||
Authenticated (Bearer token):
|
||||
GET /api/v1/whoami
|
||||
POST /api/v1/publish (multipart: file, namespace)
|
||||
POST /api/v1/skills/{namespace}/{slug}/star
|
||||
POST /api/v1/skills/{namespace}/{slug}/rating (json: {"score": 1-5})
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class SkillHubError(RuntimeError):
|
||||
"""Raised when the API returns a non-zero business code."""
|
||||
|
||||
def __init__(self, code: Any, message: str, request_id: Optional[str] = None):
|
||||
self.code = code
|
||||
self.request_id = request_id
|
||||
super().__init__(f"SkillHub API error {code}: {message}"
|
||||
+ (f" (requestId={request_id})" if request_id else ""))
|
||||
|
||||
|
||||
class SkillHubClient:
|
||||
"""Thin wrapper over the SkillHub REST API.
|
||||
|
||||
Args:
|
||||
base_url: Registry base URL, e.g. ``https://skill.example.com``.
|
||||
token: Optional API token for authenticated calls (Bearer).
|
||||
timeout: Per-request timeout in seconds.
|
||||
session: Optional pre-configured ``requests.Session``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
timeout: int = 30,
|
||||
session: Optional[requests.Session] = None,
|
||||
):
|
||||
base_url = base_url or os.environ.get("SKILLHUB_URL")
|
||||
if not base_url:
|
||||
raise ValueError(
|
||||
"base_url is required (pass it explicitly or set SKILLHUB_URL)"
|
||||
)
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token or os.environ.get("SKILLHUB_TOKEN")
|
||||
self.timeout = timeout
|
||||
self.session = session or requests.Session()
|
||||
|
||||
# -- internals -------------------------------------------------------
|
||||
|
||||
def _headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
||||
headers: Dict[str, str] = {}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
if extra:
|
||||
headers.update(extra)
|
||||
return headers
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.base_url}{path}"
|
||||
|
||||
def _unwrap(self, resp: requests.Response) -> Any:
|
||||
"""Return the payload, unwrapping the ``{code,msg,data}`` envelope.
|
||||
|
||||
Native ``/api/v1`` endpoints wrap responses in a unified envelope,
|
||||
while the CLI-compat endpoints return the object directly. This
|
||||
handles both.
|
||||
"""
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
if isinstance(payload, dict) and "code" in payload and "data" in payload:
|
||||
if payload.get("code") not in (0, None):
|
||||
raise SkillHubError(
|
||||
payload.get("code"), payload.get("msg", ""), payload.get("requestId")
|
||||
)
|
||||
return payload["data"]
|
||||
return payload
|
||||
|
||||
# -- public API ------------------------------------------------------
|
||||
|
||||
def search(
|
||||
self,
|
||||
keyword: Optional[str] = None,
|
||||
namespace: Optional[str] = None,
|
||||
page: int = 1,
|
||||
size: int = 20,
|
||||
) -> Any:
|
||||
"""Search public skills."""
|
||||
params = {"keyword": keyword, "namespace": namespace, "page": page, "size": size}
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
return self._unwrap(
|
||||
self.session.get(
|
||||
self._url("/api/v1/skills"),
|
||||
params=params,
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def get_skill(self, namespace: str, slug: str) -> Any:
|
||||
"""Fetch a single skill's detail."""
|
||||
return self._unwrap(
|
||||
self.session.get(
|
||||
self._url(f"/api/v1/skills/{namespace}/{slug}"),
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def list_versions(self, namespace: str, slug: str) -> Any:
|
||||
"""List all versions of a skill."""
|
||||
return self._unwrap(
|
||||
self.session.get(
|
||||
self._url(f"/api/v1/skills/{namespace}/{slug}/versions"),
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
namespace: str,
|
||||
slug: str,
|
||||
version: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Resolve a version constraint / tag to a concrete version."""
|
||||
params = {"version": version, "tag": tag}
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
return self._unwrap(
|
||||
self.session.get(
|
||||
self._url(f"/api/v1/skills/{namespace}/{slug}/resolve"),
|
||||
params=params,
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def download(
|
||||
self,
|
||||
namespace: str,
|
||||
slug: str,
|
||||
version: Optional[str] = None,
|
||||
dest: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Download a skill package (zip). Returns the written file path.
|
||||
|
||||
If ``version`` is omitted the ``latest`` package is downloaded. If
|
||||
``dest`` is omitted a file named ``{slug}-{version}.zip`` (or
|
||||
``{slug}.zip``) is written to the current directory.
|
||||
"""
|
||||
if version:
|
||||
path = f"/api/v1/skills/{namespace}/{slug}/versions/{version}/download"
|
||||
else:
|
||||
path = f"/api/v1/skills/{namespace}/{slug}/download"
|
||||
if dest is None:
|
||||
dest = f"{slug}-{version}.zip" if version else f"{slug}.zip"
|
||||
with self.session.get(
|
||||
self._url(path), headers=self._headers(), timeout=self.timeout, stream=True
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(dest, "wb") as fh:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
fh.write(chunk)
|
||||
return dest
|
||||
|
||||
# -- authenticated API ----------------------------------------------
|
||||
|
||||
def whoami(self) -> Any:
|
||||
"""Return the authenticated principal (requires a token)."""
|
||||
return self._unwrap(
|
||||
self.session.get(
|
||||
self._url("/api/v1/whoami"),
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def publish(
|
||||
self, zip_path: str, namespace: str, request_id: Optional[str] = None
|
||||
) -> Any:
|
||||
"""Publish a skill package (zip) to a namespace. Requires a token.
|
||||
|
||||
Pass ``request_id`` (a UUID) to make the publish idempotent via the
|
||||
``X-Request-Id`` header.
|
||||
"""
|
||||
extra = {"X-Request-Id": request_id} if request_id else None
|
||||
with open(zip_path, "rb") as fh:
|
||||
files = {"file": (os.path.basename(zip_path), fh, "application/zip")}
|
||||
data = {"namespace": namespace}
|
||||
return self._unwrap(
|
||||
self.session.post(
|
||||
self._url("/api/v1/publish"),
|
||||
files=files,
|
||||
data=data,
|
||||
headers=self._headers(extra),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def star(self, namespace: str, slug: str) -> Any:
|
||||
"""Star a skill. Requires a token."""
|
||||
return self._unwrap(
|
||||
self.session.post(
|
||||
self._url(f"/api/v1/skills/{namespace}/{slug}/star"),
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def rate(self, namespace: str, slug: str, score: int) -> Any:
|
||||
"""Rate a skill from 1 to 5. Requires a token."""
|
||||
if not 1 <= score <= 5:
|
||||
raise ValueError("score must be between 1 and 5")
|
||||
return self._unwrap(
|
||||
self.session.post(
|
||||
self._url(f"/api/v1/skills/{namespace}/{slug}/rating"),
|
||||
json={"score": score},
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
|
@ -2,16 +2,18 @@
|
|||
set -euo pipefail
|
||||
|
||||
BASE_URL="${1:-http://localhost:8080}"
|
||||
ACTUATOR_BASE_URL="${ACTUATOR_BASE_URL:-$BASE_URL}"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
COOKIE_JAR="$(mktemp)"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
COOKIE_JAR="$TMP_DIR/cookies"
|
||||
USERNAME="smoketest_$(date +%s)"
|
||||
EMAIL="${USERNAME}@example.com"
|
||||
PASSWORD="Smoke@2026"
|
||||
NEW_PASSWORD="Smoke@2027"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$COOKIE_JAR"
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
|
@ -31,6 +33,56 @@ check() {
|
|||
fi
|
||||
}
|
||||
|
||||
check_health() {
|
||||
local desc="$1"
|
||||
local url="$2"
|
||||
local body_file="$TMP_DIR/health-body"
|
||||
local result
|
||||
local status
|
||||
local content_type
|
||||
result="$(curl --retry 3 --retry-delay 1 --max-time 10 -sS -o "$body_file" \
|
||||
-w "%{http_code}|%{content_type}" "$url" || true)"
|
||||
status="${result%%|*}"
|
||||
content_type="${result#*|}"
|
||||
|
||||
if [[ "$content_type" == text/html* ]]; then
|
||||
echo "FAIL: $desc (routing/target error: received $content_type from $url)"
|
||||
FAIL=$((FAIL + 1))
|
||||
elif [[ "$status" == "200" \
|
||||
&& ( "$content_type" == application/json* || "$content_type" == application/*+json* ) \
|
||||
&& -f "$body_file" \
|
||||
&& "$(grep -Ec '"status"[[:space:]]*:' "$body_file" || true)" -gt 0 ]]; then
|
||||
echo "PASS: $desc (HTTP $status, $content_type)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL: $desc (expected HTTP 200 actuator JSON, got HTTP $status, ${content_type:-no content type})"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
check_protected_actuator() {
|
||||
local desc="$1"
|
||||
local url="$2"
|
||||
local result
|
||||
local status
|
||||
local content_type
|
||||
result="$(curl --retry 3 --retry-delay 1 --max-time 10 -sS -o /dev/null \
|
||||
-w "%{http_code}|%{content_type}" "$url" || true)"
|
||||
status="${result%%|*}"
|
||||
content_type="${result#*|}"
|
||||
|
||||
if [[ "$content_type" == text/html* ]]; then
|
||||
echo "FAIL: $desc (routing/target error: received $content_type from $url)"
|
||||
FAIL=$((FAIL + 1))
|
||||
elif [[ "$status" == "401" ]]; then
|
||||
echo "PASS: $desc (HTTP $status)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL: $desc (expected 401, got $status)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
finish() {
|
||||
echo
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
|
|
@ -38,11 +90,12 @@ finish() {
|
|||
}
|
||||
|
||||
echo "=== SkillHub Smoke Test ==="
|
||||
echo "Target: $BASE_URL"
|
||||
echo "API target: $BASE_URL"
|
||||
echo "Actuator target: $ACTUATOR_BASE_URL"
|
||||
echo
|
||||
|
||||
check "Health endpoint" "$BASE_URL/actuator/health" "200"
|
||||
check "Prometheus metrics requires auth" "$BASE_URL/actuator/prometheus" "401"
|
||||
check_health "Health endpoint" "$ACTUATOR_BASE_URL/actuator/health"
|
||||
check_protected_actuator "Prometheus metrics requires auth" "$ACTUATOR_BASE_URL/actuator/prometheus"
|
||||
check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401"
|
||||
check "Auth required" "$BASE_URL/api/v1/auth/me" "401"
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ data=""
|
|||
cookie_in=""
|
||||
cookie_out=""
|
||||
write_code=false
|
||||
write_format=""
|
||||
output_file=""
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
|
|
@ -47,6 +48,7 @@ while (($#)); do
|
|||
;;
|
||||
-w)
|
||||
write_code=true
|
||||
write_format="$2"
|
||||
shift 2
|
||||
;;
|
||||
-o)
|
||||
|
|
@ -76,8 +78,16 @@ fi
|
|||
printf '%s\n' "$method $url $data" >>"${SMOKE_CURL_LOG:?SMOKE_CURL_LOG is required}"
|
||||
|
||||
status=200
|
||||
content_type="application/json"
|
||||
body='{}'
|
||||
case "$url" in
|
||||
*/actuator/health) status=200 ;;
|
||||
https://public.example/actuator/health|https://public.example/actuator/prometheus)
|
||||
content_type="text/html"
|
||||
body='<html>SkillHub</html>'
|
||||
;;
|
||||
*/actuator/health)
|
||||
body='{"status":"UP"}'
|
||||
;;
|
||||
*/actuator/prometheus) status=401 ;;
|
||||
*/api/v1/namespaces)
|
||||
if [[ -n "$cookie_in" && -f "$cookie_in.session" ]]; then status=200; else status=401; fi
|
||||
|
|
@ -108,24 +118,35 @@ case "$url" in
|
|||
esac
|
||||
|
||||
if [[ "$output_file" != "/dev/null" && -n "$output_file" ]]; then
|
||||
printf '{}\n' >"$output_file"
|
||||
printf '%s\n' "$body" >"$output_file"
|
||||
fi
|
||||
if [[ "$write_code" == true ]]; then
|
||||
printf '%s' "$status"
|
||||
if [[ "$write_format" == *content_type* ]]; then
|
||||
printf '%s|%s' "$status" "$content_type"
|
||||
else
|
||||
printf '%s' "$status"
|
||||
fi
|
||||
fi
|
||||
EOF
|
||||
chmod +x "$TMP_DIR/bin/curl"
|
||||
|
||||
run_smoke() {
|
||||
run_smoke_at() {
|
||||
local name="$1"
|
||||
shift
|
||||
local base_url="$2"
|
||||
shift 2
|
||||
local log="$TMP_DIR/$name.curl.log"
|
||||
local out="$TMP_DIR/$name.out"
|
||||
local status=0
|
||||
env PATH="$TMP_DIR/bin:$PATH" SMOKE_CURL_LOG="$log" "$@" "$SMOKE_SCRIPT" http://skillhub.test >"$out" 2>&1 || status=$?
|
||||
env PATH="$TMP_DIR/bin:$PATH" SMOKE_CURL_LOG="$log" "$@" "$SMOKE_SCRIPT" "$base_url" >"$out" 2>&1 || status=$?
|
||||
printf '%s\n' "$status"
|
||||
}
|
||||
|
||||
run_smoke() {
|
||||
local name="$1"
|
||||
shift
|
||||
run_smoke_at "$name" http://skillhub.test "$@"
|
||||
}
|
||||
|
||||
status="$(run_smoke skip-admin env)"
|
||||
[[ "$status" == "0" ]] || fail "default smoke without admin credentials should pass"
|
||||
grep -Fq "SKIP: Admin label management" "$TMP_DIR/skip-admin.out" \
|
||||
|
|
@ -153,4 +174,22 @@ if grep -Fq 'ChangeMe!2026' "$TMP_DIR/explicit-admin.curl.log"; then
|
|||
fail "admin login must not fall back to the bootstrap default password"
|
||||
fi
|
||||
|
||||
status="$(run_smoke_at split-targets https://public.example env \
|
||||
ACTUATOR_BASE_URL=http://actuator.internal:8080 SMOKE_ADMIN_CHECKS=false)"
|
||||
[[ "$status" == "0" ]] || fail "split public and actuator targets should pass"
|
||||
grep -Fq "GET http://actuator.internal:8080/actuator/health" "$TMP_DIR/split-targets.curl.log" \
|
||||
|| fail "health check should use ACTUATOR_BASE_URL"
|
||||
grep -Fq "GET http://actuator.internal:8080/actuator/prometheus" "$TMP_DIR/split-targets.curl.log" \
|
||||
|| fail "Prometheus check should use ACTUATOR_BASE_URL"
|
||||
if grep -Fq "https://public.example/actuator/" "$TMP_DIR/split-targets.curl.log"; then
|
||||
fail "actuator checks must not use the public API target when ACTUATOR_BASE_URL is set"
|
||||
fi
|
||||
grep -Fq "GET https://public.example/api/v1/auth/me" "$TMP_DIR/split-targets.curl.log" \
|
||||
|| fail "application API checks should continue using BASE_URL"
|
||||
|
||||
status="$(run_smoke_at html-fallback https://public.example env SMOKE_ADMIN_CHECKS=false)"
|
||||
[[ "$status" != "0" ]] || fail "HTML SPA fallback must not pass as actuator health"
|
||||
grep -Fq "routing/target error: received text/html" "$TMP_DIR/html-fallback.out" \
|
||||
|| fail "HTML fallback should produce an actionable routing/target error"
|
||||
|
||||
echo "smoke-test-admin-mode-test passed"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# ---- Build Stage ----
|
||||
FROM eclipse-temurin:21-jdk-alpine AS build
|
||||
# Build the architecture-neutral JAR on the Buildx host. This avoids emulating
|
||||
# the complete Maven build when the target image is linux/riscv64.
|
||||
FROM --platform=$BUILDPLATFORM eclipse-temurin:21-jdk-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Cache dependencies
|
||||
|
|
@ -19,8 +20,14 @@ COPY . .
|
|||
RUN ./mvnw package -DskipTests -B
|
||||
|
||||
# ---- Runtime Stage ----
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
# The Noble variant publishes a linux/riscv64 image; the Alpine JRE currently
|
||||
# used by this project is limited to amd64 and arm64.
|
||||
FROM eclipse-temurin:21-jre-noble
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends wget && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd --system app && \
|
||||
useradd --system --gid app --create-home app
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app/skillhub-app/target/*.jar app.jar
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
|||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
|
||||
import com.iflytek.skillhub.domain.social.SkillStarService;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||
import com.iflytek.skillhub.service.SkillLabelProjectionService;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
|
|
@ -51,6 +53,7 @@ public class ClawHubCompatAppService {
|
|||
private final CompatSkillLookupService compatSkillLookupService;
|
||||
private final SkillStarService skillStarService;
|
||||
private final RequestIdAccessor requestIdAccessor;
|
||||
private final SkillLabelProjectionService skillLabelProjectionService;
|
||||
|
||||
public ClawHubCompatAppService(CanonicalSlugMapper mapper,
|
||||
SkillSearchAppService skillSearchAppService,
|
||||
|
|
@ -61,7 +64,8 @@ public class ClawHubCompatAppService {
|
|||
AuditLogService auditLogService,
|
||||
CompatSkillLookupService compatSkillLookupService,
|
||||
SkillStarService skillStarService,
|
||||
RequestIdAccessor requestIdAccessor) {
|
||||
RequestIdAccessor requestIdAccessor,
|
||||
SkillLabelProjectionService skillLabelProjectionService) {
|
||||
this.mapper = mapper;
|
||||
this.skillSearchAppService = skillSearchAppService;
|
||||
this.skillQueryService = skillQueryService;
|
||||
|
|
@ -72,6 +76,7 @@ public class ClawHubCompatAppService {
|
|||
this.compatSkillLookupService = compatSkillLookupService;
|
||||
this.skillStarService = skillStarService;
|
||||
this.requestIdAccessor = requestIdAccessor;
|
||||
this.skillLabelProjectionService = skillLabelProjectionService;
|
||||
}
|
||||
|
||||
public ClawHubSearchResponse search(String q,
|
||||
|
|
@ -195,6 +200,15 @@ public class ClawHubCompatAppService {
|
|||
String sort,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
return listSkills(page, limit, sort, false, userId, userNsRoles);
|
||||
}
|
||||
|
||||
public ClawHubSkillListResponse listSkills(int page,
|
||||
int limit,
|
||||
String sort,
|
||||
boolean includeLabels,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
String sortBy = sort != null ? sort : "newest";
|
||||
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
|
||||
"",
|
||||
|
|
@ -206,8 +220,15 @@ public class ClawHubCompatAppService {
|
|||
userNsRoles
|
||||
);
|
||||
|
||||
Map<Long, List<SkillLabelDto>> labelsBySkillId = includeLabels
|
||||
? skillLabelProjectionService.labelsBySkillIds(
|
||||
response.items().stream().map(SkillSummaryResponse::id).toList())
|
||||
: Map.of();
|
||||
|
||||
List<ClawHubSkillListResponse.SkillListItem> items = response.items().stream()
|
||||
.map(this::toSkillListItem)
|
||||
.map(item -> toSkillListItem(
|
||||
item,
|
||||
includeLabels ? labelsBySkillId.getOrDefault(item.id(), List.of()) : null))
|
||||
.toList();
|
||||
|
||||
String nextCursor = null;
|
||||
|
|
@ -383,7 +404,8 @@ public class ClawHubCompatAppService {
|
|||
return new ClawHubResolveResponse(matchVersion, latestVersion);
|
||||
}
|
||||
|
||||
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) {
|
||||
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item,
|
||||
List<SkillLabelDto> labels) {
|
||||
long createdAt = 0;
|
||||
long updatedAt = item.updatedAt() != null ? item.updatedAt().toEpochMilli() : 0;
|
||||
|
||||
|
|
@ -413,7 +435,8 @@ public class ClawHubCompatAppService {
|
|||
stats,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
latestVersion
|
||||
latestVersion,
|
||||
labels
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse;
|
|||
import com.iflytek.skillhub.compat.dto.ClawHubStarResponse;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubUnstarResponse;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse;
|
||||
import com.iflytek.skillhub.controller.support.IncludeOptions;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
|
@ -93,9 +96,12 @@ public class ClawHubCompatController {
|
|||
public ClawHubSkillListResponse listSkills(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int limit,
|
||||
@RequestParam(required = false) String sort,
|
||||
@Parameter(description = "Optional response expansions. Supported value: labels")
|
||||
@RequestParam(name = "include", required = false) List<String> include,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return clawHubCompatAppService.listSkills(page, limit, sort, userId, userNsRoles);
|
||||
return clawHubCompatAppService.listSkills(
|
||||
page, limit, sort, IncludeOptions.includesLabels(include), userId, userNsRoles);
|
||||
}
|
||||
|
||||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter;
|
|||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
|
@ -24,12 +25,11 @@ public class ClawHubRegistrySecurityConfig {
|
|||
http
|
||||
.securityMatcher(
|
||||
new OrRequestMatcher(
|
||||
new AntPathRequestMatcher("/api/v1/labels"),
|
||||
new AntPathRequestMatcher("/api/web/labels")
|
||||
new AntPathRequestMatcher("/api/v1/labels", HttpMethod.GET.name()),
|
||||
new AntPathRequestMatcher("/api/web/labels", HttpMethod.GET.name())
|
||||
)
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.requestCache(cache -> cache.disable())
|
||||
.securityContext(context -> context.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
|
||||
|
|
|
|||
|
|
@ -3,15 +3,21 @@ package com.iflytek.skillhub.compat;
|
|||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
|
|
@ -21,6 +27,10 @@ import org.springframework.stereotype.Service;
|
|||
@Service
|
||||
public class CompatSkillLookupService {
|
||||
|
||||
private static final int LEGACY_SLUG_PUBLISHED_SCORE = 1_000;
|
||||
private static final int LEGACY_SLUG_PUBLIC_SCORE = 100;
|
||||
private static final int LEGACY_SLUG_GLOBAL_SCORE = 10;
|
||||
|
||||
private final SkillRepository skillRepository;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
|
|
@ -40,10 +50,27 @@ public class CompatSkillLookupService {
|
|||
}
|
||||
|
||||
public CompatSkillContext findByLegacySlug(String slug) {
|
||||
Skill skill = skillRepository.findBySlug(slug).stream().findFirst()
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", slug));
|
||||
Namespace namespace = namespaceRepository.findById(skill.getNamespaceId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", skill.getNamespaceId()));
|
||||
List<Skill> skills = skillRepository.findBySlug(slug);
|
||||
if (skills.isEmpty()) {
|
||||
throw new DomainNotFoundException("error.skill.notFound", slug);
|
||||
}
|
||||
// Batch-fetch all candidate namespaces in a single query to avoid N+1 lookups.
|
||||
List<Long> namespaceIds = skills.stream()
|
||||
.map(Skill::getNamespaceId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, Namespace> namespacesById = namespaceIds.isEmpty()
|
||||
? Map.of()
|
||||
: namespaceRepository.findByIdIn(namespaceIds).stream()
|
||||
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
|
||||
Skill skill = skills.stream()
|
||||
.min(Comparator.<Skill>comparingInt(s -> -legacySlugCandidateScore(s, namespacesById))
|
||||
.thenComparing(Skill::getId))
|
||||
.orElse(skills.get(0));
|
||||
Namespace namespace = namespacesById.get(skill.getNamespaceId());
|
||||
if (namespace == null) {
|
||||
throw new DomainNotFoundException("error.namespace.notFound", skill.getNamespaceId());
|
||||
}
|
||||
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +113,16 @@ public class CompatSkillLookupService {
|
|||
return skillVersionRepository.findById(skill.getLatestVersionId());
|
||||
}
|
||||
|
||||
private static int legacySlugCandidateScore(Skill skill, Map<Long, Namespace> namespacesById) {
|
||||
Namespace namespace = namespacesById.get(skill.getNamespaceId());
|
||||
int score = 0;
|
||||
// Bare-slug CLI resolution should prefer installable candidates before namespace defaults.
|
||||
if (skill.getLatestVersionId() != null) score += LEGACY_SLUG_PUBLISHED_SCORE;
|
||||
if (skill.getVisibility() == SkillVisibility.PUBLIC) score += LEGACY_SLUG_PUBLIC_SCORE;
|
||||
if (namespace != null && namespace.getType() == NamespaceType.GLOBAL) score += LEGACY_SLUG_GLOBAL_SCORE;
|
||||
return score;
|
||||
}
|
||||
|
||||
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
|
||||
try {
|
||||
return skillSlugResolutionService.resolve(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import java.util.List;
|
||||
|
||||
public record ClawHubSkillListResponse(
|
||||
|
|
@ -14,8 +16,27 @@ public record ClawHubSkillListResponse(
|
|||
Object stats,
|
||||
long createdAt,
|
||||
long updatedAt,
|
||||
LatestVersion latestVersion
|
||||
LatestVersion latestVersion,
|
||||
/**
|
||||
* Labels attached to the skill, present only when the caller passes
|
||||
* {@code include=labels}. Omitted otherwise, so the legacy payload is unchanged.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
List<SkillLabelDto> labels
|
||||
) {
|
||||
|
||||
public SkillListItem(
|
||||
String slug,
|
||||
String displayName,
|
||||
String summary,
|
||||
Object tags,
|
||||
Object stats,
|
||||
long createdAt,
|
||||
long updatedAt,
|
||||
LatestVersion latestVersion) {
|
||||
this(slug, displayName, summary, tags, stats, createdAt, updatedAt, latestVersion, null);
|
||||
}
|
||||
|
||||
public record LatestVersion(
|
||||
String version,
|
||||
long createdAt,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
package com.iflytek.skillhub.config;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Runtime-configurable overrides for request rate limiting.
|
||||
*
|
||||
* <p>The compile-time {@link com.iflytek.skillhub.ratelimit.RateLimit} annotation on each endpoint
|
||||
* supplies the built-in defaults. Values set here — typically via {@code SKILLHUB_RATELIMIT_*}
|
||||
* environment variables — override those defaults per {@code category}, and {@code enabled=false}
|
||||
* turns request rate limiting off entirely. When nothing is configured the annotation defaults are
|
||||
* used unchanged, so existing deployments behave exactly as before.
|
||||
*
|
||||
* <p>An override applies to every endpoint that shares the same {@code category}. Only the fields
|
||||
* you set are overridden; the rest fall back to the annotation.
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "skillhub.ratelimit")
|
||||
public class RateLimitProperties {
|
||||
|
||||
/** Master switch. When {@code false} the interceptor performs no quota checks. */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** Per-category overrides keyed by {@code RateLimit#category} (e.g. "search", "download", "publish"). */
|
||||
private Map<String, CategoryLimit> categories = new HashMap<>();
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public Map<String, CategoryLimit> getCategories() {
|
||||
return categories;
|
||||
}
|
||||
|
||||
public void setCategories(Map<String, CategoryLimit> categories) {
|
||||
this.categories = categories;
|
||||
}
|
||||
|
||||
/** Configured authenticated quota for {@code category}, or {@code fallback} when unset. */
|
||||
public int authenticatedFor(String category, int fallback) {
|
||||
CategoryLimit c = categories.get(category);
|
||||
return c != null && c.getAuthenticated() != null ? c.getAuthenticated() : fallback;
|
||||
}
|
||||
|
||||
/** Configured anonymous quota for {@code category}, or {@code fallback} when unset. */
|
||||
public int anonymousFor(String category, int fallback) {
|
||||
CategoryLimit c = categories.get(category);
|
||||
return c != null && c.getAnonymous() != null ? c.getAnonymous() : fallback;
|
||||
}
|
||||
|
||||
/** Configured window (seconds) for {@code category}, or {@code fallback} when unset. */
|
||||
public int windowSecondsFor(String category, int fallback) {
|
||||
CategoryLimit c = categories.get(category);
|
||||
return c != null && c.getWindowSeconds() != null ? c.getWindowSeconds() : fallback;
|
||||
}
|
||||
|
||||
public static class CategoryLimit {
|
||||
private Integer authenticated;
|
||||
private Integer anonymous;
|
||||
private Integer windowSeconds;
|
||||
|
||||
public Integer getAuthenticated() {
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
public void setAuthenticated(Integer authenticated) {
|
||||
this.authenticated = authenticated;
|
||||
}
|
||||
|
||||
public Integer getAnonymous() {
|
||||
return anonymous;
|
||||
}
|
||||
|
||||
public void setAnonymous(Integer anonymous) {
|
||||
this.anonymous = anonymous;
|
||||
}
|
||||
|
||||
public Integer getWindowSeconds() {
|
||||
return windowSeconds;
|
||||
}
|
||||
|
||||
public void setWindowSeconds(Integer windowSeconds) {
|
||||
this.windowSeconds = windowSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,20 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.controller.support.IncludeOptions;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
import com.iflytek.skillhub.service.SkillLabelProjectionService;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
|
@ -27,11 +31,14 @@ public class SkillSearchController extends BaseApiController {
|
|||
private static final int DEFAULT_SIZE = 20;
|
||||
|
||||
private final SkillSearchAppService skillSearchAppService;
|
||||
private final SkillLabelProjectionService skillLabelProjectionService;
|
||||
|
||||
public SkillSearchController(SkillSearchAppService skillSearchAppService,
|
||||
SkillLabelProjectionService skillLabelProjectionService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.skillSearchAppService = skillSearchAppService;
|
||||
this.skillLabelProjectionService = skillLabelProjectionService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
|
|
@ -40,6 +47,8 @@ public class SkillSearchController extends BaseApiController {
|
|||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) String namespace,
|
||||
@RequestParam(name = "label", required = false) java.util.List<String> labels,
|
||||
@Parameter(description = "Optional response expansions. Supported value: labels")
|
||||
@RequestParam(name = "include", required = false) List<String> include,
|
||||
@Parameter(schema = @Schema(defaultValue = DEFAULT_SORT))
|
||||
@RequestParam(required = false) String sort,
|
||||
@Parameter(schema = @Schema(type = "integer", defaultValue = "0", minimum = "0"))
|
||||
|
|
@ -49,6 +58,7 @@ public class SkillSearchController extends BaseApiController {
|
|||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
||||
boolean includeLabels = IncludeOptions.includesLabels(include);
|
||||
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
|
||||
q,
|
||||
namespace,
|
||||
|
|
@ -60,7 +70,18 @@ public class SkillSearchController extends BaseApiController {
|
|||
userNsRoles
|
||||
);
|
||||
|
||||
return ok("response.success.read", response);
|
||||
return ok("response.success.read", includeLabels ? withLabels(response) : response);
|
||||
}
|
||||
|
||||
private SkillSearchAppService.SearchResponse withLabels(SkillSearchAppService.SearchResponse response) {
|
||||
Map<Long, List<SkillLabelDto>> labelsBySkillId = skillLabelProjectionService.labelsBySkillIds(
|
||||
response.items().stream().map(SkillSummaryResponse::id).toList());
|
||||
|
||||
List<SkillSummaryResponse> items = response.items().stream()
|
||||
.map(item -> item.withLabels(labelsBySkillId.getOrDefault(item.id(), List.of())))
|
||||
.toList();
|
||||
|
||||
return new SkillSearchAppService.SearchResponse(items, response.total(), response.page(), response.size());
|
||||
}
|
||||
|
||||
private String normalizeSort(String sort) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
package com.iflytek.skillhub.controller.support;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Parses optional response expansions from {@code include=...} query parameters.
|
||||
*/
|
||||
public final class IncludeOptions {
|
||||
|
||||
private static final String LABELS = "labels";
|
||||
private static final Set<String> SUPPORTED = Set.of(LABELS);
|
||||
|
||||
private IncludeOptions() {
|
||||
}
|
||||
|
||||
public static boolean includesLabels(List<String> include) {
|
||||
if (include == null || include.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean requested = false;
|
||||
for (String rawValue : include) {
|
||||
if (rawValue == null || rawValue.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
for (String rawOption : rawValue.split(",")) {
|
||||
String option = rawOption.trim().toLowerCase(Locale.ROOT);
|
||||
if (option.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (!SUPPORTED.contains(option)) {
|
||||
throw new DomainBadRequestException("error.request.include.unsupported", option);
|
||||
}
|
||||
requested = true;
|
||||
}
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ public class SkillPackageArchiveExtractor {
|
|||
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
|
||||
ZipEntry zipEntry;
|
||||
while ((zipEntry = zis.getNextEntry()) != null) {
|
||||
if (zipEntry.isDirectory()) {
|
||||
if (isDirectoryEntry(zipEntry)) {
|
||||
zis.closeEntry();
|
||||
continue;
|
||||
}
|
||||
|
|
@ -164,6 +164,14 @@ public class SkillPackageArchiveExtractor {
|
|||
return new ExtractionResult(promoted, warnings);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link ZipEntry#isDirectory()} only recognizes the ZIP-standard forward slash. Some Windows
|
||||
* archive tools emit directory entries whose names end in a backslash instead.
|
||||
*/
|
||||
static boolean isDirectoryEntry(ZipEntry entry) {
|
||||
return entry.isDirectory() || entry.getName().endsWith("\\");
|
||||
}
|
||||
|
||||
private static boolean isOsMetadataEntry(String name) {
|
||||
String normalized = name.replace('\\', '/');
|
||||
if (normalized.startsWith("__MACOSX/") || normalized.equals("__MACOSX")) return true;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ public class ZipPackageExtractor {
|
|||
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
|
||||
ZipEntry zipEntry;
|
||||
while ((zipEntry = zis.getNextEntry()) != null) {
|
||||
if (zipEntry.isDirectory()) {
|
||||
if (SkillPackageArchiveExtractor.isDirectoryEntry(zipEntry)) {
|
||||
zis.closeEntry();
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -16,10 +18,81 @@ public record SkillSummaryResponse(
|
|||
Integer ratingCount,
|
||||
String namespace,
|
||||
Instant updatedAt,
|
||||
String ownerId,
|
||||
String ownerDisplayName,
|
||||
boolean canSubmitPromotion,
|
||||
SkillLifecycleVersionResponse headlineVersion,
|
||||
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, null, null, canSubmitPromotion, headlineVersion, publishedVersion,
|
||||
ownerPreviewVersion, resolutionMode, complianceSnapshot, null);
|
||||
}
|
||||
|
||||
/** Summary with owner information but without an optional 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,
|
||||
String ownerId,
|
||||
String ownerDisplayName,
|
||||
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, ownerId, ownerDisplayName, 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, ownerId, ownerDisplayName,
|
||||
canSubmitPromotion, headlineVersion,
|
||||
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.skill.Skill;
|
|||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.social.SkillSubscriptionService;
|
||||
import com.iflytek.skillhub.domain.social.SubscriptionRecipientEligibility;
|
||||
import com.iflytek.skillhub.notification.domain.NotificationCategory;
|
||||
import com.iflytek.skillhub.notification.service.NotificationDispatcher;
|
||||
import org.slf4j.Logger;
|
||||
|
|
@ -33,6 +34,7 @@ public class NotificationEventListener {
|
|||
private final NotificationDispatcher dispatcher;
|
||||
private final SkillSubscriptionService skillSubscriptionService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SubscriptionRecipientEligibility subscriptionEligibility;
|
||||
|
||||
public NotificationEventListener(SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
|
|
@ -40,7 +42,8 @@ public class NotificationEventListener {
|
|||
RecipientResolver recipientResolver,
|
||||
NotificationDispatcher dispatcher,
|
||||
SkillSubscriptionService skillSubscriptionService,
|
||||
ObjectMapper objectMapper) {
|
||||
ObjectMapper objectMapper,
|
||||
SubscriptionRecipientEligibility subscriptionEligibility) {
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
|
|
@ -48,6 +51,7 @@ public class NotificationEventListener {
|
|||
this.dispatcher = dispatcher;
|
||||
this.skillSubscriptionService = skillSubscriptionService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.subscriptionEligibility = subscriptionEligibility;
|
||||
}
|
||||
|
||||
@Async("skillhubEventExecutor")
|
||||
|
|
@ -74,6 +78,8 @@ public class NotificationEventListener {
|
|||
if (subscribers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
var namespace = namespaceRepository.findById(skill.getNamespaceId()).orElse(null);
|
||||
subscribers = subscriptionEligibility.currentRecipients(skill, namespace, subscribers);
|
||||
String title = "Skill updated: " + skillDisplayName(skill);
|
||||
Map<String, Object> body = bodyWithSkill(skill);
|
||||
versionLabel(event.versionId(), body);
|
||||
|
|
@ -96,6 +102,8 @@ public class NotificationEventListener {
|
|||
if (subscribers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
var namespace = namespaceRepository.findById(skill.getNamespaceId()).orElse(null);
|
||||
subscribers = subscriptionEligibility.yankedRecipients(skill, namespace, subscribers, event.wasPublished());
|
||||
String title = "Skill version yanked: " + skillDisplayName(skill);
|
||||
Map<String, Object> body = bodyWithSkill(skill);
|
||||
versionLabel(event.versionId(), body);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
package com.iflytek.skillhub.listener;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.PersonalNamespaceOwner;
|
||||
import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
/**
|
||||
* Creates a newly activated account's own namespace once the account itself is committed.
|
||||
*
|
||||
* <p>Runs synchronously rather than on the event executor so the namespace exists by the time the
|
||||
* user's next request arrives, and swallows failures so a naming clash or a database hiccup costs
|
||||
* the user a namespace rather than their registration or login.
|
||||
*/
|
||||
@Component
|
||||
public class PersonalNamespaceProvisioningListener {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PersonalNamespaceProvisioningListener.class);
|
||||
|
||||
private final PersonalNamespaceProvisioningService personalNamespaceProvisioningService;
|
||||
|
||||
public PersonalNamespaceProvisioningListener(
|
||||
PersonalNamespaceProvisioningService personalNamespaceProvisioningService) {
|
||||
this.personalNamespaceProvisioningService = personalNamespaceProvisioningService;
|
||||
}
|
||||
|
||||
@TransactionalEventListener
|
||||
public void onUserActivated(UserActivatedEvent event) {
|
||||
try {
|
||||
personalNamespaceProvisioningService.provisionFor(
|
||||
new PersonalNamespaceOwner(event.userId(), event.username(), event.email()));
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Personal namespace provisioning failed for user {}; the account is unaffected",
|
||||
event.userId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.ratelimit;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.config.RateLimitProperties;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||
|
|
@ -28,19 +29,22 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||
private final ApiResponseFactory apiResponseFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SkillHubMetrics metrics;
|
||||
private final RateLimitProperties properties;
|
||||
|
||||
public RateLimitInterceptor(RateLimiter rateLimiter,
|
||||
ClientIpResolver clientIpResolver,
|
||||
AnonymousDownloadIdentityService anonymousDownloadIdentityService,
|
||||
ApiResponseFactory apiResponseFactory,
|
||||
ObjectMapper objectMapper,
|
||||
SkillHubMetrics metrics) {
|
||||
SkillHubMetrics metrics,
|
||||
RateLimitProperties properties) {
|
||||
this.rateLimiter = rateLimiter;
|
||||
this.clientIpResolver = clientIpResolver;
|
||||
this.anonymousDownloadIdentityService = anonymousDownloadIdentityService;
|
||||
this.apiResponseFactory = apiResponseFactory;
|
||||
this.objectMapper = objectMapper;
|
||||
this.metrics = metrics;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -56,23 +60,33 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||
return true;
|
||||
}
|
||||
|
||||
// Master switch: when disabled, perform no quota checks at all.
|
||||
if (!properties.isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Determine if user is authenticated
|
||||
String userId = (String) request.getAttribute("userId");
|
||||
boolean isAuthenticated = userId != null;
|
||||
|
||||
// Get limit based on authentication status
|
||||
int limit = isAuthenticated ? rateLimit.authenticated() : rateLimit.anonymous();
|
||||
String resourceSuffix = resolveResourceSuffix(rateLimit.category(), request);
|
||||
// Effective limits: runtime config overrides (per category) fall back to the
|
||||
// annotation defaults, so unconfigured deployments behave exactly as before.
|
||||
String category = rateLimit.category();
|
||||
int windowSeconds = properties.windowSecondsFor(category, rateLimit.windowSeconds());
|
||||
int limit = isAuthenticated
|
||||
? properties.authenticatedFor(category, rateLimit.authenticated())
|
||||
: properties.anonymousFor(category, rateLimit.anonymous());
|
||||
String resourceSuffix = resolveResourceSuffix(category, request);
|
||||
|
||||
boolean allowed = isAuthenticated
|
||||
? rateLimiter.tryAcquire(
|
||||
"ratelimit:" + rateLimit.category() + ":user:" + userId + resourceSuffix,
|
||||
"ratelimit:" + category + ":user:" + userId + resourceSuffix,
|
||||
limit,
|
||||
rateLimit.windowSeconds())
|
||||
: checkAnonymousLimit(request, response, rateLimit, limit, resourceSuffix);
|
||||
windowSeconds)
|
||||
: checkAnonymousLimit(request, response, category, limit, windowSeconds, resourceSuffix);
|
||||
|
||||
if (!allowed) {
|
||||
metrics.incrementRateLimitExceeded(rateLimit.category());
|
||||
metrics.incrementRateLimitExceeded(category);
|
||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
ApiResponse<Void> body = apiResponseFactory.error(429, "error.rateLimit.exceeded");
|
||||
|
|
@ -85,14 +99,15 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||
|
||||
private boolean checkAnonymousLimit(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
RateLimit rateLimit,
|
||||
String category,
|
||||
int limit,
|
||||
int windowSeconds,
|
||||
String resourceSuffix) {
|
||||
if (!"download".equals(rateLimit.category())) {
|
||||
if (!"download".equals(category)) {
|
||||
return rateLimiter.tryAcquire(
|
||||
"ratelimit:" + rateLimit.category() + ":ip:" + clientIpResolver.resolve(request) + resourceSuffix,
|
||||
"ratelimit:" + category + ":ip:" + clientIpResolver.resolve(request) + resourceSuffix,
|
||||
limit,
|
||||
rateLimit.windowSeconds()
|
||||
windowSeconds
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +116,7 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||
boolean ipAllowed = rateLimiter.tryAcquire(
|
||||
"ratelimit:download:ip:" + identity.ipHash() + resourceSuffix,
|
||||
limit,
|
||||
rateLimit.windowSeconds()
|
||||
windowSeconds
|
||||
);
|
||||
if (!ipAllowed) {
|
||||
return false;
|
||||
|
|
@ -109,7 +124,7 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||
return rateLimiter.tryAcquire(
|
||||
"ratelimit:download:anon:" + identity.cookieHash() + resourceSuffix,
|
||||
limit,
|
||||
rateLimit.windowSeconds()
|
||||
windowSeconds
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
|||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
|
||||
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.SkillSummaryResponse;
|
||||
import java.util.List;
|
||||
|
|
@ -16,6 +18,7 @@ import java.util.Map;
|
|||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
@Repository
|
||||
public class JpaMySkillQueryRepository implements MySkillQueryRepository {
|
||||
|
|
@ -23,13 +26,23 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final PromotionRequestRepository promotionRequestRepository;
|
||||
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
|
||||
public JpaMySkillQueryRepository(NamespaceRepository namespaceRepository,
|
||||
PromotionRequestRepository promotionRequestRepository,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService) {
|
||||
this(namespaceRepository, promotionRequestRepository, skillLifecycleProjectionService, null);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public JpaMySkillQueryRepository(NamespaceRepository namespaceRepository,
|
||||
PromotionRequestRepository promotionRequestRepository,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService,
|
||||
UserAccountRepository userAccountRepository) {
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.promotionRequestRepository = promotionRequestRepository;
|
||||
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -41,14 +54,19 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
|
|||
skills.stream().map(Skill::getNamespaceId).distinct().toList())
|
||||
.stream()
|
||||
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
|
||||
Map<String, UserAccount> ownersById = userAccountRepository == null
|
||||
? Map.of()
|
||||
: userAccountRepository.findByIdIn(skills.stream().map(Skill::getOwnerId).distinct().toList())
|
||||
.stream().collect(Collectors.toMap(UserAccount::getId, Function.identity()));
|
||||
return skills.stream()
|
||||
.map(skill -> toSummaryResponse(skill, currentUserId, namespacesById))
|
||||
.map(skill -> toSummaryResponse(skill, currentUserId, namespacesById, ownersById))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private SkillSummaryResponse toSummaryResponse(Skill skill,
|
||||
String currentUserId,
|
||||
Map<Long, Namespace> namespacesById) {
|
||||
Map<Long, Namespace> namespacesById,
|
||||
Map<String, UserAccount> ownersById) {
|
||||
Namespace namespace = namespacesById.get(skill.getNamespaceId());
|
||||
SkillLifecycleProjectionService.Projection projection = skillLifecycleProjectionService.projectForViewer(
|
||||
skill,
|
||||
|
|
@ -75,11 +93,16 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
|
|||
skill.getRatingCount(),
|
||||
namespace != null ? namespace.getSlug() : null,
|
||||
skill.getUpdatedAt(),
|
||||
skill.getOwnerId(),
|
||||
ownersById.get(skill.getOwnerId()) != null
|
||||
? ownersById.get(skill.getOwnerId()).getDisplayName()
|
||||
: null,
|
||||
canSubmitPromotion(skill, publishedVersion, namespace),
|
||||
toLifecycleVersion(headlineVersion),
|
||||
toLifecycleVersion(publishedVersion),
|
||||
toLifecycleVersion(ownerPreviewVersion),
|
||||
projection.resolutionMode().name(),
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.entity.Role;
|
|||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
import com.iflytek.skillhub.auth.repository.RoleRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
|
|
@ -18,6 +19,7 @@ import org.springframework.data.domain.Page;
|
|||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
|
@ -44,16 +46,19 @@ public class AdminUserAppService {
|
|||
private final UserAccountRepository userAccountRepository;
|
||||
private final UserRoleBindingRepository userRoleBindingRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public AdminUserAppService(
|
||||
AdminUserSearchRepository adminUserSearchRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
UserRoleBindingRepository userRoleBindingRepository,
|
||||
RoleRepository roleRepository) {
|
||||
RoleRepository roleRepository,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.adminUserSearchRepository = adminUserSearchRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
|
@ -109,8 +114,13 @@ public class AdminUserAppService {
|
|||
UserAccount user = loadUser(userId);
|
||||
rejectSystemAccountMutation(user);
|
||||
UserStatus nextStatus = parseManageableStatus(status);
|
||||
UserStatus previousStatus = user.getStatus();
|
||||
user.setStatus(nextStatus);
|
||||
userAccountRepository.save(user);
|
||||
if (nextStatus == UserStatus.ACTIVE && previousStatus != UserStatus.ACTIVE) {
|
||||
eventPublisher.publishEvent(
|
||||
new UserActivatedEvent(user.getId(), user.getDisplayName(), user.getEmail()));
|
||||
}
|
||||
return new AdminUserMutationResponse(user.getId(), null, nextStatus.name());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
|||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.search.SearchQuery;
|
||||
import com.iflytek.skillhub.search.SearchQueryService;
|
||||
|
|
@ -39,6 +41,7 @@ public class SkillSearchAppService {
|
|||
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
|
||||
private final ComplianceSnapshotProjectionService complianceSnapshotProjectionService;
|
||||
private final RbacService rbacService;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
|
||||
public SkillSearchAppService(
|
||||
SearchQueryService searchQueryService,
|
||||
|
|
@ -54,7 +57,8 @@ public class SkillSearchAppService {
|
|||
namespaceService,
|
||||
skillLifecycleProjectionService,
|
||||
new ComplianceSnapshotProjectionService(new com.fasterxml.jackson.databind.ObjectMapper()),
|
||||
rbacService
|
||||
rbacService,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +70,8 @@ public class SkillSearchAppService {
|
|||
NamespaceService namespaceService,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService,
|
||||
ComplianceSnapshotProjectionService complianceSnapshotProjectionService,
|
||||
RbacService rbacService) {
|
||||
RbacService rbacService,
|
||||
UserAccountRepository userAccountRepository) {
|
||||
this.searchQueryService = searchQueryService;
|
||||
this.skillRepository = skillRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
|
|
@ -74,6 +79,7 @@ public class SkillSearchAppService {
|
|||
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
|
||||
this.complianceSnapshotProjectionService = complianceSnapshotProjectionService;
|
||||
this.rbacService = rbacService;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
public record SearchResponse(
|
||||
|
|
@ -215,6 +221,10 @@ public class SkillSearchAppService {
|
|||
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
|
||||
Map<Long, String> namespaceSlugsById = namespacesById.entrySet().stream()
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getSlug()));
|
||||
Map<String, UserAccount> ownersById = userAccountRepository == null
|
||||
? Map.of()
|
||||
: userAccountRepository.findByIdIn(matchedSkills.stream().map(Skill::getOwnerId).distinct().toList())
|
||||
.stream().collect(Collectors.toMap(UserAccount::getId, Function.identity()));
|
||||
Map<Long, SkillLifecycleProjectionService.Projection> projectionsBySkillId =
|
||||
skillLifecycleProjectionService.projectPublishedSummaries(matchedSkills);
|
||||
|
||||
|
|
@ -224,6 +234,7 @@ public class SkillSearchAppService {
|
|||
.map(skill -> toSummaryResponse(
|
||||
skill,
|
||||
namespaceSlugsById,
|
||||
ownersById,
|
||||
projectionsBySkillId.get(skill.getId())
|
||||
))
|
||||
.toList();
|
||||
|
|
@ -232,8 +243,10 @@ public class SkillSearchAppService {
|
|||
private SkillSummaryResponse toSummaryResponse(
|
||||
Skill skill,
|
||||
Map<Long, String> namespaceSlugsById,
|
||||
Map<String, UserAccount> ownersById,
|
||||
SkillLifecycleProjectionService.Projection projection) {
|
||||
String namespaceSlug = namespaceSlugsById.get(skill.getNamespaceId());
|
||||
UserAccount owner = ownersById.get(skill.getOwnerId());
|
||||
SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion();
|
||||
|
||||
return new SkillSummaryResponse(
|
||||
|
|
@ -249,6 +262,10 @@ public class SkillSearchAppService {
|
|||
skill.getRatingCount(),
|
||||
namespaceSlug,
|
||||
skill.getUpdatedAt(),
|
||||
skill.getOwnerId(),
|
||||
owner != null
|
||||
? owner.getDisplayName()
|
||||
: null,
|
||||
false,
|
||||
toLifecycleVersion(projection.headlineVersion()),
|
||||
toLifecycleVersion(projection.publishedVersion()),
|
||||
|
|
@ -256,7 +273,8 @@ public class SkillSearchAppService {
|
|||
projection.resolutionMode().name(),
|
||||
headlineVersion != null
|
||||
? complianceSnapshotProjectionService.fromParsedMetadataJson(headlineVersion.parsedMetadataJson())
|
||||
: null
|
||||
: null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.observability.MessageObservationSupport;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import org.redisson.api.RLock;
|
||||
import org.redisson.api.RedissonClient;
|
||||
|
||||
import java.io.IOException;
|
||||
|
|
@ -26,6 +27,7 @@ import java.util.Map;
|
|||
public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.ScanTaskPayload> {
|
||||
private static final Path SCAN_TEMP_DIR = Paths.get("/tmp/skillhub-scans").toAbsolutePath().normalize();
|
||||
|
||||
private final RedissonClient redissonClient;
|
||||
private final SecurityScanner securityScanner;
|
||||
private final SecurityScanService securityScanService;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
|
|
@ -42,6 +44,7 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
ObjectStorageService objectStorageService,
|
||||
MessageObservationSupport messageObservationSupport) {
|
||||
super(redissonClient, streamKey, groupName, messageObservationSupport);
|
||||
this.redissonClient = redissonClient;
|
||||
this.securityScanner = securityScanner;
|
||||
this.securityScanService = securityScanService;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
|
|
@ -72,6 +75,7 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
reclaimInterval,
|
||||
messageObservationSupport
|
||||
);
|
||||
this.redissonClient = redissonClient;
|
||||
this.securityScanner = securityScanner;
|
||||
this.securityScanService = securityScanService;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
|
|
@ -128,17 +132,49 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
|
||||
@Override
|
||||
protected void processBusiness(ScanTaskPayload payload) {
|
||||
if (securityScanService.isTaskAlreadyProcessed(payload.taskId())) {
|
||||
log.info("Skipping already processed security scan task: taskId={}, versionId={}", payload.taskId(), payload.versionId());
|
||||
return;
|
||||
}
|
||||
RLock processingLock = redissonClient.getLock("skillhub:scan:processing:" + payload.taskId());
|
||||
boolean acquired = false;
|
||||
try {
|
||||
acquired = processingLock.tryLock();
|
||||
if (!acquired) {
|
||||
log.info("Skipping concurrently processed security scan task: taskId={}, versionId={}",
|
||||
payload.taskId(), payload.versionId());
|
||||
payload.skipCleanup();
|
||||
// A normal return is treated as success by AbstractStreamConsumer and ACKs
|
||||
// the Redis entry. Requeue through the common failure path instead, so a
|
||||
// reclaimed duplicate cannot erase the only durable delivery while the active
|
||||
// scanner still owns the task lock.
|
||||
throw new ConcurrentScanInProgressException(payload.taskId());
|
||||
}
|
||||
if (securityScanService.isTaskAlreadyProcessed(payload.taskId())) {
|
||||
return;
|
||||
}
|
||||
executeScan(payload);
|
||||
} finally {
|
||||
if (acquired && processingLock.isHeldByCurrentThread()) {
|
||||
processingLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void executeScan(ScanTaskPayload payload) {
|
||||
String skillPath = resolveWorkingSkillPath(payload);
|
||||
SecurityScanRequest request = new SecurityScanRequest(
|
||||
payload.taskId(),
|
||||
payload.versionId(),
|
||||
skillPath,
|
||||
Map.of()
|
||||
);
|
||||
payload.taskId(), payload.versionId(), skillPath, Map.of());
|
||||
SecurityScanResponse response = securityScanner.scan(request);
|
||||
securityScanService.processScanResult(payload.versionId(), payload.scannerType(), response);
|
||||
}
|
||||
|
||||
private static final class ConcurrentScanInProgressException extends RuntimeException {
|
||||
private ConcurrentScanInProgressException(String taskId) {
|
||||
super("Security scan is already in progress: taskId=" + taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void markCompleted(ScanTaskPayload payload) {
|
||||
cleanupTempPath(payload.cleanupPath());
|
||||
|
|
@ -259,6 +295,7 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
private final ScannerType scannerType;
|
||||
private final int retryCount;
|
||||
private String workingSkillPath;
|
||||
private boolean cleanupEnabled = true;
|
||||
|
||||
protected ScanTaskPayload(String taskId, Long versionId, String skillPath, String bundleKey, ScannerType scannerType) {
|
||||
this(taskId, versionId, skillPath, bundleKey, scannerType, 0);
|
||||
|
|
@ -307,9 +344,16 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
}
|
||||
|
||||
protected String cleanupPath() {
|
||||
if (!cleanupEnabled) {
|
||||
return null;
|
||||
}
|
||||
return workingSkillPath != null ? workingSkillPath : skillPath;
|
||||
}
|
||||
|
||||
protected void skipCleanup() {
|
||||
cleanupEnabled = false;
|
||||
}
|
||||
|
||||
protected String workingSkillPath() {
|
||||
return workingSkillPath;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
package com.iflytek.skillhub.task;
|
||||
|
||||
import com.iflytek.skillhub.domain.security.ScanTaskOutbox;
|
||||
import com.iflytek.skillhub.domain.security.ScanTaskOutboxRepository;
|
||||
import com.iflytek.skillhub.domain.security.ScanTaskProducer;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "skillhub.security.scanner", name = "enabled", havingValue = "true")
|
||||
public class ScanTaskOutboxDispatcher {
|
||||
private static final Logger log = LoggerFactory.getLogger(ScanTaskOutboxDispatcher.class);
|
||||
|
||||
private final ScanTaskOutboxRepository repository;
|
||||
private final ScanTaskProducer producer;
|
||||
private final SkillVersionRepository versionRepository;
|
||||
private final Clock clock;
|
||||
private final int batchSize;
|
||||
private final int maxAttempts;
|
||||
private final Duration lease;
|
||||
private final Duration maxBackoff;
|
||||
|
||||
public ScanTaskOutboxDispatcher(ScanTaskOutboxRepository repository,
|
||||
ScanTaskProducer producer,
|
||||
SkillVersionRepository versionRepository,
|
||||
Clock clock,
|
||||
@Value("${skillhub.security.outbox.batch-size:50}") int batchSize,
|
||||
@Value("${skillhub.security.outbox.max-attempts:10}") int maxAttempts,
|
||||
@Value("${skillhub.security.outbox.lease:PT2M}") Duration lease,
|
||||
@Value("${skillhub.security.outbox.max-backoff:PT5M}") Duration maxBackoff) {
|
||||
this.repository = repository;
|
||||
this.producer = producer;
|
||||
this.versionRepository = versionRepository;
|
||||
this.clock = clock;
|
||||
this.batchSize = batchSize;
|
||||
if (maxAttempts < 1) {
|
||||
throw new IllegalArgumentException("maxAttempts must be at least 1");
|
||||
}
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.lease = lease;
|
||||
this.maxBackoff = maxBackoff;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${skillhub.security.outbox.dispatch-interval-ms:5000}")
|
||||
@Transactional
|
||||
public void dispatch() {
|
||||
Instant now = Instant.now(clock);
|
||||
for (ScanTaskOutbox outbox : repository.findDispatchable(now, batchSize)) {
|
||||
if (!outbox.claim(now, lease)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
producer.publishScanTask(outbox.toScanTask());
|
||||
outbox.markSent(Instant.now(clock));
|
||||
repository.save(outbox);
|
||||
} catch (Exception e) {
|
||||
handlePublishFailure(outbox, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handlePublishFailure(ScanTaskOutbox outbox, Exception error) {
|
||||
Instant now = Instant.now(clock);
|
||||
int nextAttempt = outbox.getRetryCount() + 1;
|
||||
if (nextAttempt >= maxAttempts) {
|
||||
outbox.markFailed(now, error.toString());
|
||||
repository.save(outbox);
|
||||
versionRepository.findById(outbox.getVersionId())
|
||||
.filter(version -> version.getStatus() == SkillVersionStatus.SCANNING)
|
||||
.ifPresent(version -> {
|
||||
version.setStatus(SkillVersionStatus.SCAN_FAILED);
|
||||
versionRepository.save(version);
|
||||
});
|
||||
log.error("Scan task publish failed permanently: taskId={}, versionId={}, attempts={}",
|
||||
outbox.getTaskId(), outbox.getVersionId(), outbox.getRetryCount(), error);
|
||||
return;
|
||||
}
|
||||
Duration delay = retryDelay(nextAttempt);
|
||||
outbox.markRetry(now, delay, error.toString());
|
||||
repository.save(outbox);
|
||||
log.warn("Failed to publish scan task; will retry taskId={}, retryCount={}, nextDelay={}",
|
||||
outbox.getTaskId(), outbox.getRetryCount(), delay, error);
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 20 2 * * ?")
|
||||
@Transactional
|
||||
public void cleanupSent() {
|
||||
int deleted = repository.deleteSentBefore(Instant.now(clock).minus(Duration.ofDays(7)));
|
||||
if (deleted > 0) {
|
||||
log.info("Cleaned up {} sent scan outbox records", deleted);
|
||||
}
|
||||
}
|
||||
|
||||
private Duration retryDelay(int retryCount) {
|
||||
long seconds = Math.min(maxBackoff.toSeconds(), 1L << Math.min(retryCount, 16));
|
||||
return Duration.ofSeconds(Math.max(seconds, 1));
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ server:
|
|||
spring:
|
||||
messages:
|
||||
basename: messages
|
||||
fallback-to-system-locale: false
|
||||
application:
|
||||
name: skillhub
|
||||
lifecycle:
|
||||
|
|
@ -71,6 +72,7 @@ spring:
|
|||
client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab}
|
||||
provider:
|
||||
github:
|
||||
api-base-url: ${OAUTH2_GITHUB_API_BASE_URL:https://api.github.com}
|
||||
user-info-uri: https://api.github.com/user
|
||||
gitlab:
|
||||
authorization-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/authorize
|
||||
|
|
@ -116,6 +118,11 @@ skillhub:
|
|||
code-expiry: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:PT10M}
|
||||
email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local}
|
||||
email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub}
|
||||
namespace:
|
||||
# Whether a newly activated account gets a namespace of its own.
|
||||
# Slug and display-name templates use safe code defaults in PersonalNamespaceProvisioningProperties.
|
||||
personal-provisioning:
|
||||
enabled: ${SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED:true}
|
||||
public:
|
||||
base-url: ${SKILLHUB_PUBLIC_BASE_URL:}
|
||||
access-policy:
|
||||
|
|
@ -151,6 +158,26 @@ skillhub:
|
|||
candidate-multiplier: 8
|
||||
max-candidates: 120
|
||||
ratelimit:
|
||||
# Master switch for per-endpoint request rate limiting. Set false (or
|
||||
# SKILLHUB_RATELIMIT_ENABLED=false) to turn quota checks off entirely.
|
||||
enabled: ${SKILLHUB_RATELIMIT_ENABLED:true}
|
||||
# Per-category threshold overrides. Unset categories use the built-in
|
||||
# @RateLimit annotation defaults, so this block is optional and changes
|
||||
# nothing until you set a value. Each override applies to every endpoint
|
||||
# sharing that category. Override via env vars, e.g.:
|
||||
# SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_AUTHENTICATED=120
|
||||
# SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_ANONYMOUS=40
|
||||
# SKILLHUB_RATELIMIT_CATEGORIES_PUBLISH_WINDOW_SECONDS=3600
|
||||
# categories:
|
||||
# search:
|
||||
# authenticated: 60
|
||||
# anonymous: 20
|
||||
# download:
|
||||
# authenticated: 120
|
||||
# anonymous: 30
|
||||
# publish:
|
||||
# authenticated: 10
|
||||
# window-seconds: 60
|
||||
download:
|
||||
anonymous-cookie-name: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_NAME:skillhub_anon_dl}
|
||||
anonymous-cookie-max-age: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_MAX_AGE:P30D}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
CREATE TABLE scan_task_outbox (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id VARCHAR(100) NOT NULL,
|
||||
version_id BIGINT NOT NULL,
|
||||
skill_path VARCHAR(1000),
|
||||
bundle_key VARCHAR(1000),
|
||||
publisher_id VARCHAR(255),
|
||||
status VARCHAR(20) NOT NULL,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
lease_until TIMESTAMPTZ,
|
||||
last_error VARCHAR(2000),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
entity_version BIGINT NOT NULL DEFAULT 0,
|
||||
CONSTRAINT uk_scan_task_outbox_task_id UNIQUE (task_id),
|
||||
CONSTRAINT ck_scan_task_outbox_status CHECK (status IN ('PENDING', 'SENDING', 'SENT', 'FAILED'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scan_task_outbox_pending
|
||||
ON scan_task_outbox (status, next_attempt_at, created_at);
|
||||
CREATE INDEX idx_scan_task_outbox_lease
|
||||
ON scan_task_outbox (status, lease_until);
|
||||
CREATE INDEX idx_scan_task_outbox_version
|
||||
ON scan_task_outbox (version_id);
|
||||
|
||||
ALTER TABLE security_audit ADD COLUMN task_id VARCHAR(100);
|
||||
CREATE INDEX idx_security_audit_task_id ON security_audit (task_id);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE scan_task_outbox
|
||||
ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
|
@ -54,6 +54,7 @@ error.forbidden=Forbidden
|
|||
error.apiToken.scope.missing=API token is missing required scope: {0}
|
||||
error.apiToken.endpoint.unsupported=API token cannot access endpoint: {0}
|
||||
error.request.timeout=Request timed out
|
||||
error.request.include.unsupported=Unsupported include option: {0}
|
||||
error.rateLimit.exceeded=Rate limit exceeded
|
||||
error.storage.unavailable=Object storage is temporarily unavailable. Please try again later.
|
||||
error.internal=An unexpected error occurred
|
||||
|
|
@ -184,3 +185,4 @@ promotion.status.invalid=Unsupported promotion status: {0}
|
|||
promotion.sort.field.invalid=Unsupported promotion sort field: {0}
|
||||
promotion.sort.direction.invalid=Unsupported promotion sort direction: {0}
|
||||
promotion.sort.pending_unsupported=Pending promotion requests do not support reviewed-time sorting
|
||||
error.skill.subscription.noPermission=You do not have permission to subscribe to this skill.
|
||||
|
|
|
|||
179
server/skillhub-app/src/main/resources/messages_ru.properties
Normal file
179
server/skillhub-app/src/main/resources/messages_ru.properties
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
response.success=Успешно
|
||||
response.success.read=Успешно получено
|
||||
response.success.created=Успешно создано
|
||||
response.success.updated=Успешно обновлено
|
||||
response.success.deleted=Успешно удалено
|
||||
response.success.published=Успешно опубликовано
|
||||
response.success.revoked=Успешно отозвано
|
||||
response.success.health=Сервис работает
|
||||
validation.namespace.slug.notBlank=Slug не может быть пустым
|
||||
validation.namespace.slug.size=Slug должен содержать от 2 до 64 символов
|
||||
validation.namespace.displayName.notBlank=Отображаемое имя не может быть пустым
|
||||
validation.namespace.displayName.size=Отображаемое имя не должно превышать 128 символов
|
||||
validation.namespace.description.size=Описание не должно превышать 512 символов
|
||||
validation.member.userId.notNull=Требуется ID пользователя
|
||||
validation.member.role.notNull=Требуется роль
|
||||
validation.auth.local.username.notBlank=Имя пользователя не может быть пустым
|
||||
validation.auth.local.password.notBlank=Пароль не может быть пустым
|
||||
validation.auth.local.email.notBlank=Email не может быть пустым
|
||||
validation.auth.local.currentPassword.notBlank=Текущий пароль не может быть пустым
|
||||
validation.auth.local.newPassword.notBlank=Новый пароль не может быть пустым
|
||||
validation.auth.local.email.invalid=Некорректный формат email
|
||||
validation.token.name.notBlank=Имя токена не может быть пустым
|
||||
validation.token.name.size=Имя токена должно содержать не более 64 символов
|
||||
validation.token.expiresAt.invalid=Некорректный формат времени истечения
|
||||
validation.token.expiresAt.future=Время истечения должно быть в будущем
|
||||
error.token.name.duplicate=У вас уже есть токен с таким именем
|
||||
error.token.notFound=Токен не найден: {0}
|
||||
error.auth.required=Требуется аутентификация
|
||||
error.auth.local.username.exists=Имя пользователя уже занято
|
||||
error.auth.local.email.exists=Email уже занят
|
||||
error.auth.local.password.tooShort=Пароль должен содержать не менее 8 символов
|
||||
error.auth.local.password.tooLong=Пароль не должен превышать 128 символов
|
||||
error.auth.local.password.tooWeak=Пароль должен включать не менее 3 типов символов
|
||||
error.auth.local.username.invalid=Имя пользователя: 3–64 символа, только буквы, цифры или подчёркивания
|
||||
error.auth.local.invalidCredentials=Неверное имя пользователя или пароль
|
||||
error.auth.local.notEnabled=Вход по локальной учётной записи для этого пользователя не включён
|
||||
error.auth.local.accountDisabled=Эта учётная запись отключена
|
||||
error.auth.local.accountPending=Эта учётная запись ожидает активации
|
||||
error.auth.local.accountMerged=Эта учётная запись объединена и больше не может использоваться для входа
|
||||
error.auth.local.locked=Слишком много неудачных попыток. Повторите через {0} мин.
|
||||
error.auth.login.throttled=Слишком много попыток входа. Повторите через {0} мин.
|
||||
error.auth.direct.disabled=Совместимость прямой аутентификации отключена
|
||||
error.auth.direct.providerUnsupported=Неподдерживаемый провайдер прямой аутентификации: {0}
|
||||
error.auth.sessionBootstrap.disabled=Инициализация сессии отключена
|
||||
error.auth.sessionBootstrap.providerUnsupported=Неподдерживаемый провайдер инициализации сессии: {0}
|
||||
error.auth.sessionBootstrap.notAuthenticated=Внешняя аутентифицированная сессия не найдена
|
||||
error.badRequest=Некорректный запрос
|
||||
error.methodNotAllowed=HTTP-метод не поддерживается
|
||||
error.unsupportedMediaType=Неподдерживаемый тип медиа
|
||||
error.notAcceptable=Запрошенный тип ответа не поддерживается
|
||||
error.forbidden=Доступ запрещён
|
||||
error.apiToken.scope.missing=У API-токена отсутствует требуемая область доступа: {0}
|
||||
error.apiToken.endpoint.unsupported=API-токен не может обращаться к эндпоинту: {0}
|
||||
error.request.timeout=Время ожидания запроса истекло
|
||||
error.rateLimit.exceeded=Превышен лимит запросов
|
||||
error.storage.unavailable=Объектное хранилище временно недоступно. Повторите попытку позже.
|
||||
error.internal=Произошла непредвиденная ошибка
|
||||
error.slug.blank=Slug не может быть пустым
|
||||
error.slug.length=Длина slug должна быть от {0} до {1} символов
|
||||
error.slug.pattern=Slug может содержать только строчные буквы, цифры и дефисы и должен начинаться и заканчиваться буквой или цифрой
|
||||
error.slug.doubleHyphen=Slug не может содержать два дефиса подряд
|
||||
error.slug.reserved=Slug ''{0}'' зарезервирован и не может быть использован
|
||||
label.definition.too_many=Достигнут лимит определений меток ({0})
|
||||
label.sort_order.empty=Полезная нагрузка обновления порядка сортировки не может быть пустой
|
||||
label.translation.empty=Требуется хотя бы один перевод метки
|
||||
label.translation.locale.blank=Локаль перевода метки не может быть пустой
|
||||
label.translation.display_name.blank=Отображаемое имя перевода метки не может быть пустым
|
||||
label.translation.locale.duplicate=Дублирующаяся локаль перевода метки: {0}
|
||||
label.translation.locale.conflict=Дублирующаяся локаль перевода метки
|
||||
error.namespace.slug.exists=Slug пространства имён ''{0}'' уже существует
|
||||
error.namespace.id.notFound=Пространство имён не найдено: {0}
|
||||
error.namespace.slug.notFound=Пространство имён не найдено: {0}
|
||||
error.namespace.membership.required=Требуется членство в пространстве имён
|
||||
error.namespace.global.members.platformAdmin.required=Только администраторы пользователей платформы могут просматривать участников глобального пространства имён
|
||||
error.namespace.admin.required=Требуется роль владельца или администратора пространства имён
|
||||
error.namespace.owner.required=Требуется роль владельца пространства имён
|
||||
error.namespace.create.platformAdminRequired=Создавать пространства имён могут только SKILL_ADMIN или SUPER_ADMIN
|
||||
error.namespace.delete.hasDependencies=Пространство имён нельзя удалить, пока в нём есть скиллы или записи управления
|
||||
error.namespace.member.owner.assignDirect=Нельзя назначить роль OWNER напрямую
|
||||
error.namespace.member.alreadyExists=Пользователь уже является участником пространства имён
|
||||
error.namespace.member.notFound=Участник не найден
|
||||
error.namespace.member.owner.remove=Нельзя удалить владельца пространства имён
|
||||
error.namespace.member.owner.setDirect=Нельзя задать роль OWNER напрямую, используйте передачу владения
|
||||
error.namespace.member.search.tooShort=Поисковый запрос должен содержать не менее 2 символов
|
||||
error.namespace.owner.current.notFound=Текущий владелец не найден
|
||||
error.namespace.owner.current.invalid=Текущий пользователь не является владельцем пространства имён
|
||||
error.namespace.owner.new.notFound=Новый владелец не является участником пространства имён
|
||||
error.skill.metadata.content.empty=Содержимое SKILL.md не может быть пустым
|
||||
error.skill.metadata.frontmatter.missingStart=Отсутствует начальный маркер frontmatter ''---''
|
||||
error.skill.metadata.frontmatter.missingContent=Отсутствует содержимое frontmatter после начального маркера
|
||||
error.skill.metadata.frontmatter.missingEnd=Отсутствует конечный маркер frontmatter ''---''
|
||||
error.skill.metadata.yaml.notMap=Frontmatter должен быть YAML-объектом
|
||||
error.skill.metadata.yaml.invalid=Некорректный YAML во frontmatter: {0}
|
||||
error.skill.metadata.requiredField.missing=Отсутствует обязательное поле: {0}
|
||||
error.skill.metadata.compliance.invalid=Некорректные метаданные x-astron-compliance: {0}
|
||||
error.skill.publish.publisher.notMember=Публикующий не является участником пространства имён: {0}
|
||||
error.skill.publish.package.invalid=Проверка пакета не пройдена: {0}
|
||||
error.skill.publish.skillMd.notFound=SKILL.md не найден
|
||||
error.skill.publish.precheck.confirmRequired=Предупреждения перед публикацией требуют подтверждения:\n{0}
|
||||
error.skill.publish.precheck.failed=Проверка перед публикацией не пройдена: {0}
|
||||
error.security.scanner.required=Перед публикацией публичных или видимых в пространстве имён скиллов необходимо включить сканер безопасности
|
||||
error.skill.publish.archived=Архивный скилл нужно восстановить перед публикацией: {0}
|
||||
review.withdraw.not_pending=Отозвать можно только заявки на ревью со статусом pending: {0}
|
||||
review.withdraw.not_submitter=Отозвать это ревью может только отправитель
|
||||
review.approve.scan_in_progress=Сканирование безопасности ещё выполняется. Одобрение недоступно до завершения сканирования.
|
||||
review_task.not_found_for_version=Не найдена ожидающая заявка на ревью для версии: {0}
|
||||
error.skill.publish.summary.tooLong=Описание скилла не должно превышать {0} символов
|
||||
error.skill.notFound=Скилл не найден: {0}
|
||||
error.skill.access.denied=Нет доступа к скиллу: {0}
|
||||
error.skill.status.notActive=Скилл не активен
|
||||
error.skill.lifecycle.noPermission=Управлять этим скиллом может только владелец скилла или администратор пространства имён
|
||||
error.skill.version.exists=Версия уже существует: {0}
|
||||
error.skill.version.notFound=Версия не найдена: {0}
|
||||
error.skill.version.notPublished=Версия не опубликована: {0}
|
||||
error.skill.version.delete.unsupported=Удалять можно только версии в статусах DRAFT, UPLOADED, REJECTED или SCAN_FAILED: {0}
|
||||
error.skill.version.delete.lastVersion=Нельзя удалить последнюю оставшуюся версию: {0}
|
||||
error.skill.version.compare.same=Нельзя сравнить версию саму с собой
|
||||
error.skill.report.reason.required=Укажите причину жалобы
|
||||
error.skill.report.unavailable=Сейчас на этот скилл нельзя пожаловаться: {0}
|
||||
error.skill.report.self=Нельзя пожаловаться на свой собственный скилл
|
||||
error.skill.report.duplicate=У вас уже есть ожидающая жалоба на этот скилл
|
||||
error.skill.report.notFound=Жалоба на скилл не найдена: {0}
|
||||
error.skill.report.alreadyHandled=Эта жалоба на скилл уже обработана
|
||||
error.skill.report.status.invalid=Неподдерживаемый статус жалобы на скилл: {0}
|
||||
error.skill.version.latest.unavailable=Нет опубликованной версии для скилла: {0}
|
||||
error.skill.version.latest.notFound=Последняя опубликованная версия не найдена
|
||||
error.skill.file.notFound=Файл не найден: {0}
|
||||
error.skill.tag.latest.reserved=Имя тега ''latest'' зарезервировано
|
||||
error.skill.tag.latest.delete=Имя тега ''latest'' зарезервировано и не может быть удалено
|
||||
error.skill.tag.notFound=Тег не найден: {0}
|
||||
error.skill.tag.targetVersion.notPublished=Целевая версия должна быть опубликована
|
||||
error.skill.tag.version.missing=Тег не указывает на версию: {0}
|
||||
error.skill.tag.version.notFound=Версия, на которую указывает тег, не найдена: {0}
|
||||
error.skill.bundle.notFound=Опубликованный пакет не найден в хранилище
|
||||
error.skill.resolve.versionTag.conflict=Параметры version и tag нельзя использовать вместе
|
||||
error.deviceAuth.userCode.invalid=Неверный или просроченный код пользователя
|
||||
error.deviceAuth.deviceCode.expired=Код устройства истёк
|
||||
error.deviceAuth.deviceCode.invalid=Код устройства истёк или недействителен
|
||||
error.deviceAuth.deviceCode.used=Код устройства уже использован
|
||||
error.admin.user.notFound=Пользователь не найден: {0}
|
||||
error.admin.user.role.invalid=Неверная роль: {0}
|
||||
error.admin.user.role.superAdmin.assignDenied=Изменять состояние роли SUPER_ADMIN может только SUPER_ADMIN
|
||||
error.admin.user.systemAccount.immutable=Системные учётные записи нельзя изменять из управления пользователями
|
||||
error.admin.user.status.invalid=Неверный статус пользователя: {0}
|
||||
error.admin.user.status.unsupported=Здесь можно управлять только статусами ACTIVE или DISABLED
|
||||
error.skill.publish.nameConflict=Опубликованный скилл с именем ''{0}'' уже существует в этом пространстве имён
|
||||
error.skill.publish.nameConflict.private=Приватный скилл с именем ''{0}'' уже опубликован в этом пространстве имён
|
||||
error.skill.approve.nameConflict=Нельзя одобрить: опубликованный скилл с именем ''{0}'' уже существует в этом пространстве имён
|
||||
error.skill.version.submit.notUploaded=Версия ''{0}'' не в статусе UPLOADED и не может быть отправлена на ревью
|
||||
error.skill.version.confirm.notUploaded=Версия ''{0}'' не в статусе UPLOADED и не может быть подтверждена
|
||||
error.skill.confirm.notPrivate=Confirm-publish доступен только для PRIVATE скиллов
|
||||
error.skill.version.notDownloadable=Версия ''{0}'' недоступна для скачивания
|
||||
error.profile.displayName.length=Отображаемое имя должно содержать от 2 до 32 символов
|
||||
error.profile.displayName.pattern=Отображаемое имя может содержать только китайские иероглифы, латинские буквы, цифры, пробелы, подчёркивания и дефисы
|
||||
error.profile.noChanges=Необходимо указать хотя бы одно поле
|
||||
response.profile.updated=Профиль успешно обновлён
|
||||
response.profile.pendingReview=Изменения профиля отправлены на ревью
|
||||
error.profileReview.notFound=Заявка на изменение профиля не найдена
|
||||
error.profileReview.notPending=Эта заявка уже рассмотрена
|
||||
error.profileReview.commentRequired=Требуется причина отклонения
|
||||
error.profileReview.commentTooLong=Причина отклонения не должна превышать 500 символов
|
||||
error.profileReview.status.invalid=Неверный статус ревью: {0}
|
||||
error.profileReview.userDisabled=Нельзя применить изменения — учётная запись пользователя отключена
|
||||
response.auth.password.reset.requested=Если учётная запись подходит, код подтверждения для сброса пароля отправлен.
|
||||
response.auth.password.reset.confirmed=Пароль успешно сброшен. Войдите с новым паролем.
|
||||
error.auth.password.reset.invalid.code=Код подтверждения недействителен или истёк.
|
||||
error.auth.password.reset.not.eligible=Эта учётная запись не подходит для сброса пароля.
|
||||
error.auth.password.reset.no.credential=У этой учётной записи нет локальных учётных данных.
|
||||
error.auth.password.reset.email.failed=Не удалось отправить код подтверждения для сброса пароля. Повторите попытку позже.
|
||||
validation.auth.password.reset.email.notBlank=Email не может быть пустым
|
||||
validation.auth.password.reset.email.invalid=Некорректный формат email
|
||||
validation.auth.password.reset.code.notBlank=Код подтверждения не может быть пустым
|
||||
validation.auth.password.reset.code.invalid=Код подтверждения должен состоять из 6 цифр
|
||||
validation.auth.password.reset.newPassword.notBlank=Новый пароль не может быть пустым
|
||||
promotion.target_skill_conflict=Целевой глобальный скилл "{0}" уже существует
|
||||
promotion.status.invalid=Неподдерживаемый статус продвижения: {0}
|
||||
promotion.sort.field.invalid=Неподдерживаемое поле сортировки продвижения: {0}
|
||||
promotion.sort.direction.invalid=Неподдерживаемое направление сортировки продвижения: {0}
|
||||
promotion.sort.pending_unsupported=Ожидающие заявки на продвижение не поддерживают сортировку по времени ревью
|
||||
|
|
@ -54,6 +54,7 @@ error.forbidden=没有权限执行该操作
|
|||
error.apiToken.scope.missing=API 令牌缺少所需权限范围:{0}
|
||||
error.apiToken.endpoint.unsupported=API 令牌无法访问接口:{0}
|
||||
error.request.timeout=请求超时
|
||||
error.request.include.unsupported=不支持的 include 参数:{0}
|
||||
error.rateLimit.exceeded=请求过于频繁,请稍后再试
|
||||
error.storage.unavailable=对象存储暂时不可用,请稍后再试
|
||||
error.internal=服务器内部错误
|
||||
|
|
@ -184,3 +185,4 @@ promotion.status.invalid=不支持的提升审核状态:{0}
|
|||
promotion.sort.field.invalid=不支持的提升审核排序字段:{0}
|
||||
promotion.sort.direction.invalid=不支持的提升审核排序方向:{0}
|
||||
promotion.sort.pending_unsupported=待审核提升请求不支持按处理时间排序
|
||||
error.skill.subscription.noPermission=您没有订阅此技能的权限。
|
||||
|
|
|
|||
|
|
@ -15,8 +15,13 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
|||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
|
||||
import com.iflytek.skillhub.domain.social.SkillStarService;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubSkillListResponse;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||
import com.iflytek.skillhub.service.SkillLabelProjectionService;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -31,6 +36,7 @@ class ClawHubCompatAppServiceTest {
|
|||
private final AuditLogService auditLogService = mock(AuditLogService.class);
|
||||
private final CompatSkillLookupService compatSkillLookupService = mock(CompatSkillLookupService.class);
|
||||
private final SkillStarService skillStarService = mock(SkillStarService.class);
|
||||
private final SkillLabelProjectionService skillLabelProjectionService = mock(SkillLabelProjectionService.class);
|
||||
|
||||
private final ClawHubCompatAppService service = new ClawHubCompatAppService(
|
||||
new CanonicalSlugMapper(),
|
||||
|
|
@ -42,7 +48,8 @@ class ClawHubCompatAppServiceTest {
|
|||
auditLogService,
|
||||
compatSkillLookupService,
|
||||
skillStarService,
|
||||
new RequestIdAccessor()
|
||||
new RequestIdAccessor(),
|
||||
skillLabelProjectionService
|
||||
);
|
||||
|
||||
@Test
|
||||
|
|
@ -120,4 +127,35 @@ class ClawHubCompatAppServiceTest {
|
|||
|
||||
assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/versions/20260707.025847/download");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listSkills_omitsLabelsByDefault() {
|
||||
when(skillSearchAppService.search("", null, "newest", 0, 25, null, Map.of()))
|
||||
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25));
|
||||
|
||||
ClawHubSkillListResponse response = service.listSkills(0, 25, null, null, Map.of());
|
||||
|
||||
assertThat(response.items()).hasSize(1);
|
||||
assertThat(response.items().get(0).labels()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listSkills_returnsLabelsWhenRequested() {
|
||||
when(skillSearchAppService.search("", null, "newest", 0, 25, null, Map.of()))
|
||||
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25));
|
||||
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L)))
|
||||
.thenReturn(Map.of(7L, List.of(new SkillLabelDto("automation", "RECOMMENDED", "Automation"))));
|
||||
|
||||
ClawHubSkillListResponse response = service.listSkills(0, 25, null, true, null, Map.of());
|
||||
|
||||
assertThat(response.items().get(0).labels())
|
||||
.extracting(SkillLabelDto::slug)
|
||||
.containsExactly("automation");
|
||||
}
|
||||
|
||||
private static SkillSummaryResponse summary(Long id) {
|
||||
return new SkillSummaryResponse(
|
||||
id, "demo-skill", "Demo Skill", "A demo", "PUBLIC", "PUBLISHED",
|
||||
0L, 0, null, 0, "global", null, false, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
|||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.service.SkillLabelProjectionService;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
|
@ -48,6 +50,7 @@ import static org.mockito.ArgumentMatchers.anyMap;
|
|||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
|
@ -72,6 +75,9 @@ class ClawHubCompatControllerTest {
|
|||
@MockBean
|
||||
private SkillSearchAppService skillSearchAppService;
|
||||
|
||||
@MockBean
|
||||
private SkillLabelProjectionService skillLabelProjectionService;
|
||||
|
||||
@MockBean
|
||||
private SkillQueryService skillQueryService;
|
||||
|
||||
|
|
@ -110,6 +116,8 @@ class ClawHubCompatControllerTest {
|
|||
2,
|
||||
"global",
|
||||
Instant.parse("2026-03-13T09:00:00Z"),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"),
|
||||
new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"),
|
||||
|
|
@ -154,6 +162,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 +444,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",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ class ClawHubRegistryFacadeTest {
|
|||
2,
|
||||
"global",
|
||||
updatedAt,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"),
|
||||
new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
package com.iflytek.skillhub.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.SystemEnvironmentPropertySource;
|
||||
|
||||
class RateLimitPropertiesTest {
|
||||
|
||||
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||
.withUserConfiguration(TestConfiguration.class);
|
||||
|
||||
@Test
|
||||
void enabledByDefaultAndNoOverrides() {
|
||||
RateLimitProperties properties = new RateLimitProperties();
|
||||
|
||||
assertThat(properties.isEnabled()).isTrue();
|
||||
assertThat(properties.getCategories()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToAnnotationDefaultsWhenCategoryUnset() {
|
||||
RateLimitProperties properties = new RateLimitProperties();
|
||||
|
||||
assertThat(properties.authenticatedFor("search", 60)).isEqualTo(60);
|
||||
assertThat(properties.anonymousFor("search", 20)).isEqualTo(20);
|
||||
assertThat(properties.windowSecondsFor("search", 60)).isEqualTo(60);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overridesOnlyTheFieldsThatAreSet() {
|
||||
RateLimitProperties.CategoryLimit search = new RateLimitProperties.CategoryLimit();
|
||||
search.setAuthenticated(120);
|
||||
// anonymous and windowSeconds intentionally left null
|
||||
|
||||
RateLimitProperties properties = new RateLimitProperties();
|
||||
properties.getCategories().put("search", search);
|
||||
|
||||
assertThat(properties.authenticatedFor("search", 60)).isEqualTo(120);
|
||||
assertThat(properties.anonymousFor("search", 20)).isEqualTo(20);
|
||||
assertThat(properties.windowSecondsFor("search", 60)).isEqualTo(60);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overrideAppliesPerCategoryOnly() {
|
||||
RateLimitProperties.CategoryLimit publish = new RateLimitProperties.CategoryLimit();
|
||||
publish.setAuthenticated(5);
|
||||
publish.setWindowSeconds(3600);
|
||||
|
||||
RateLimitProperties properties = new RateLimitProperties();
|
||||
properties.getCategories().put("publish", publish);
|
||||
|
||||
assertThat(properties.authenticatedFor("publish", 10)).isEqualTo(5);
|
||||
assertThat(properties.windowSecondsFor("publish", 60)).isEqualTo(3600);
|
||||
// A different category is unaffected.
|
||||
assertThat(properties.authenticatedFor("download", 120)).isEqualTo(120);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindsDocumentedCategoryOverrideFromEnvironmentVariable() {
|
||||
// The *-systemEnvironment suffix activates Spring Boot's environment-variable name adaptation.
|
||||
contextRunner.withInitializer(context -> context.getEnvironment().getPropertySources().addFirst(
|
||||
new SystemEnvironmentPropertySource("test-systemEnvironment", Map.of(
|
||||
"SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_AUTHENTICATED", "120"))))
|
||||
.run(context -> assertThat(context.getBean(RateLimitProperties.class)
|
||||
.authenticatedFor("search", 60)).isEqualTo(120));
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(RateLimitProperties.class)
|
||||
static class TestConfiguration {
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
|
|
@ -60,4 +61,10 @@ class LabelControllerTest {
|
|||
.andExpect(jsonPath("$.data[0].slug").value("code-generation"))
|
||||
.andExpect(jsonPath("$.data[0].displayName").value("Code Generation"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelMutationShouldNotUseThePublicGetOnlySecurityChain() throws Exception {
|
||||
mockMvc.perform(post("/api/v1/labels"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ class MeControllerTest {
|
|||
0,
|
||||
"team-ai",
|
||||
Instant.parse("2026-03-17T12:00:00Z"),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"),
|
||||
new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.service.SkillLabelProjectionService;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
|
@ -15,6 +18,7 @@ import java.util.Map;
|
|||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
|
|
@ -34,6 +38,9 @@ class SkillSearchControllerTest {
|
|||
@MockBean
|
||||
private SkillSearchAppService skillSearchAppService;
|
||||
|
||||
@MockBean
|
||||
private SkillLabelProjectionService skillLabelProjectionService;
|
||||
|
||||
@Test
|
||||
void searchShouldUseUnifiedEnvelopeAndItemsField() throws Exception {
|
||||
when(skillSearchAppService.search(
|
||||
|
|
@ -143,4 +150,59 @@ class SkillSearchControllerTest {
|
|||
.andExpect(jsonPath("$.data.page").value(0))
|
||||
.andExpect(jsonPath("$.data.size").value(20));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchShouldOmitLabelsUnlessRequested() throws Exception {
|
||||
when(skillSearchAppService.search(
|
||||
eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any()))
|
||||
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20));
|
||||
|
||||
mockMvc.perform(get("/api/web/skills"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].slug").value("demo-skill"))
|
||||
.andExpect(jsonPath("$.data.items[0].labels").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchShouldReturnLabelsWhenRequested() throws Exception {
|
||||
when(skillSearchAppService.search(
|
||||
eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any()))
|
||||
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20));
|
||||
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L)))
|
||||
.thenReturn(Map.of(7L, List.of(new SkillLabelDto("automation", "TOPIC", "Automation"))));
|
||||
|
||||
mockMvc.perform(get("/api/web/skills").param("include", "labels"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].labels[0].slug").value("automation"))
|
||||
.andExpect(jsonPath("$.data.items[0].labels[0].type").value("TOPIC"))
|
||||
.andExpect(jsonPath("$.data.items[0].labels[0].displayName").value("Automation"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchShouldReturnEmptyLabelArrayForSkillsWithoutLabels() throws Exception {
|
||||
when(skillSearchAppService.search(
|
||||
eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any()))
|
||||
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20));
|
||||
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L))).thenReturn(Map.of());
|
||||
|
||||
mockMvc.perform(get("/api/web/skills").param("include", "labels"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].labels").isArray())
|
||||
.andExpect(jsonPath("$.data.items[0].labels").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchShouldRejectUnsupportedIncludeOptions() throws Exception {
|
||||
mockMvc.perform(get("/api/web/skills").param("include", "labels,stats"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(400));
|
||||
|
||||
verifyNoInteractions(skillSearchAppService);
|
||||
}
|
||||
|
||||
private static SkillSummaryResponse summary(Long id) {
|
||||
return new SkillSummaryResponse(
|
||||
id, "demo-skill", "Demo Skill", "A demo", "PUBLIC", "PUBLISHED",
|
||||
0L, 0, null, 0, "global", null, false, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
package com.iflytek.skillhub.controller.support;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class IncludeOptionsTest {
|
||||
|
||||
@Test
|
||||
void includesLabels_acceptsAbsentBlankRepeatedAndCommaSeparatedInputs() {
|
||||
assertThat(IncludeOptions.includesLabels(null)).isFalse();
|
||||
assertThat(IncludeOptions.includesLabels(List.of("", " "))).isFalse();
|
||||
assertThat(IncludeOptions.includesLabels(List.of("labels"))).isTrue();
|
||||
assertThat(IncludeOptions.includesLabels(List.of(" LABELS "))).isTrue();
|
||||
assertThat(IncludeOptions.includesLabels(List.of("labels,"))).isTrue();
|
||||
assertThat(IncludeOptions.includesLabels(List.of("", "labels"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void includesLabels_rejectsUnsupportedOptions() {
|
||||
assertThatThrownBy(() -> IncludeOptions.includesLabels(List.of("labels,stats")))
|
||||
.isInstanceOf(DomainBadRequestException.class)
|
||||
.hasMessage("error.request.include.unsupported");
|
||||
}
|
||||
}
|
||||
|
|
@ -129,6 +129,25 @@ class SkillPackageArchiveExtractorTest {
|
|||
assertEquals("SKILL.md", entries.get(0).path());
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsWindowsStyleDirectoryEntries() throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
zos.putNextEntry(new ZipEntry("my-skill\\"));
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new ZipEntry("my-skill\\SKILL.md"));
|
||||
zos.write("---\nname: test\n---".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "test.zip", "application/zip", baos.toByteArray());
|
||||
|
||||
List<PackageEntry> entries = extractor.extract(file);
|
||||
|
||||
assertEquals(1, entries.size());
|
||||
assertEquals("SKILL.md", entries.get(0).path());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotStripWhenMultipleRootDirectories() throws Exception {
|
||||
byte[] zipBytes = createZip(Map.of(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,26 @@ class ZipPackageExtractorTest {
|
|||
assertTrue(entries.stream().noneMatch(e -> e.path().equals("skill.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsWindowsStyleDirectoryEntries() throws Exception {
|
||||
ZipPackageExtractor extractor = new ZipPackageExtractor(new SkillPublishProperties());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
zos.putNextEntry(new ZipEntry("my-skill\\"));
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new ZipEntry("my-skill/SKILL.md"));
|
||||
zos.write("---\nname: test\n---\n".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "test.zip", "application/zip", baos.toByteArray());
|
||||
|
||||
List<PackageEntry> entries = extractor.extract(file);
|
||||
|
||||
assertEquals(1, entries.size());
|
||||
assertEquals("SKILL.md", entries.get(0).path());
|
||||
}
|
||||
|
||||
private byte[] createZip(Map<String, byte[]> entries) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,17 @@ package com.iflytek.skillhub.domain.social;
|
|||
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
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.domain.user.UserStatus;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillSubscribedEvent;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillUnsubscribedEvent;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
|
@ -13,10 +24,13 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SkillSubscriptionServiceTest {
|
||||
|
|
@ -24,17 +38,34 @@ class SkillSubscriptionServiceTest {
|
|||
@Mock private SkillSubscriptionRepository subscriptionRepository;
|
||||
@Mock private SkillRepository skillRepository;
|
||||
@Mock private ApplicationEventPublisher eventPublisher;
|
||||
@Mock private NamespaceRepository namespaceRepository;
|
||||
@Mock private NamespaceMemberRepository namespaceMemberRepository;
|
||||
@Mock private UserAccountRepository userAccountRepository;
|
||||
|
||||
private SkillSubscriptionService service;
|
||||
|
||||
private void allowPublicSubscription(Skill skill) {
|
||||
skill.setLatestVersionId(10L);
|
||||
when(userAccountRepository.findById("user-1"))
|
||||
.thenReturn(Optional.of(new UserAccount("user-1", "User", null, null)));
|
||||
Namespace namespace = new Namespace("demo", "Demo", "owner");
|
||||
when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(skill.getNamespaceId(), "user-1"))
|
||||
.thenReturn(Optional.empty());
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new SkillSubscriptionService(subscriptionRepository, skillRepository, eventPublisher);
|
||||
service = new SkillSubscriptionService(subscriptionRepository, skillRepository, eventPublisher,
|
||||
namespaceRepository, namespaceMemberRepository, userAccountRepository,
|
||||
new SubscriptionMetadataAccessPolicy());
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribe_createsSubscriptionAndPublishesEvent() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class)));
|
||||
Skill skill = new Skill(5L, "public-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
allowPublicSubscription(skill);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty());
|
||||
when(subscriptionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
|
|
@ -50,7 +81,9 @@ class SkillSubscriptionServiceTest {
|
|||
|
||||
@Test
|
||||
void subscribe_idempotent_doesNotDuplicate() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class)));
|
||||
Skill skill = new Skill(5L, "public-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
allowPublicSubscription(skill);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1"))
|
||||
.thenReturn(Optional.of(mock(SkillSubscription.class)));
|
||||
|
||||
|
|
@ -102,4 +135,120 @@ class SkillSubscriptionServiceTest {
|
|||
|
||||
assertThat(service.isSubscribed(1L, "user-1")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribe_rejectsInactiveAccountWithoutMutation() {
|
||||
Skill skill = new Skill(5L, "private-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PRIVATE);
|
||||
skill.setLatestVersionId(10L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
UserAccount account = new UserAccount("user-1", "User", null, null);
|
||||
account.setStatus(UserStatus.DISABLED);
|
||||
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(account));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.subscribe(1L, "user-1"))
|
||||
.isInstanceOf(DomainForbiddenException.class);
|
||||
|
||||
verifyNoInteractions(subscriptionRepository);
|
||||
verify(skillRepository, never()).incrementSubscriptionCount(anyLong());
|
||||
verifyNoInteractions(eventPublisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribe_rejectsRemovedMemberOfArchivedNamespaceWithoutMutation() {
|
||||
Skill skill = new Skill(5L, "public-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
skill.setLatestVersionId(10L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(userAccountRepository.findById("user-1"))
|
||||
.thenReturn(Optional.of(new UserAccount("user-1", "User", null, null)));
|
||||
Namespace namespace = new Namespace("archived", "Archived", "owner");
|
||||
namespace.setStatus(NamespaceStatus.ARCHIVED);
|
||||
when(namespaceRepository.findById(5L)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(5L, "user-1")).thenReturn(Optional.empty());
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.subscribe(1L, "user-1"))
|
||||
.isInstanceOf(DomainForbiddenException.class);
|
||||
|
||||
verifyNoInteractions(subscriptionRepository);
|
||||
verify(skillRepository, never()).incrementSubscriptionCount(anyLong());
|
||||
verifyNoInteractions(eventPublisher);
|
||||
}
|
||||
|
||||
static Stream<DeniedSubscription> deniedSubscriptions() {
|
||||
return Stream.of(
|
||||
new DeniedSubscription("private member", SkillVisibility.PRIVATE, false, NamespaceStatus.ACTIVE,
|
||||
NamespaceRole.MEMBER),
|
||||
new DeniedSubscription("private nonmember", SkillVisibility.PRIVATE, false, NamespaceStatus.ACTIVE,
|
||||
null),
|
||||
new DeniedSubscription("private cross namespace member", SkillVisibility.PRIVATE, false,
|
||||
NamespaceStatus.ACTIVE, null),
|
||||
new DeniedSubscription("hidden public", SkillVisibility.PUBLIC, true, NamespaceStatus.ACTIVE,
|
||||
NamespaceRole.MEMBER)
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("deniedSubscriptions")
|
||||
void subscribe_rejectsUnauthorizedMetadataWithoutAnyMutation(DeniedSubscription scenario) {
|
||||
Skill skill = new Skill(5L, "restricted", "owner", scenario.visibility());
|
||||
skill.setLatestVersionId(10L);
|
||||
skill.setHidden(scenario.hidden());
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(userAccountRepository.findById("user-1"))
|
||||
.thenReturn(Optional.of(new UserAccount("user-1", "User", null, null)));
|
||||
Namespace namespace = new Namespace("team", "Team", "owner");
|
||||
namespace.setStatus(scenario.namespaceStatus());
|
||||
when(namespaceRepository.findById(5L)).thenReturn(Optional.of(namespace));
|
||||
if (scenario.role() == null) {
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(5L, "user-1"))
|
||||
.thenReturn(Optional.empty());
|
||||
} else {
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(5L, "user-1"))
|
||||
.thenReturn(Optional.of(new NamespaceMember(5L, "user-1", scenario.role())));
|
||||
}
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.subscribe(1L, "user-1"))
|
||||
.isInstanceOf(DomainForbiddenException.class);
|
||||
|
||||
verifyNoInteractions(subscriptionRepository);
|
||||
verify(skillRepository, never()).incrementSubscriptionCount(anyLong());
|
||||
verifyNoInteractions(eventPublisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribe_allowsPublicArchivedSkillBecauseMetadataPurposeDoesNotRequireActiveSkill() {
|
||||
Skill skill = new Skill(5L, "archived-skill", "owner", SkillVisibility.PUBLIC);
|
||||
skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED);
|
||||
skill.setLatestVersionId(10L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(userAccountRepository.findById("user-1"))
|
||||
.thenReturn(Optional.of(new UserAccount("user-1", "User", null, null)));
|
||||
when(namespaceRepository.findById(5L)).thenReturn(Optional.of(new Namespace("team", "Team", "owner")));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(5L, "user-1")).thenReturn(Optional.empty());
|
||||
when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty());
|
||||
|
||||
service.subscribe(1L, "user-1");
|
||||
|
||||
verify(subscriptionRepository).save(any(SkillSubscription.class));
|
||||
verify(skillRepository).incrementSubscriptionCount(1L);
|
||||
verify(eventPublisher).publishEvent(any(SkillSubscribedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAndDeleteDoNotConsultMetadataAuthorizationDependencies() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class)));
|
||||
SkillSubscription existing = mock(SkillSubscription.class);
|
||||
when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1"))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
assertThat(service.isSubscribed(1L, "user-1")).isTrue();
|
||||
service.unsubscribe(1L, "user-1");
|
||||
|
||||
verifyNoInteractions(namespaceRepository, namespaceMemberRepository, userAccountRepository);
|
||||
verify(subscriptionRepository).delete(existing);
|
||||
}
|
||||
|
||||
record DeniedSubscription(String label, SkillVisibility visibility, boolean hidden,
|
||||
NamespaceStatus namespaceStatus, NamespaceRole role) {
|
||||
@Override public String toString() { return label; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,20 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||
import com.iflytek.skillhub.domain.event.*;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.social.SkillSubscriptionService;
|
||||
import com.iflytek.skillhub.domain.social.SubscriptionRecipientEligibility;
|
||||
import com.iflytek.skillhub.domain.social.SubscriptionMetadataAccessPolicy;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import com.iflytek.skillhub.notification.domain.NotificationCategory;
|
||||
import com.iflytek.skillhub.notification.service.NotificationDispatcher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -31,10 +41,21 @@ class NotificationEventListenerTest {
|
|||
@Mock RecipientResolver recipientResolver;
|
||||
@Mock NotificationDispatcher dispatcher;
|
||||
@Mock ObjectMapper objectMapper;
|
||||
@Mock SkillSubscriptionService skillSubscriptionService;
|
||||
@Mock UserAccountRepository userAccountRepository;
|
||||
@Mock NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@InjectMocks
|
||||
NotificationEventListener listener;
|
||||
|
||||
@org.junit.jupiter.api.BeforeEach
|
||||
void setUpListener() {
|
||||
listener = new NotificationEventListener(skillRepository, skillVersionRepository, namespaceRepository,
|
||||
recipientResolver, dispatcher, skillSubscriptionService, objectMapper,
|
||||
new SubscriptionRecipientEligibility(userAccountRepository, namespaceMemberRepository,
|
||||
new SubscriptionMetadataAccessPolicy()));
|
||||
}
|
||||
|
||||
private Skill mockSkill(Long id) {
|
||||
Skill skill = mock(Skill.class);
|
||||
when(skill.getId()).thenReturn(id);
|
||||
|
|
@ -225,4 +246,153 @@ class NotificationEventListenerTest {
|
|||
verify(dispatcher).dispatch(eq("reporter-1"), eq(NotificationCategory.REPORT),
|
||||
eq("REPORT_RESOLVED"), anyString(), anyString(), eq("SKILL"), eq(1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishSubscriberFanoutExcludesInactiveAccount() {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
skill.setLatestVersionId(10L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("inactive"));
|
||||
UserAccount inactive = new UserAccount("inactive", "Inactive", null, null);
|
||||
inactive.setStatus(UserStatus.DISABLED);
|
||||
when(userAccountRepository.findByIdIn(List.of("inactive"))).thenReturn(List.of(inactive));
|
||||
mockNamespace();
|
||||
|
||||
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "owner"));
|
||||
|
||||
verifyNoInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishSubscriberFanoutFailsClosedBeforeDispatchWhenAccountBatchFails() {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
skill.setLatestVersionId(10L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("user-1", "user-2"));
|
||||
when(userAccountRepository.findByIdIn(anyList())).thenThrow(new IllegalStateException("account batch unavailable"));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
|
||||
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "owner")))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
|
||||
verifyNoInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishSubscriberFanoutDispatchesOnlyEligibleNonPublisherWithExactPayload() throws Exception {
|
||||
Skill skill = skill(1L, "publisher", "publisher");
|
||||
skill.setLatestVersionId(10L);
|
||||
skill.setVisibility(SkillVisibility.PRIVATE);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L))
|
||||
.thenReturn(List.of("publisher", "admin", "member", "inactive", "missing"));
|
||||
UserAccount publisher = new UserAccount("publisher", "Publisher", null, null);
|
||||
UserAccount admin = new UserAccount("admin", "Admin", null, null);
|
||||
UserAccount member = new UserAccount("member", "Member", null, null);
|
||||
UserAccount inactive = new UserAccount("inactive", "Inactive", null, null);
|
||||
inactive.setStatus(UserStatus.DISABLED);
|
||||
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(publisher, admin, member, inactive));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserIdIn(eq(5L), anyCollection()))
|
||||
.thenReturn(List.of(new NamespaceMember(5L, "admin", NamespaceRole.ADMIN),
|
||||
new NamespaceMember(5L, "member", NamespaceRole.MEMBER)));
|
||||
mockNamespace();
|
||||
when(objectMapper.writeValueAsString(any())).thenReturn("{\"skillId\":1,\"version\":\"1.0.0\"}");
|
||||
|
||||
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "publisher"));
|
||||
|
||||
verify(dispatcher).dispatch("admin", NotificationCategory.PUBLISH, "SUBSCRIPTION_NEW_VERSION",
|
||||
"Skill updated: Test Skill", "{\"skillId\":1,\"version\":\"1.0.0\"}", "SKILL", 1L);
|
||||
verifyNoMoreInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void yankWithoutFallbackUsesVerifiedPreYankPublicationAndExcludesActor() throws Exception {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("actor", "subscriber"));
|
||||
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(
|
||||
new UserAccount("actor", "Actor", null, null),
|
||||
new UserAccount("subscriber", "Subscriber", null, null)));
|
||||
mockNamespace();
|
||||
when(objectMapper.writeValueAsString(any())).thenReturn("{\"skillId\":1,\"versionId\":10}");
|
||||
|
||||
listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", true));
|
||||
|
||||
verify(dispatcher).dispatch("subscriber", NotificationCategory.PUBLISH, "SUBSCRIPTION_VERSION_YANKED",
|
||||
"Skill version yanked: Test Skill", "{\"skillId\":1,\"versionId\":10}", "SKILL", 1L);
|
||||
verifyNoMoreInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void yankDoesNotDispatchWhenEventDoesNotVerifyPublishedPreState() {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
skill.setLatestVersionId(9L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("subscriber"));
|
||||
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(
|
||||
new UserAccount("subscriber", "Subscriber", null, null)));
|
||||
mockNamespace();
|
||||
|
||||
listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", false));
|
||||
|
||||
verifyNoInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishFanoutFailsClosedBeforeDispatchWhenNamespaceReadFails() {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
skill.setLatestVersionId(10L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("user-1"));
|
||||
when(namespaceRepository.findById(5L)).thenThrow(new IllegalStateException("namespace unavailable"));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
|
||||
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "owner")))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
|
||||
verifyNoInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void yankFanoutFailsClosedBeforeDispatchWhenMembershipBatchFails() {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("user-1", "user-2"));
|
||||
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(
|
||||
new UserAccount("user-1", "One", null, null),
|
||||
new UserAccount("user-2", "Two", null, null)));
|
||||
when(namespaceRepository.findById(5L))
|
||||
.thenReturn(Optional.of(new Namespace("demo", "Demo", "owner")));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserIdIn(eq(5L), anyCollection()))
|
||||
.thenThrow(new IllegalStateException("membership unavailable"));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
|
||||
listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", true)))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
|
||||
verifyNoInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void archivedNamespaceRemovedSubscriberIsRejectedButCurrentMemberReceivesYank() throws Exception {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("current", "removed"));
|
||||
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(
|
||||
new UserAccount("current", "Current", null, null),
|
||||
new UserAccount("removed", "Removed", null, null)));
|
||||
Namespace namespace = new Namespace("archived", "Archived", "owner");
|
||||
namespace.setStatus(NamespaceStatus.ARCHIVED);
|
||||
when(namespaceRepository.findById(5L)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserIdIn(eq(5L), anyCollection()))
|
||||
.thenReturn(List.of(new NamespaceMember(5L, "current", NamespaceRole.MEMBER)));
|
||||
when(objectMapper.writeValueAsString(any())).thenReturn("{}");
|
||||
|
||||
listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", true));
|
||||
|
||||
verify(dispatcher).dispatch(eq("current"), eq(NotificationCategory.PUBLISH),
|
||||
eq("SUBSCRIPTION_VERSION_YANKED"), anyString(), eq("{}"), eq("SKILL"), eq(1L));
|
||||
verifyNoMoreInteractions(dispatcher);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
package com.iflytek.skillhub.listener;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
|
||||
import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.social.SkillSubscriptionService;
|
||||
import com.iflytek.skillhub.domain.social.SubscriptionMetadataAccessPolicy;
|
||||
import com.iflytek.skillhub.domain.social.SubscriptionRecipientEligibility;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import com.iflytek.skillhub.notification.domain.Notification;
|
||||
import com.iflytek.skillhub.notification.domain.NotificationCategory;
|
||||
import com.iflytek.skillhub.notification.domain.NotificationChannel;
|
||||
import com.iflytek.skillhub.notification.service.NotificationDispatcher;
|
||||
import com.iflytek.skillhub.notification.service.NotificationPreferenceService;
|
||||
import com.iflytek.skillhub.notification.service.NotificationService;
|
||||
import com.iflytek.skillhub.notification.sse.SseEmitterManager;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
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.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SubscriberNotificationSinkTest {
|
||||
|
||||
private static final Long SKILL_ID = 1L;
|
||||
private static final Long NAMESPACE_ID = 5L;
|
||||
private static final Long VERSION_ID = 10L;
|
||||
private static final Instant CREATED_AT = Instant.parse("2026-08-19T20:30:00Z");
|
||||
|
||||
@Mock SkillRepository skillRepository;
|
||||
@Mock SkillVersionRepository skillVersionRepository;
|
||||
@Mock NamespaceRepository namespaceRepository;
|
||||
@Mock RecipientResolver recipientResolver;
|
||||
@Mock SkillSubscriptionService subscriptionService;
|
||||
@Mock UserAccountRepository accountRepository;
|
||||
@Mock NamespaceMemberRepository memberRepository;
|
||||
@Mock NotificationService notificationService;
|
||||
@Mock NotificationPreferenceService preferenceService;
|
||||
@Mock SseEmitterManager sseEmitterManager;
|
||||
|
||||
private NotificationEventListener listener;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SubscriptionRecipientEligibility eligibility = new SubscriptionRecipientEligibility(
|
||||
accountRepository, memberRepository, new SubscriptionMetadataAccessPolicy());
|
||||
NotificationDispatcher dispatcher = new NotificationDispatcher(
|
||||
notificationService, preferenceService, sseEmitterManager);
|
||||
listener = new NotificationEventListener(skillRepository, skillVersionRepository, namespaceRepository,
|
||||
recipientResolver, dispatcher, subscriptionService, new ObjectMapper(), eligibility);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishPersistsAndPushesOnlyCurrentEligibleNonPublisherAcrossAuthorizationMatrix() {
|
||||
Skill skill = skill(SkillVisibility.PRIVATE, false, VERSION_ID);
|
||||
Namespace namespace = namespace(NamespaceStatus.ACTIVE);
|
||||
List<String> candidates = List.of("publisher", "current-admin", "stale-removed", "inactive",
|
||||
"missing", "private-member", "cross-namespace", "platform-super-admin");
|
||||
arrangeEvent(skill, namespace, candidates);
|
||||
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(
|
||||
account("publisher"), account("current-admin"), account("stale-removed"),
|
||||
inactiveAccount("inactive"), account("private-member"), account("cross-namespace"),
|
||||
account("platform-super-admin")));
|
||||
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of(
|
||||
member("current-admin", NamespaceRole.ADMIN),
|
||||
member("private-member", NamespaceRole.MEMBER)));
|
||||
enablePersistenceFor("current-admin", "SUBSCRIPTION_NEW_VERSION");
|
||||
|
||||
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(SKILL_ID, VERSION_ID, "publisher"));
|
||||
|
||||
String body = "{\"skillId\":1,\"skillName\":\"Test Skill\",\"slug\":\"test-skill\",\"namespace\":\"demo\"}";
|
||||
verify(notificationService).create("current-admin", NotificationCategory.PUBLISH,
|
||||
"SUBSCRIPTION_NEW_VERSION", "Skill updated: Test Skill", body, "SKILL", SKILL_ID);
|
||||
assertSingleSse("current-admin", "SUBSCRIPTION_NEW_VERSION", body);
|
||||
verify(accountRepository).findByIdIn(candidates);
|
||||
verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hiddenPublishPersistsAndPushesOnlyManagerWhileOrdinaryAndPlatformOnlyCandidatesStayAtZero() {
|
||||
Skill skill = skill(SkillVisibility.PUBLIC, true, VERSION_ID);
|
||||
Namespace namespace = namespace(NamespaceStatus.ACTIVE);
|
||||
List<String> candidates = List.of("manager", "ordinary-member", "platform-super-admin");
|
||||
arrangeEvent(skill, namespace, candidates);
|
||||
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(
|
||||
account("manager"), account("ordinary-member"), account("platform-super-admin")));
|
||||
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of(
|
||||
member("manager", NamespaceRole.ADMIN), member("ordinary-member", NamespaceRole.MEMBER)));
|
||||
enablePersistenceFor("manager", "SUBSCRIPTION_NEW_VERSION");
|
||||
|
||||
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(SKILL_ID, VERSION_ID, "publisher"));
|
||||
|
||||
String body = "{\"skillId\":1,\"skillName\":\"Test Skill\",\"slug\":\"test-skill\",\"namespace\":\"demo\"}";
|
||||
verify(notificationService).create("manager", NotificationCategory.PUBLISH,
|
||||
"SUBSCRIPTION_NEW_VERSION", "Skill updated: Test Skill", body, "SKILL", SKILL_ID);
|
||||
assertSingleSse("manager", "SUBSCRIPTION_NEW_VERSION", body);
|
||||
verify(accountRepository).findByIdIn(candidates);
|
||||
verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "yank wasPublished with fallback={0} reaches only current archived-namespace member")
|
||||
@ValueSource(booleans = {true, false})
|
||||
void yankPersistsAndPushesOnlyCurrentMemberForFallbackAndNoFallback(boolean hasFallback) {
|
||||
Skill skill = skill(SkillVisibility.PUBLIC, false, hasFallback ? 9L : null);
|
||||
Namespace namespace = namespace(NamespaceStatus.ARCHIVED);
|
||||
List<String> candidates = List.of("actor", "current", "removed", "inactive", "missing");
|
||||
arrangeEvent(skill, namespace, candidates);
|
||||
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(
|
||||
account("actor"), account("current"), account("removed"), inactiveAccount("inactive")));
|
||||
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of(
|
||||
member("actor", NamespaceRole.ADMIN), member("current", NamespaceRole.MEMBER)));
|
||||
enablePersistenceFor("current", "SUBSCRIPTION_VERSION_YANKED");
|
||||
|
||||
listener.onSkillVersionYankedForSubscribers(
|
||||
new SkillVersionYankedEvent(SKILL_ID, VERSION_ID, "actor", true));
|
||||
|
||||
String body = "{\"skillId\":1,\"skillName\":\"Test Skill\",\"slug\":\"test-skill\",\"namespace\":\"demo\"}";
|
||||
verify(notificationService).create("current", NotificationCategory.PUBLISH,
|
||||
"SUBSCRIPTION_VERSION_YANKED", "Skill version yanked: Test Skill", body, "SKILL", SKILL_ID);
|
||||
assertSingleSse("current", "SUBSCRIPTION_VERSION_YANKED", body);
|
||||
verify(accountRepository).findByIdIn(candidates);
|
||||
verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates);
|
||||
}
|
||||
|
||||
@Test
|
||||
void yankWithoutVerifiedPublishedPreStateProducesNoPersistenceOrSse() {
|
||||
Skill skill = skill(SkillVisibility.PUBLIC, false, null);
|
||||
Namespace namespace = namespace(NamespaceStatus.ACTIVE);
|
||||
List<String> candidates = List.of("current");
|
||||
arrangeEvent(skill, namespace, candidates);
|
||||
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(account("current")));
|
||||
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of());
|
||||
|
||||
listener.onSkillVersionYankedForSubscribers(
|
||||
new SkillVersionYankedEvent(SKILL_ID, VERSION_ID, "actor", false));
|
||||
|
||||
verifyNoInteractions(notificationService, preferenceService, sseEmitterManager);
|
||||
verify(accountRepository).findByIdIn(candidates);
|
||||
verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} batch failure happens before every final sink")
|
||||
@EnumSource(BatchFailure.class)
|
||||
void authoritativeBatchFailureProducesNoPartialPersistenceOrSse(BatchFailure failure) {
|
||||
Skill skill = skill(SkillVisibility.PUBLIC, false, VERSION_ID);
|
||||
Namespace namespace = namespace(NamespaceStatus.ACTIVE);
|
||||
List<String> candidates = List.of("first", "second");
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(subscriptionService.findSubscribersBySkillId(SKILL_ID)).thenReturn(candidates);
|
||||
if (failure == BatchFailure.NAMESPACE) {
|
||||
when(namespaceRepository.findById(NAMESPACE_ID)).thenThrow(new IllegalStateException("namespace batch"));
|
||||
} else {
|
||||
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace));
|
||||
if (failure == BatchFailure.ACCOUNT) {
|
||||
when(accountRepository.findByIdIn(candidates)).thenThrow(new IllegalStateException("account batch"));
|
||||
} else {
|
||||
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(account("first"), account("second")));
|
||||
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates))
|
||||
.thenThrow(new IllegalStateException("membership batch"));
|
||||
}
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> listener.onSkillPublishedForSubscribers(
|
||||
new SkillPublishedEvent(SKILL_ID, VERSION_ID, "publisher")))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining(failure.name().toLowerCase());
|
||||
|
||||
verifyNoInteractions(notificationService, preferenceService, sseEmitterManager);
|
||||
verify(namespaceRepository, times(1)).findById(NAMESPACE_ID);
|
||||
if (failure == BatchFailure.NAMESPACE) {
|
||||
verify(accountRepository, never()).findByIdIn(anyList());
|
||||
verify(memberRepository, never()).findByNamespaceIdAndUserIdIn(any(), anyCollection());
|
||||
} else {
|
||||
verify(accountRepository, times(1)).findByIdIn(candidates);
|
||||
verify(memberRepository, failure == BatchFailure.MEMBERSHIP ? times(1) : never())
|
||||
.findByNamespaceIdAndUserIdIn(eq(NAMESPACE_ID), anyCollection());
|
||||
}
|
||||
}
|
||||
|
||||
private void arrangeEvent(Skill skill, Namespace namespace, List<String> candidates) {
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(subscriptionService.findSubscribersBySkillId(SKILL_ID)).thenReturn(candidates);
|
||||
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace));
|
||||
}
|
||||
|
||||
private void enablePersistenceFor(String recipient, String eventType) {
|
||||
when(preferenceService.isEnabled(recipient, NotificationCategory.PUBLISH, NotificationChannel.IN_APP))
|
||||
.thenReturn(true);
|
||||
when(notificationService.create(eq(recipient), eq(NotificationCategory.PUBLISH), eq(eventType),
|
||||
any(String.class), any(String.class), eq("SKILL"), eq(SKILL_ID)))
|
||||
.thenAnswer(invocation -> notification(recipient, eventType,
|
||||
invocation.getArgument(3), invocation.getArgument(4)));
|
||||
}
|
||||
|
||||
private void assertSingleSse(String recipient, String eventType, String body) {
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> payload = ArgumentCaptor.forClass(Map.class);
|
||||
verify(sseEmitterManager).push(eq(recipient), payload.capture());
|
||||
assertThat(payload.getValue())
|
||||
.containsEntry("id", 42L)
|
||||
.containsEntry("category", "PUBLISH")
|
||||
.containsEntry("eventType", eventType)
|
||||
.containsEntry("bodyJson", body)
|
||||
.containsEntry("entityType", "SKILL")
|
||||
.containsEntry("entityId", SKILL_ID);
|
||||
}
|
||||
|
||||
private Skill skill(SkillVisibility visibility, boolean hidden, Long latestVersionId) {
|
||||
Skill skill = new Skill(NAMESPACE_ID, "test-skill", "publisher", visibility);
|
||||
skill.setDisplayName("Test Skill");
|
||||
skill.setHidden(hidden);
|
||||
skill.setLatestVersionId(latestVersionId);
|
||||
setId(skill, SKILL_ID);
|
||||
return skill;
|
||||
}
|
||||
|
||||
private Namespace namespace(NamespaceStatus status) {
|
||||
Namespace namespace = new Namespace("demo", "Demo", "publisher");
|
||||
namespace.setStatus(status);
|
||||
return namespace;
|
||||
}
|
||||
|
||||
private UserAccount account(String id) {
|
||||
return new UserAccount(id, id, null, null);
|
||||
}
|
||||
|
||||
private UserAccount inactiveAccount(String id) {
|
||||
UserAccount account = account(id);
|
||||
account.setStatus(UserStatus.DISABLED);
|
||||
return account;
|
||||
}
|
||||
|
||||
private NamespaceMember member(String userId, NamespaceRole role) {
|
||||
return new NamespaceMember(NAMESPACE_ID, userId, role);
|
||||
}
|
||||
|
||||
private Notification notification(String recipient, String eventType, String title, String body) {
|
||||
Notification notification = new Notification(recipient, NotificationCategory.PUBLISH, eventType,
|
||||
title, body, "SKILL", SKILL_ID, CREATED_AT);
|
||||
setId(notification, 42L);
|
||||
return notification;
|
||||
}
|
||||
|
||||
private void setId(Object entity, Long id) {
|
||||
try {
|
||||
var field = entity.getClass().getDeclaredField("id");
|
||||
field.setAccessible(true);
|
||||
field.set(entity, id);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private enum BatchFailure {
|
||||
ACCOUNT,
|
||||
NAMESPACE,
|
||||
MEMBERSHIP
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ class ApiAccessDeniedHandlerTest {
|
|||
@BeforeEach
|
||||
void setUp() {
|
||||
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
|
||||
messageSource.setFallbackToSystemLocale(false);
|
||||
messageSource.setBasename("messages");
|
||||
messageSource.setDefaultEncoding("UTF-8");
|
||||
RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.user.UserStatus;
|
|||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.repository.AdminUserSearchRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
|
|
@ -35,11 +36,13 @@ class AdminUserAppServiceTest {
|
|||
private final UserRoleBindingRepository userRoleBindingRepository = mock(UserRoleBindingRepository.class);
|
||||
private final RoleRepository roleRepository = mock(RoleRepository.class);
|
||||
private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class);
|
||||
private final ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class);
|
||||
private final AdminUserAppService service = new AdminUserAppService(
|
||||
adminUserSearchRepository,
|
||||
userAccountRepository,
|
||||
userRoleBindingRepository,
|
||||
roleRepository
|
||||
roleRepository,
|
||||
eventPublisher
|
||||
);
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.search.SearchQuery;
|
||||
import com.iflytek.skillhub.search.SearchQueryService;
|
||||
import com.iflytek.skillhub.search.SearchResult;
|
||||
|
|
@ -59,6 +61,9 @@ class SkillSearchAppServiceTest {
|
|||
@Mock
|
||||
private RbacService rbacService;
|
||||
|
||||
@Mock
|
||||
private UserAccountRepository userAccountRepository;
|
||||
|
||||
private SkillSearchAppService service;
|
||||
|
||||
@BeforeEach
|
||||
|
|
@ -69,7 +74,9 @@ class SkillSearchAppServiceTest {
|
|||
namespaceRepository,
|
||||
namespaceService,
|
||||
new SkillLifecycleProjectionService(skillVersionRepository),
|
||||
rbacService
|
||||
new ComplianceSnapshotProjectionService(new com.fasterxml.jackson.databind.ObjectMapper()),
|
||||
rbacService,
|
||||
userAccountRepository
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -100,11 +107,14 @@ class SkillSearchAppServiceTest {
|
|||
when(skillRepository.findByIdIn(List.of(11L))).thenReturn(List.of(visibleSkill));
|
||||
when(namespaceRepository.findByIdIn(List.of(2L))).thenReturn(List.of(activeNamespace));
|
||||
when(skillVersionRepository.findByIdIn(List.of(111L))).thenReturn(List.of());
|
||||
when(userAccountRepository.findByIdIn(List.of("owner-1")))
|
||||
.thenReturn(List.of(new UserAccount("owner-1", "Alice", "alice@example.com", null)));
|
||||
|
||||
SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 1, null, null);
|
||||
|
||||
assertEquals(1, response.items().size());
|
||||
assertEquals("visible-skill", response.items().getFirst().slug());
|
||||
assertEquals("Alice", response.items().getFirst().ownerDisplayName());
|
||||
assertEquals(1, response.total());
|
||||
verify(searchQueryService, times(1)).search(any());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ class CliSkillAppServiceTest {
|
|||
List.of(new SkillSummaryResponse(
|
||||
1L, "pdf-parser", "PDF Parser", "Parse PDFs",
|
||||
"PUBLIC", "ACTIVE", 100L, 5, BigDecimal.valueOf(4.5), 10,
|
||||
"global", Instant.now(), false,
|
||||
"global", Instant.now(), null, null, false,
|
||||
new SkillLifecycleVersionResponse(1L, "1.2.0", "PUBLISHED"),
|
||||
new SkillLifecycleVersionResponse(1L, "1.2.0", "PUBLISHED"),
|
||||
null, "PUBLISHED", null
|
||||
|
|
@ -100,7 +100,7 @@ class CliSkillAppServiceTest {
|
|||
new SkillSummaryResponse(
|
||||
2L, "ready", "Ready", "Installable",
|
||||
"PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0,
|
||||
"global", Instant.now(), false,
|
||||
"global", Instant.now(), null, null, false,
|
||||
new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"),
|
||||
new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"),
|
||||
null, "PUBLISHED", null
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import com.iflytek.skillhub.storage.ObjectStorageService;
|
|||
import io.micrometer.observation.ObservationRegistry;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.redisson.api.RLock;
|
||||
import org.redisson.api.RStream;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.redisson.api.StreamMessageId;
|
||||
|
|
@ -35,6 +36,7 @@ import java.util.Optional;
|
|||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ScanTaskConsumerLoggingTest {
|
||||
|
||||
|
|
@ -150,6 +152,15 @@ class ScanTaskConsumerLoggingTest {
|
|||
}
|
||||
}
|
||||
|
||||
private static RedissonClient redissonClientWithAvailableProcessingLock() {
|
||||
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||
RLock processingLock = mock(RLock.class);
|
||||
when(redissonClient.getLock(org.mockito.ArgumentMatchers.anyString())).thenReturn(processingLock);
|
||||
when(processingLock.tryLock()).thenReturn(true);
|
||||
when(processingLock.isHeldByCurrentThread()).thenReturn(true);
|
||||
return redissonClient;
|
||||
}
|
||||
|
||||
private static final class TestableLoggingConsumer extends ScanTaskConsumer {
|
||||
private final RStream<String, String> stream = mock(RStream.class);
|
||||
|
||||
|
|
@ -159,7 +170,7 @@ class ScanTaskConsumerLoggingTest {
|
|||
ScanTaskProducer scanTaskProducer,
|
||||
ObjectStorageService objectStorageService) {
|
||||
super(
|
||||
mock(RedissonClient.class),
|
||||
redissonClientWithAvailableProcessingLock(),
|
||||
"skillhub:scan:requests",
|
||||
"skillhub-scanners",
|
||||
securityScanner,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import com.iflytek.skillhub.storage.ObjectStorageService;
|
|||
import com.iflytek.skillhub.storage.ObjectMetadata;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.redisson.api.RLock;
|
||||
import org.redisson.api.RStream;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.redisson.api.StreamMessageId;
|
||||
|
|
@ -38,7 +39,11 @@ import java.util.Map;
|
|||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ScanTaskConsumerTest {
|
||||
private static final Path SCAN_TEMP_DIR = Path.of("/tmp/skillhub-scans");
|
||||
|
|
@ -268,6 +273,88 @@ class ScanTaskConsumerTest {
|
|||
assertThat(listScanTempFiles(versionId)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void processBusiness_whenTaskIsAlreadyInFlight_skipsScanAndPreservesSharedTempPath() throws Exception {
|
||||
Files.createDirectories(SCAN_TEMP_DIR);
|
||||
Path tempDir = Files.createTempDirectory(SCAN_TEMP_DIR, "scan-task-consumer-inflight");
|
||||
Path skillFile = Files.writeString(tempDir.resolve("SKILL.md"), "# demo");
|
||||
StubSecurityScanner securityScanner = new StubSecurityScanner();
|
||||
RLock processingLock = mock(RLock.class);
|
||||
when(processingLock.tryLock()).thenReturn(false);
|
||||
TestableScanTaskConsumer consumer = new TestableScanTaskConsumer(
|
||||
securityScanner,
|
||||
new StubSecurityScanService(),
|
||||
new InMemorySkillVersionRepository(),
|
||||
new InMemoryScanTaskProducer(),
|
||||
new InMemoryObjectStorageService(),
|
||||
redissonClient(processingLock)
|
||||
);
|
||||
ScanTaskConsumer.ScanTaskPayload payload = new ScanTaskConsumer.ScanTaskPayload(
|
||||
"task-inflight", 42L, tempDir.toString(), null, ScannerType.SKILL_SCANNER);
|
||||
|
||||
try {
|
||||
assertThatThrownBy(() -> consumer.invokeProcessBusiness(payload))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasMessage("Security scan is already in progress: taskId=task-inflight");
|
||||
|
||||
assertThat(securityScanner.lastRequest).isNull();
|
||||
assertThat(skillFile).exists();
|
||||
verify(processingLock, never()).unlock();
|
||||
} finally {
|
||||
Files.deleteIfExists(skillFile);
|
||||
Files.deleteIfExists(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleMessage_whenTaskLockIsHeld_republishesInsteadOfDroppingDelivery() {
|
||||
StubSecurityScanner securityScanner = new StubSecurityScanner();
|
||||
InMemoryScanTaskProducer producer = new InMemoryScanTaskProducer();
|
||||
RLock processingLock = mock(RLock.class);
|
||||
when(processingLock.tryLock()).thenReturn(false);
|
||||
TestableScanTaskConsumer consumer = new TestableScanTaskConsumer(
|
||||
securityScanner,
|
||||
new StubSecurityScanService(),
|
||||
new InMemorySkillVersionRepository(),
|
||||
producer,
|
||||
new InMemoryObjectStorageService(),
|
||||
redissonClient(processingLock)
|
||||
);
|
||||
|
||||
consumer.handleMessage(new StreamMessageId(11, 0), Map.of(
|
||||
"taskId", "task-reclaimed",
|
||||
"versionId", "42",
|
||||
"skillPath", "/tmp/skillhub-scans/42",
|
||||
"scannerType", ScannerType.SKILL_SCANNER.getValue()
|
||||
));
|
||||
|
||||
assertThat(producer.publishedTask.taskId()).isEqualTo("task-reclaimed");
|
||||
assertThat(producer.publishedTask.metadata()).containsEntry("retryCount", "1");
|
||||
verify(consumer.stream).ack("skillhub-scanners", new StreamMessageId(11, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void processBusiness_whenScannerFails_releasesProcessingLock() {
|
||||
StubSecurityScanner securityScanner = new StubSecurityScanner();
|
||||
securityScanner.failure = new IllegalStateException("scanner unavailable");
|
||||
RLock processingLock = availableProcessingLock();
|
||||
TestableScanTaskConsumer consumer = new TestableScanTaskConsumer(
|
||||
securityScanner,
|
||||
new StubSecurityScanService(),
|
||||
new InMemorySkillVersionRepository(),
|
||||
new InMemoryScanTaskProducer(),
|
||||
new InMemoryObjectStorageService(),
|
||||
redissonClient(processingLock)
|
||||
);
|
||||
ScanTaskConsumer.ScanTaskPayload payload = new ScanTaskConsumer.ScanTaskPayload(
|
||||
"task-failure", 42L, "/tmp/failure", null, ScannerType.SKILL_SCANNER);
|
||||
|
||||
assertThatThrownBy(() -> consumer.invokeProcessBusiness(payload))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("scanner unavailable");
|
||||
verify(processingLock).unlock();
|
||||
}
|
||||
|
||||
private void setField(Object target, String fieldName, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
|
|
@ -299,7 +386,28 @@ class ScanTaskConsumerTest {
|
|||
ScanTaskProducer scanTaskProducer,
|
||||
ObjectStorageService objectStorageService) {
|
||||
super(
|
||||
mock(RedissonClient.class),
|
||||
redissonClient(availableProcessingLock()),
|
||||
"skillhub:scan:requests",
|
||||
"skillhub-scanners",
|
||||
securityScanner,
|
||||
securityScanService,
|
||||
skillVersionRepository,
|
||||
scanTaskProducer,
|
||||
objectStorageService,
|
||||
new MessageObservationSupport(ObservationRegistry.NOOP, new RequestIdAccessor())
|
||||
);
|
||||
this.stream = mock(RStream.class);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private TestableScanTaskConsumer(SecurityScanner securityScanner,
|
||||
SecurityScanService securityScanService,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
ScanTaskProducer scanTaskProducer,
|
||||
ObjectStorageService objectStorageService,
|
||||
RedissonClient redissonClient) {
|
||||
super(
|
||||
redissonClient,
|
||||
"skillhub:scan:requests",
|
||||
"skillhub-scanners",
|
||||
securityScanner,
|
||||
|
|
@ -334,6 +442,19 @@ class ScanTaskConsumerTest {
|
|||
}
|
||||
}
|
||||
|
||||
private static RLock availableProcessingLock() {
|
||||
RLock processingLock = mock(RLock.class);
|
||||
when(processingLock.tryLock()).thenReturn(true);
|
||||
when(processingLock.isHeldByCurrentThread()).thenReturn(true);
|
||||
return processingLock;
|
||||
}
|
||||
|
||||
private static RedissonClient redissonClient(RLock processingLock) {
|
||||
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||
when(redissonClient.getLock(org.mockito.ArgumentMatchers.anyString())).thenReturn(processingLock);
|
||||
return redissonClient;
|
||||
}
|
||||
|
||||
private static final class StubSecurityScanner implements SecurityScanner {
|
||||
private SecurityScanRequest lastRequest;
|
||||
private SecurityScanResponse response;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
package com.iflytek.skillhub.task;
|
||||
|
||||
import com.iflytek.skillhub.domain.security.ScanTask;
|
||||
import com.iflytek.skillhub.domain.security.ScanTaskOutbox;
|
||||
import com.iflytek.skillhub.domain.security.ScanTaskOutboxRepository;
|
||||
import com.iflytek.skillhub.domain.security.ScanTaskOutboxStatus;
|
||||
import com.iflytek.skillhub.domain.security.ScanTaskProducer;
|
||||
import com.iflytek.skillhub.domain.security.ScannerType;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ScanTaskOutboxDispatcherTest {
|
||||
@Mock ScanTaskOutboxRepository repository;
|
||||
@Mock ScanTaskProducer producer;
|
||||
@Mock SkillVersionRepository versionRepository;
|
||||
|
||||
@Test
|
||||
void failedRedisPublishLeavesTaskPendingForRetry() {
|
||||
ScanTaskOutbox outbox = outbox("task-1", 1L);
|
||||
given(repository.findDispatchable(any(), any(Integer.class))).willReturn(List.of(outbox));
|
||||
doThrow(new IllegalStateException("redis unavailable")).when(producer).publishScanTask(any());
|
||||
|
||||
dispatcher(10).dispatch();
|
||||
|
||||
assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.PENDING);
|
||||
assertThat(outbox.getRetryCount()).isEqualTo(1);
|
||||
verify(producer).publishScanTask(any());
|
||||
verify(repository).save(outbox);
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulPublishMarksTaskSentWithoutChangingVersion() {
|
||||
ScanTaskOutbox outbox = outbox("task-success", 3L);
|
||||
given(repository.findDispatchable(any(), any(Integer.class))).willReturn(List.of(outbox));
|
||||
|
||||
dispatcher(10).dispatch();
|
||||
|
||||
assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.SENT);
|
||||
assertThat(outbox.getRetryCount()).isZero();
|
||||
verify(producer).publishScanTask(any());
|
||||
verify(repository).save(outbox);
|
||||
verifyNoInteractions(versionRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lastPublishAttemptMarksOutboxAndVersionFailed() {
|
||||
ScanTaskOutbox outbox = outbox("task-2", 2L);
|
||||
SkillVersion version = new SkillVersion(9L, "1.0.0", "user");
|
||||
version.setStatus(SkillVersionStatus.SCANNING);
|
||||
given(repository.findDispatchable(any(), any(Integer.class))).willReturn(List.of(outbox));
|
||||
given(versionRepository.findById(2L)).willReturn(Optional.of(version));
|
||||
doThrow(new IllegalStateException("redis unavailable")).when(producer).publishScanTask(any());
|
||||
|
||||
dispatcher(1).dispatch();
|
||||
|
||||
assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.FAILED);
|
||||
assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCAN_FAILED);
|
||||
verify(versionRepository).save(version);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lastPublishAttemptDoesNotOverwriteTerminalVersionStatus() {
|
||||
ScanTaskOutbox outbox = outbox("task-published", 4L);
|
||||
SkillVersion version = new SkillVersion(9L, "1.0.0", "user");
|
||||
version.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
given(repository.findDispatchable(any(), any(Integer.class))).willReturn(List.of(outbox));
|
||||
given(versionRepository.findById(4L)).willReturn(Optional.of(version));
|
||||
doThrow(new IllegalStateException("redis unavailable")).when(producer).publishScanTask(any());
|
||||
|
||||
dispatcher(1).dispatch();
|
||||
|
||||
assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.FAILED);
|
||||
assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED);
|
||||
verify(versionRepository, never()).save(version);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredLeaseCanBeReclaimedAndPublished() {
|
||||
ScanTaskOutbox outbox = outbox("task-expired", 5L);
|
||||
assertThat(outbox.claim(Instant.parse("2025-12-31T23:00:00Z"), Duration.ofMinutes(2))).isTrue();
|
||||
given(repository.findDispatchable(any(), any(Integer.class))).willReturn(List.of(outbox));
|
||||
|
||||
dispatcher(10).dispatch();
|
||||
|
||||
assertThat(outbox.getStatus()).isEqualTo(ScanTaskOutboxStatus.SENT);
|
||||
verify(producer).publishScanTask(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleFinderResultInTerminalStateIsIgnored() {
|
||||
ScanTaskOutbox outbox = outbox("task-sent", 6L);
|
||||
outbox.markSent(Instant.parse("2025-12-31T23:00:00Z"));
|
||||
given(repository.findDispatchable(any(), any(Integer.class))).willReturn(List.of(outbox));
|
||||
|
||||
dispatcher(10).dispatch();
|
||||
|
||||
verifyNoInteractions(producer);
|
||||
verify(repository, never()).save(outbox);
|
||||
}
|
||||
|
||||
@Test
|
||||
void maxAttemptsMustBePositive() {
|
||||
assertThatThrownBy(() -> dispatcher(0))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("maxAttempts");
|
||||
}
|
||||
|
||||
private ScanTaskOutboxDispatcher dispatcher(int maxAttempts) {
|
||||
Clock clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
|
||||
return new ScanTaskOutboxDispatcher(repository, producer, versionRepository, clock,
|
||||
50, maxAttempts, Duration.ofMinutes(2), Duration.ofMinutes(5));
|
||||
}
|
||||
|
||||
private ScanTaskOutbox outbox(String taskId, Long versionId) {
|
||||
return new ScanTaskOutbox(new ScanTask(taskId, versionId, "/tmp/" + versionId, null, "user", 1L,
|
||||
java.util.Map.of("scannerType", ScannerType.SKILL_SCANNER.getValue())));
|
||||
}
|
||||
}
|
||||
|
|
@ -45,11 +45,11 @@ import org.springframework.security.web.util.matcher.RequestMatcher;
|
|||
public class SecurityConfig {
|
||||
private static final String CONTENT_SECURITY_POLICY = String.join("; ",
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||
"script-src 'self'",
|
||||
"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:*",
|
||||
"connect-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
import com.iflytek.skillhub.auth.entity.IdentityBinding;
|
||||
import com.iflytek.skillhub.auth.oauth.AccountDisabledException;
|
||||
import com.iflytek.skillhub.auth.oauth.AccountMergedException;
|
||||
import com.iflytek.skillhub.auth.oauth.AccountPendingException;
|
||||
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
|
||||
import com.iflytek.skillhub.auth.oauth.SystemAccountLoginException;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
|
||||
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.UUID;
|
||||
|
|
@ -27,15 +33,18 @@ public class IdentityBindingService {
|
|||
private final UserAccountRepository userRepo;
|
||||
private final UserRoleBindingRepository roleBindingRepo;
|
||||
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public IdentityBindingService(IdentityBindingRepository bindingRepo,
|
||||
UserAccountRepository userRepo,
|
||||
UserRoleBindingRepository roleBindingRepo,
|
||||
GlobalNamespaceMembershipService globalNamespaceMembershipService) {
|
||||
GlobalNamespaceMembershipService globalNamespaceMembershipService,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.bindingRepo = bindingRepo;
|
||||
this.userRepo = userRepo;
|
||||
this.roleBindingRepo = roleBindingRepo;
|
||||
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -48,8 +57,9 @@ public class IdentityBindingService {
|
|||
if (binding != null) {
|
||||
user = userRepo.findById(binding.getUserId())
|
||||
.orElseThrow(() -> new IllegalStateException("User not found for binding"));
|
||||
ensureExternalLoginAllowed(user);
|
||||
user.setDisplayName(claims.providerLogin());
|
||||
if (claims.email() != null) user.setEmail(claims.email());
|
||||
if (trustedEmail(claims) != null) user.setEmail(claims.email());
|
||||
if (claims.extra().get("avatar_url") != null) {
|
||||
user.setAvatarUrl((String) claims.extra().get("avatar_url"));
|
||||
}
|
||||
|
|
@ -58,25 +68,22 @@ public class IdentityBindingService {
|
|||
user = new UserAccount(
|
||||
"usr_" + UUID.randomUUID(),
|
||||
claims.providerLogin(),
|
||||
claims.email(),
|
||||
trustedEmail(claims),
|
||||
(String) claims.extra().get("avatar_url")
|
||||
);
|
||||
user.setStatus(initialStatus);
|
||||
user = userRepo.save(user);
|
||||
if (initialStatus == UserStatus.ACTIVE) {
|
||||
globalNamespaceMembershipService.ensureMember(user.getId());
|
||||
eventPublisher.publishEvent(
|
||||
new UserActivatedEvent(user.getId(), claims.providerLogin(), claims.email()));
|
||||
}
|
||||
|
||||
binding = new IdentityBinding(user.getId(), claims.provider(), claims.subject(), claims.providerLogin());
|
||||
bindingRepo.save(binding);
|
||||
}
|
||||
|
||||
if (user.getStatus() == UserStatus.PENDING) {
|
||||
throw new com.iflytek.skillhub.auth.oauth.AccountPendingException();
|
||||
}
|
||||
if (user.getStatus() == UserStatus.DISABLED) {
|
||||
throw new com.iflytek.skillhub.auth.oauth.AccountDisabledException();
|
||||
}
|
||||
ensureExternalLoginAllowed(user);
|
||||
|
||||
Set<String> roles = roleBindingRepo.findByUserId(user.getId()).stream()
|
||||
.map(rb -> rb.getRole().getCode())
|
||||
|
|
@ -97,16 +104,14 @@ public class IdentityBindingService {
|
|||
if (existingBinding != null) {
|
||||
UserAccount existingUser = userRepo.findById(existingBinding.getUserId())
|
||||
.orElseThrow(() -> new IllegalStateException("User not found for binding"));
|
||||
if (existingUser.getStatus() == UserStatus.DISABLED) {
|
||||
throw new com.iflytek.skillhub.auth.oauth.AccountDisabledException();
|
||||
}
|
||||
throw new com.iflytek.skillhub.auth.oauth.AccountPendingException();
|
||||
ensureExternalLoginAllowed(existingUser);
|
||||
throw new AccountPendingException();
|
||||
}
|
||||
|
||||
UserAccount user = new UserAccount(
|
||||
"usr_" + UUID.randomUUID(),
|
||||
claims.providerLogin(),
|
||||
claims.email(),
|
||||
trustedEmail(claims),
|
||||
(String) claims.extra().get("avatar_url")
|
||||
);
|
||||
user.setStatus(UserStatus.PENDING);
|
||||
|
|
@ -115,4 +120,23 @@ public class IdentityBindingService {
|
|||
IdentityBinding binding = new IdentityBinding(user.getId(), claims.provider(), claims.subject(), claims.providerLogin());
|
||||
bindingRepo.save(binding);
|
||||
}
|
||||
|
||||
private String trustedEmail(OAuthClaims claims) {
|
||||
return claims.emailVerified() ? claims.email() : null;
|
||||
}
|
||||
|
||||
private void ensureExternalLoginAllowed(UserAccount user) {
|
||||
if (user.isSystemAccount()) {
|
||||
throw new SystemAccountLoginException();
|
||||
}
|
||||
if (user.getStatus() == UserStatus.PENDING) {
|
||||
throw new AccountPendingException();
|
||||
}
|
||||
if (user.getStatus() == UserStatus.DISABLED) {
|
||||
throw new AccountDisabledException();
|
||||
}
|
||||
if (user.getStatus() == UserStatus.MERGED) {
|
||||
throw new AccountMergedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
|||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
|
|
@ -16,6 +17,7 @@ import java.util.Set;
|
|||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -44,6 +46,7 @@ public class LocalAuthService {
|
|||
private final PasswordPolicyValidator passwordPolicyValidator;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final Clock clock;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public LocalAuthService(LocalCredentialRepository credentialRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
|
|
@ -51,7 +54,8 @@ public class LocalAuthService {
|
|||
GlobalNamespaceMembershipService globalNamespaceMembershipService,
|
||||
PasswordPolicyValidator passwordPolicyValidator,
|
||||
PasswordEncoder passwordEncoder,
|
||||
Clock clock) {
|
||||
Clock clock,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.credentialRepository = credentialRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
|
|
@ -59,6 +63,7 @@ public class LocalAuthService {
|
|||
this.passwordPolicyValidator = passwordPolicyValidator;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.clock = clock;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -100,6 +105,7 @@ public class LocalAuthService {
|
|||
passwordEncoder.encode(password)
|
||||
));
|
||||
globalNamespaceMembershipService.ensureMember(user.getId());
|
||||
eventPublisher.publishEvent(new UserActivatedEvent(user.getId(), normalizedUsername, normalizedEmail));
|
||||
|
||||
return buildPrincipal(user);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
|
||||
/**
|
||||
* OAuth authentication exception raised when the mapped platform account was merged.
|
||||
*/
|
||||
public class AccountMergedException extends OAuth2AuthenticationException {
|
||||
|
||||
public AccountMergedException() {
|
||||
super(new OAuth2Error("account_merged", "Account was merged", null));
|
||||
}
|
||||
}
|
||||
|
|
@ -6,10 +6,11 @@ import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
|||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
|
|
@ -19,10 +20,22 @@ import java.util.Map;
|
|||
@Component
|
||||
public class GitHubClaimsExtractor implements OAuthClaimsExtractor {
|
||||
|
||||
private final RestClient restClient = RestClient.builder()
|
||||
.baseUrl("https://api.github.com")
|
||||
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
|
||||
.build();
|
||||
private static final String DEFAULT_API_BASE_URL = "https://api.github.com";
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
public GitHubClaimsExtractor(RestClient.Builder restClientBuilder) {
|
||||
this(restClientBuilder, DEFAULT_API_BASE_URL);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public GitHubClaimsExtractor(RestClient.Builder restClientBuilder,
|
||||
@org.springframework.beans.factory.annotation.Value("${skillhub.auth.github.api-base-url:https://api.github.com}") String apiBaseUrl) {
|
||||
this.restClient = restClientBuilder
|
||||
.baseUrl(apiBaseUrl)
|
||||
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProvider() { return "github"; }
|
||||
|
|
@ -32,9 +45,7 @@ public class GitHubClaimsExtractor implements OAuthClaimsExtractor {
|
|||
Map<String, Object> attrs = oAuth2User.getAttributes();
|
||||
GitHubEmail primaryEmail = loadPrimaryEmail(request);
|
||||
String email = primaryEmail != null ? primaryEmail.email() : (String) attrs.get("email");
|
||||
boolean emailVerified = primaryEmail != null
|
||||
? primaryEmail.verified()
|
||||
: attrs.get("email") != null;
|
||||
boolean emailVerified = primaryEmail != null && primaryEmail.verified();
|
||||
|
||||
return new OAuthClaims(
|
||||
"github",
|
||||
|
|
@ -47,11 +58,17 @@ public class GitHubClaimsExtractor implements OAuthClaimsExtractor {
|
|||
}
|
||||
|
||||
private GitHubEmail loadPrimaryEmail(OAuth2UserRequest request) {
|
||||
List<GitHubEmail> emails = restClient.get()
|
||||
.uri("/user/emails")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + request.getAccessToken().getTokenValue())
|
||||
.retrieve()
|
||||
.body(new org.springframework.core.ParameterizedTypeReference<List<GitHubEmail>>() {});
|
||||
List<GitHubEmail> emails;
|
||||
try {
|
||||
emails = restClient.get()
|
||||
.uri("/user/emails")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + request.getAccessToken().getTokenValue())
|
||||
.retrieve()
|
||||
.body(new org.springframework.core.ParameterizedTypeReference<List<GitHubEmail>>() {});
|
||||
} catch (RestClientException exception) {
|
||||
// A provider lookup failure must never turn an unverified profile email into a trusted email.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (emails == null || emails.isEmpty()) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -98,7 +98,9 @@ public class OAuthLoginFlowService {
|
|||
if (exception instanceof AccountPendingException) {
|
||||
return "/pending-approval";
|
||||
}
|
||||
if (exception instanceof AccountDisabledException) {
|
||||
if (exception instanceof AccountDisabledException
|
||||
|| exception instanceof AccountMergedException
|
||||
|| exception instanceof SystemAccountLoginException) {
|
||||
return "/access-denied";
|
||||
}
|
||||
if (exception instanceof OAuth2AuthenticationException oauth2Exception
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue