diff --git a/Makefile b/Makefile index 3bf04b28..f6640d8d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci +.PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke suite-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci DEV_DIR := .dev DEV_SERVER_PID := $(DEV_DIR)/server.pid @@ -147,6 +147,9 @@ dev-server-restart: ## 重启后端开发服务器 namespace-smoke: ## 运行命名空间工作流 smoke test ./scripts/namespace-smoke-test.sh $(DEV_API_URL) +suite-smoke: ## 运行 Skill Suite 生命周期 smoke test + ./scripts/suite-smoke-test.sh $(DEV_API_URL) + dev-down: ## 停止本地开发环境(含 skill-scanner) $(DEV_COMPOSE) down --remove-orphans diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 4d071b88..1e8bc1a3 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -298,6 +298,20 @@ standalone → replication、Redis standalone/replication → Sentinel 等切换 Cluster,也不将其计入上述内置架构运行时验证范围。应用侧应另行验证 Spring Data、Spring Session 与 Redisson Stream 链路。 +### Skill Suite 审核滚动升级门禁 + +Chart 默认将 `server.suiteReviewWritesEnabled` 设为 `false`,避免新旧 Server Pod 混跑时, +旧实例读取到无法识别的 Suite 审核任务。全新安装可以直接启用: + +```yaml +server: + suiteReviewWritesEnabled: true +``` + +从不支持 Suite 的版本滚动升级时,先保持 `false` 完成全部 Server Pod 升级;确认集群中不再有 +旧版实例后,再改为 `true` 并执行一次滚动更新。单实例 `compose.release.yml` 不存在混跑窗口, +因此已默认启用。 + ### Redis Sentinel 内置 Sentinel 使用 Bitnami Redis 的同一份密码同时保护 Redis 数据节点和 diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index 149cb203..a6e4e788 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -77,6 +77,8 @@ spec: {{- $profiles = printf "%s,redis-sentinel" $profiles }} {{- end }} value: {{ $profiles | quote }} + - name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED + value: {{ .Values.server.suiteReviewWritesEnabled | quote }} # Database - name: SPRING_DATASOURCE_URL diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index 20e98d3f..cd297881 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -37,6 +37,14 @@ fi grep -Fq 'fsGroup: 101' "$TMP_DIR/default.yaml" grep -Fq 'fsGroupChangePolicy: OnRootMismatch' "$TMP_DIR/default.yaml" grep -Fq 'type: Recreate' "$TMP_DIR/default.yaml" +grep -A1 -F 'name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED' "$TMP_DIR/default.yaml" \ + | grep -Fq 'value: "false"' + +render suite-review-enabled "$CHART_DIR" \ + --set server.suiteReviewWritesEnabled=true \ + --show-only templates/server-deployment.yaml >"$TMP_DIR/suite-review-enabled.yaml" +grep -A1 -F 'name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED' "$TMP_DIR/suite-review-enabled.yaml" \ + | grep -Fq 'value: "true"' render custom-server-fsgroup "$CHART_DIR" \ --set server.podSecurityContext.fsGroup=2000 \ diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index 30c62503..1bede63b 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -370,10 +370,11 @@ { "type": "object", "additionalProperties": false, - "required": ["enabled", "replicaCount", "image", "dependencyWait", "service", "storage", "podSecurityContext", "resources", "javaOpts", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], + "required": ["enabled", "replicaCount", "suiteReviewWritesEnabled", "image", "dependencyWait", "service", "storage", "podSecurityContext", "resources", "javaOpts", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], "properties": { "enabled": { "type": "boolean" }, "replicaCount": { "type": "integer", "minimum": 1 }, + "suiteReviewWritesEnabled": { "type": "boolean" }, "image": { "$ref": "#/definitions/image" }, "dependencyWait": { "type": "object", diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index d99b47f1..092e73e4 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -282,6 +282,10 @@ server: enabled: true replicaCount: 1 + # Keep false while old and new Server versions overlap. Set true after every active + # Server instance supports typed review subjects; fresh non-rolling installs may enable it directly. + suiteReviewWritesEnabled: false + image: registry: "" tag: "" diff --git a/cli/src/clients/skillhub-client.ts b/cli/src/clients/skillhub-client.ts index ab787cec..a64d5ca3 100644 --- a/cli/src/clients/skillhub-client.ts +++ b/cli/src/clients/skillhub-client.ts @@ -76,6 +76,58 @@ export interface DryRunResponse { resolvedVersion: string | null } +export interface ServerMetadata { + apiBase?: string + capabilities?: string[] +} + +export interface SuiteInstallMember { + skillId: number + skillVersionId: number + namespace: string + slug: string + version: string + fingerprint: string + downloadUrl: string + position: number + entry: boolean +} + +export interface SuiteInstallPlan { + operationId: string + namespace: string + slug: string + version: string + fingerprint: string + members: SuiteInstallMember[] +} + +export interface SuiteDetailMember { + skillId: number + skillVersionId: number + namespace: string + slug: string + version: string + fingerprint: string + position: number + entry: boolean + blockingReason?: string | null +} + +export interface SuiteDetail { + id: number + versionId: number + namespace: string + slug: string + displayName: string + summary?: string | null + version: string + status: string + visibility: string + available: boolean + members: SuiteDetailMember[] +} + interface PublicErrorFields { msg?: string requestId?: string @@ -94,6 +146,84 @@ export class SkillHubClient { return this.getJson('/auth/whoami') } + async serverMetadata(): Promise { + let response: Response + try { + response = await this.fetchImpl(`${this.registry}/.well-known/clawhub.json`) + } catch { + throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' }) + } + if (!response.ok) return {} + let body: unknown + try { + body = await response.json() + } catch { + // Older registries and reverse proxies may return an HTML landing page at this path. + return {} + } + return typeof body === 'object' && body !== null ? body as ServerMetadata : {} + } + + async suiteInstallPlan( + namespace: string, + slug: string, + version?: string, + idempotencyKey?: string + ): Promise { + const params = version ? `?version=${encodeURIComponent(version)}` : '' + const url = `${this.registry}/api/v1/suites/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/install-plan${params}` + for (let attempt = 0; attempt < 2; attempt += 1) { + let response: Response + try { + response = await this.fetchImpl(url, { + method: 'POST', + headers: { ...this.headers(), ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) } + }) + } catch { + if (attempt === 0) continue + throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' }) + } + if (attempt === 0 && [502, 503, 504].includes(response.status)) continue + try { + return await this.handleJsonResponse(response) + } catch (error) { + // A successful response whose body is truncated is safe to retry with the same key. + if (attempt === 0 && !(error instanceof CliError)) continue + throw error + } + } + throw new CliError('registry unreachable', EXIT.network, { registry: this.registry }) + } + + async suiteDetail(namespace: string, slug: string, version?: string): Promise { + const params = version ? `?version=${encodeURIComponent(version)}` : '' + let response: Response + try { + response = await this.fetchImpl( + `${this.registry}/api/v1/suites/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}${params}`, + { headers: this.headers() } + ) + } catch { + throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' }) + } + if (response.status === 404) { + const error = await this.createResponseError(response, 'json') + throw new CliError(error.message, error.exitCode, { ...error.details, status: 404 }) + } + return this.handleJsonResponse(response) + } + + async downloadFromUrl(downloadUrl: string): Promise { + let response: Response + try { + response = await this.fetchImpl(new URL(downloadUrl, `${this.registry}/`).toString(), { headers: this.headers() }) + } catch { + throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' }) + } + if (!response.ok) throw await this.createResponseError(response, 'download') + return response + } + async search(query: string, limit: number): Promise { const params = new URLSearchParams({ q: query, limit: String(limit) }) return this.getJson(`/skills/search?${params}`) @@ -244,7 +374,7 @@ export class SkillHubClient { exitCode = EXIT.auth } else if (response.status === 404) { fallback = kind === 'download' ? 'skill or version not found' : 'resource not found' - } else if (response.status === 502 || response.status === 503) { + } else if (response.status === 502 || response.status === 503 || response.status === 504) { fallback = kind === 'download' ? `download failed with status ${response.status}` : `registry returned ${response.status}` diff --git a/cli/src/commands/help.ts b/cli/src/commands/help.ts index 64cf1fe9..8a42e1d5 100644 --- a/cli/src/commands/help.ts +++ b/cli/src/commands/help.ts @@ -45,6 +45,16 @@ export const commands = { 'skillhub install pdf-parser --scope project --agent codex' ] }, + suite: { + summary: 'Install and manage a Skill Suite locally', + usage: 'skillhub suite [options]', + examples: [ + 'skillhub suite install @global/marketing --scope user', + 'skillhub suite check @global/marketing', + 'skillhub suite upgrade @global/marketing --check', + 'skillhub suite remove @global/marketing' + ] + }, upgrade: { summary: 'Upgrade explicitly selected installed skills', usage: 'skillhub upgrade [--namespace ] [--agent ] [--dir ] [--registry ] [--check] [--force] [--json]', diff --git a/cli/src/commands/suite.ts b/cli/src/commands/suite.ts new file mode 100644 index 00000000..38367ff8 --- /dev/null +++ b/cli/src/commands/suite.ts @@ -0,0 +1,153 @@ +import { ConfigStore } from '../stores/config-store' +import { CredentialsStore } from '../stores/credentials-store' +import { resolveRegistry, resolveToken } from '../services/registry-service' +import { resolveSkillName } from '../shared/skill-name-parser' +import { resolveInstallTargets } from '../agents/resolver' +import { resolveEffectiveScope } from './install' +import { computeStrictIsTTY } from '../shared/tty' +import { CliError } from '../shared/errors' +import { EXIT } from '../shared/constants' +import { checkSuite, installSuite, planSuiteUpgrade, removeSuite, upgradeSuite } from '../services/suite-service' + +export interface SuiteCommandOptions { + version?: string | undefined + scope?: string | undefined + agent?: string[] | undefined + dir?: string | undefined + force?: boolean | undefined + check?: boolean | undefined + registry?: string | undefined + token?: string | undefined + json?: boolean | undefined +} + +export async function suiteCommand( + action: string, + coordinate: string, + options: SuiteCommandOptions +): Promise { + if (!['install', 'check', 'upgrade', 'remove'].includes(action)) { + throw new CliError(`unknown suite action: ${action}`, EXIT.usage, { + next: 'use install, check, upgrade, or remove' + }) + } + 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)) + const { namespace, slug } = resolveSkillName(coordinate) + const common = { registry, token, namespace, slug } + + if (action === 'install') { + const isTTY = computeStrictIsTTY({ + stdinIsTTY: process.stdin.isTTY === true, + stdoutIsTTY: process.stdout.isTTY === true, + json: Boolean(options.json) + }) + const scope = await resolveEffectiveScope(options, { + isTTY, + promptScope: async () => { + const prompts = await import('prompts') + const { selected } = await prompts.default({ + type: 'select', + name: 'selected', + message: 'Install Suite for user or project?', + choices: [ + { title: 'User', value: 'user' }, + { title: 'Project', value: 'project' } + ] + }) + if (!selected) throw new CliError('installation cancelled', EXIT.usage) + return selected as 'user' | 'project' + } + }) + const targets = await resolveInstallTargets({ + cwd: process.cwd(), + scope, + dir: options.dir, + agents: options.agent ?? [], + json: Boolean(options.json), + interactive: isTTY + }) + const result = await installSuite({ + ...common, + version: options.version, + targets, + force: Boolean(options.force) + }) + if (options.json) return JSON.stringify({ ok: true, suite: result.plan, installed: result.installed, reused: result.reused }) + return [ + `Installed Suite @${namespace}/${slug}@${result.plan.version}`, + ...result.installed.map(item => `Installed @${item.namespace}/${item.slug} -> ${item.dir} (${item.agent})`), + ...result.reused.map(item => `Reused @${item.namespace}/${item.slug} -> ${item.dir} (${item.agent})`) + ].join('\n') + } + + if (hasInstallOnlyOptions(options)) { + throw new CliError('--scope, --agent, --dir, --force, and --version are only valid with suite install', EXIT.usage) + } + + if (action === 'check') { + const result = await checkSuite(common) + if (options.json) return JSON.stringify({ ok: result.current, ...result }) + return [ + `Suite @${namespace}/${slug}@${result.suite.version}: ${result.current ? 'current' : 'changes detected'}`, + ...(result.remoteVersion && result.remoteVersion !== result.suite.version + ? [`Remote version: ${result.remoteVersion}`] + : []), + ...(!result.installedVersionAvailable + ? [`Installed version unavailable: ${result.blockingReasons.join(', ') || 'unknown reason'}`] + : []), + ...result.members.map(member => `${member.status.padEnd(8)} @${member.namespace}/${member.slug}@${member.version} ${member.dir}`) + ].join('\n') + } + + if (action === 'remove') { + const result = await removeSuite(common) + if (options.json) return JSON.stringify({ ok: true, ...result }) + return [ + `Removed Suite @${namespace}/${slug}`, + ...result.removed.map(dir => `Removed member: ${dir}`), + ...result.preserved.map(item => `Preserved member (${item.reason}): ${item.dir}`) + ].join('\n') + } + + if (options.check) { + const plan = await planSuiteUpgrade(common) + return renderUpgradePlan(plan, Boolean(options.json)) + } + const { upgrade, result } = await upgradeSuite(common) + if (options.json) return JSON.stringify({ ok: true, upgrade, result }) + if (!result) return `Suite @${namespace}/${slug}@${upgrade.current.version} is current` + return [ + `Upgraded Suite @${namespace}/${slug}: ${upgrade.current.version} -> ${upgrade.remote.version}`, + ...upgrade.changes.map(change => renderChange(change)) + ].join('\n') +} + +function hasInstallOnlyOptions(options: SuiteCommandOptions): boolean { + return options.scope !== undefined || options.agent !== undefined || options.dir !== undefined || + options.force !== undefined || options.version !== undefined +} + +function renderUpgradePlan(plan: Awaited>, json: boolean): string { + if (json) return JSON.stringify({ + ok: true, + currentVersion: plan.current.version, + remoteVersion: plan.remote.version, + changes: plan.changes + }) + return [ + `Suite upgrade plan: ${plan.current.version} -> ${plan.remote.version}`, + ...(plan.changes.length === 0 ? ['No member changes'] : plan.changes.map(renderChange)) + ].join('\n') +} + +function renderChange(change: Awaited>['changes'][number]): string { + const versions = change.action === 'add' + ? ` -> ${change.toVersion}` + : change.action === 'remove' + ? ` ${change.fromVersion} -> removed` + : ` ${change.fromVersion} -> ${change.toVersion}` + return `${change.action.padEnd(7)} ${change.coordinate}${versions}` +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 053e1e52..84b83e0c 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -9,6 +9,7 @@ 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 { suiteCommand, type SuiteCommandOptions } from './commands/suite' import { syncDiffCommand, syncPullCommand, syncPushCommand, syncStatusCommand, type SyncCommonOptions, type SyncPullOptions, type SyncPushOptions } from './commands/sync' import { updateCommand } from './commands/update' import { upgradeCommand, type UpgradeCommandOptions } from './commands/upgrade' @@ -248,6 +249,24 @@ cli return runCommand(() => installCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json)) }) +cli + .command('suite ', 'Install and manage a Skill Suite locally') + .option('--version ', 'Exact Suite version for install') + .option('--scope ', 'Install scope: user or project') + .option('--agent ', 'Agent profile (repeatable)') + .option('--dir ', 'Install directory') + .option('--force', 'Replace same-source member versions') + .option('--check', 'Show an upgrade plan without writing') + .option('--registry ', 'Registry URL') + .option('--token ', 'API token') + .option('--json', 'Output JSON') + .action((action: string, coordinate: string, options: SuiteCommandOptions & { agent?: string | string[] }) => { + return runCommand( + () => suiteCommand(action, coordinate, { ...options, agent: toArray(options.agent) }), + Boolean(options.json) + ) + }) + cli .command('upgrade [...coordinates]', 'Upgrade explicitly selected installed skills') .option('--namespace ', 'Filter a bare slug by namespace') diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index ba98ac5a..0bfc7cf7 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -31,6 +31,7 @@ export interface InstallOptions { expectedTargetFiles?: Record> | undefined allowTargetDrift?: boolean | undefined requireExistingTargets?: boolean | undefined + client?: SkillHubClient | undefined /** Internal test seam for lock lifecycle failures; production uses acquireSkillTargetLock. */ acquireTargetLock?: typeof acquireSkillTargetLock } @@ -99,9 +100,11 @@ export async function installSkill(options: InstallOptions): Promise + reused: Array<{ namespace: string; slug: string; agent: string; dir: string }> +} + +export interface SuiteCheckResult { + suite: InventorySuite + remoteVersion?: string + current: boolean + installedVersionAvailable: boolean + blockingReasons: string[] + members: Array<{ + namespace: string + slug: string + version: string + dir: string + status: 'ok' | 'missing' | 'modified' + }> +} + +export interface SuiteRemoveResult { + removed: string[] + preserved: Array<{ dir: string; reason: 'shared' | 'modified' | 'missing' }> +} + +export interface SuiteUpgradePlan { + current: InventorySuite + remote: SuiteDetail + targets: AgentCandidate[] + changes: Array<{ + coordinate: string + action: 'add' | 'remove' | 'change' + fromVersion?: string + toVersion?: string + }> +} + +interface PreparedTarget { + member: SuiteInstallPlan['members'][number] + target: AgentCandidate + installDir: string + stagedDir: string + replace: boolean + reuse: boolean + backupDir: string | undefined + committed: boolean +} + +interface RetiredTarget { + item: InventoryItem + target: InventoryTarget + backupDir: string + moved: boolean +} + +export function suiteSource(namespace: string, slug: string, version: string): string { + return `suite:@${namespace}/${slug}@${version}` +} + +export async function assertSuiteCapability(client: SkillHubClient): Promise { + const metadata = await client.serverMetadata() + if (!metadata.capabilities?.includes(SUITE_CAPABILITY)) { + throw new CliError('registry does not support Skill Suites', EXIT.validation, { + capability: SUITE_CAPABILITY, + next: 'upgrade the SkillHub server before using suite commands' + }) + } +} + +/** + * Installs every exact member as one local transaction. Downloads and fingerprint checks finish + * before any live Skill directory is replaced; commit failures restore all captured backups. + */ +export async function installSuite(options: SuiteInstallOptions): Promise { + const client = options.client ?? new SkillHubClient(options.registry, options.token) + const renameOperation = options.renameOperation ?? rename + await assertSuiteCapability(client) + // The same client key survives the single HTTP retry; the Server owns the operation ID. + const idempotencyKey = randomUUID() + const plan = await client.suiteInstallPlan( + options.namespace, + options.slug, + options.version, + idempotencyKey + ) + assertNoTargetCollisions(plan) + + const store = new InventoryStore(options.home) + const before = await store.read() + const previousSuite = installedSuites(before).find(candidate => + candidate.registry === options.registry && candidate.namespace === plan.namespace && candidate.slug === plan.slug) + const source = suiteSource(plan.namespace, plan.slug, plan.version) + const stageHome = await mkdtemp(join(tmpdir(), 'skillhub-suite-inventory-')) + const stageToken = `${process.pid}-${Date.now()}` + const prepared: PreparedTarget[] = [] + const retired = await prepareRetiredTargets(before, previousSuite, plan, stageToken) + + try { + await preflightExistingTargets(before, options.registry, plan, options.targets, options.force) + + for (const member of plan.members) { + const stagingTargets = options.targets.map((target, index) => ({ + ...target, + rootDir: join(resolve(target.rootDir), `.skillhub-suite-stage-${stageToken}-${index}`) + })) + await installSkill({ + registry: options.registry, + token: options.token, + namespace: member.namespace, + slug: member.slug, + version: member.version, + targets: stagingTargets, + force: false, + home: stageHome, + client, + resolved: { + namespace: member.namespace, + slug: member.slug, + version: member.version, + versionId: member.skillVersionId, + fingerprint: member.fingerprint, + downloadUrl: member.downloadUrl + } + }) + + for (let index = 0; index < options.targets.length; index += 1) { + const target = options.targets[index]! + const installDir = join(resolve(target.rootDir), member.slug) + const stagedDir = join(stagingTargets[index]!.rootDir, member.slug) + const reuse = await isReusable(before, options.registry, member, installDir) + prepared.push({ + member, + target: { ...target, rootDir: resolve(target.rootDir) }, + installDir, + stagedDir, + replace: await pathExists(installDir) && !reuse, + reuse, + committed: false, + backupDir: undefined + }) + } + } + + const releases: Array<() => Promise> = [] + try { + const lockTargets = [ + ...prepared.map(item => ({ rootDir: item.target.rootDir, slug: item.member.slug })), + ...retired.map(item => ({ rootDir: item.target.rootDir, slug: item.item.slug })) + ].sort((a, b) => join(a.rootDir, a.slug).localeCompare(join(b.rootDir, b.slug))) + const lockedPaths = new Set() + for (const target of lockTargets) { + const path = join(resolve(target.rootDir), target.slug) + if (lockedPaths.has(path)) continue + lockedPaths.add(path) + releases.push(await acquireSkillTargetLock(target.rootDir, target.slug)) + } + // Recheck after target locks so a concurrent direct install cannot invalidate preflight. + const lockedInventory = await store.read() + await preflightExistingTargets(lockedInventory, options.registry, plan, options.targets, options.force) + + for (const item of prepared) { + if (item.reuse) { + await rm(item.stagedDir, { recursive: true, force: true }) + continue + } + if (item.replace) { + item.backupDir = `${item.installDir}.skillhub-suite-backup-${stageToken}` + await renameOperation(item.installDir, item.backupDir) + } + await renameOperation(item.stagedDir, item.installDir) + item.committed = true + } + for (const item of retired) { + if (await pathExists(item.target.installDir)) { + await renameOperation(item.target.installDir, item.backupDir) + item.moved = true + } + } + + try { + await store.mutateAtomic(inventory => commitInventory( + inventory, options.registry, plan, source, prepared, retired)) + } catch (error) { + await rollbackTransaction(prepared, retired, error, renameOperation) + throw error + } + + for (const item of prepared) { + if (item.backupDir) { + await rm(item.backupDir, { recursive: true, force: true }).catch(() => {}) + item.backupDir = undefined + } + } + for (const item of retired) { + await rm(item.backupDir, { recursive: true, force: true }).catch(() => {}) + item.moved = false + } + } catch (error) { + if (prepared.some(item => item.committed || item.backupDir) || retired.some(item => item.moved)) { + await rollbackTransaction(prepared, retired, error, renameOperation) + } + throw error + } finally { + for (const release of releases.reverse()) await release().catch(() => {}) + } + + return { + plan, + installed: prepared.filter(item => !item.reuse).map(item => ({ + namespace: item.member.namespace, + slug: item.member.slug, + agent: item.target.agent, + dir: item.installDir + })), + reused: prepared.filter(item => item.reuse).map(item => ({ + namespace: item.member.namespace, + slug: item.member.slug, + agent: item.target.agent, + dir: item.installDir + })) + } + } finally { + await rm(stageHome, { recursive: true, force: true }).catch(() => {}) + for (const item of prepared) { + await rm(dirname(item.stagedDir), { recursive: true, force: true }).catch(() => {}) + } + } +} + +export async function checkSuite(options: { + registry: string + token?: string | undefined + namespace: string + slug: string + home?: string | undefined + client?: SkillHubClient | undefined +}): Promise { + const client = options.client ?? new SkillHubClient(options.registry, options.token) + await assertSuiteCapability(client) + const inventory = await new InventoryStore(options.home).read() + const suite = findInstalledSuite(inventory, options.registry, options.namespace, options.slug) + const installedRemote = await client.suiteDetail(options.namespace, options.slug, suite.version) + let latestRemote: SuiteDetail | undefined + try { + latestRemote = await client.suiteDetail(options.namespace, options.slug) + } catch (error) { + if (!(error instanceof CliError) || error.details.status !== 404) throw error + } + const members: SuiteCheckResult['members'] = [] + for (const member of suite.members) { + for (const installDir of member.installDirs) { + let status: SuiteCheckResult['members'][number]['status'] = 'missing' + if (await pathExists(installDir)) { + status = (await snapshotSkillDirectory(installDir)).fingerprint === member.fingerprint + ? 'ok' + : 'modified' + } + members.push({ + namespace: member.namespace, + slug: member.slug, + version: member.version, + dir: installDir, + status + }) + } + } + return { + suite, + ...(latestRemote ? { remoteVersion: latestRemote.version } : {}), + current: latestRemote?.version === suite.version + && installedRemote.available + && members.every(member => member.status === 'ok'), + installedVersionAvailable: installedRemote.available, + blockingReasons: installedRemote.members + .flatMap(member => member.blockingReason ? [member.blockingReason] : []), + members + } +} + +export async function removeSuite(options: { + registry: string + namespace: string + slug: string + home?: string | undefined +}): Promise { + const store = new InventoryStore(options.home) + const inventory = await store.read() + const suite = findInstalledSuite(inventory, options.registry, options.namespace, options.slug) + const source = suiteSource(suite.namespace, suite.slug, suite.version) + const removable: Array<{ item: InventoryItem; target: InventoryTarget; backupDir: string }> = [] + const preserved: SuiteRemoveResult['preserved'] = [] + const token = `${process.pid}-${Date.now()}` + + for (const member of suite.members) { + const item = inventory.items.find(candidate => + candidate.registry === options.registry && candidate.namespace === member.namespace && candidate.slug === member.slug) + if (!item) continue + for (const installDir of member.installDirs) { + const target = item.targets.find(candidate => resolve(candidate.installDir) === resolve(installDir)) + if (!target) continue + const remainingSources = targetInstalledBy(item, target).filter(candidate => candidate !== source) + if (remainingSources.length > 0) { + preserved.push({ dir: installDir, reason: 'shared' }) + } else if (!(await pathExists(installDir))) { + preserved.push({ dir: installDir, reason: 'missing' }) + } else if ((await snapshotSkillDirectory(installDir)).fingerprint !== member.fingerprint) { + preserved.push({ dir: installDir, reason: 'modified' }) + } else { + removable.push({ item, target, backupDir: `${installDir}.skillhub-suite-remove-${token}` }) + } + } + } + + const releases: Array<() => Promise> = [] + const moved: typeof removable = [] + try { + for (const candidate of [...removable].sort((a, b) => a.target.installDir.localeCompare(b.target.installDir))) { + releases.push(await acquireSkillTargetLock(candidate.target.rootDir, candidate.item.slug)) + } + const lockedInventory = await store.read() + for (const candidate of removable) { + const current = lockedInventory.items.find(item => + item.registry === candidate.item.registry && item.namespace === candidate.item.namespace && + item.slug === candidate.item.slug) + const target = current?.targets.find(item => + resolve(item.installDir) === resolve(candidate.target.installDir)) + if (!current || !target + || targetInstalledBy(current, target).some(candidateSource => candidateSource !== source)) { + throw new CliError(`Suite member ownership changed before removal: ${candidate.target.installDir}`, EXIT.validation, { + path: candidate.target.installDir, + next: 'run `skillhub suite check` and retry' + }) + } + } + for (const candidate of removable) { + await rename(candidate.target.installDir, candidate.backupDir) + moved.push(candidate) + } + await store.mutateAtomic(current => { + current.suites = installedSuites(current).filter(candidate => + candidate.registry !== options.registry || candidate.namespace !== options.namespace || candidate.slug !== options.slug) + for (const item of current.items) { + const deletedDirs = new Set(removable + .filter(candidate => candidate.item.registry === item.registry && + candidate.item.namespace === item.namespace && candidate.item.slug === item.slug) + .map(candidate => resolve(candidate.target.installDir))) + item.targets = item.targets + .filter(target => !deletedDirs.has(resolve(target.installDir))) + .map(target => ({ + ...target, + installedBy: targetInstalledBy(item, target).filter(candidate => candidate !== source) + })) + item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? []))) + } + current.items = current.items.filter(item => item.targets.length > 0) + }) + } catch (error) { + for (const candidate of moved.reverse()) { + await rename(candidate.backupDir, candidate.target.installDir).catch(() => {}) + } + throw error + } finally { + for (const release of releases.reverse()) await release().catch(() => {}) + } + + for (const candidate of removable) { + await rm(candidate.backupDir, { recursive: true, force: true }).catch(() => {}) + } + return { removed: removable.map(candidate => candidate.target.installDir), preserved } +} + +export async function planSuiteUpgrade(options: { + registry: string + token?: string | undefined + namespace: string + slug: string + home?: string | undefined + client?: SkillHubClient | undefined +}): Promise { + const client = options.client ?? new SkillHubClient(options.registry, options.token) + await assertSuiteCapability(client) + const inventory = await new InventoryStore(options.home).read() + const current = findInstalledSuite(inventory, options.registry, options.namespace, options.slug) + const remote = await client.suiteDetail(options.namespace, options.slug) + if (!remote.available) { + throw new CliError(`Suite @${options.namespace}/${options.slug}@${remote.version} is unavailable`, EXIT.validation, { + blockedMembers: remote.members.filter(member => member.blockingReason).map(member => ({ + coordinate: `@${member.namespace}/${member.slug}@${member.version}`, + reason: member.blockingReason + })) + }) + } + + const currentMembers = new Map(current.members.map(member => [`${member.namespace}\u0000${member.slug}`, member])) + const remoteMembers = new Map(remote.members.map(member => [`${member.namespace}\u0000${member.slug}`, member])) + const changes: SuiteUpgradePlan['changes'] = [] + for (const [key, member] of remoteMembers) { + const existing = currentMembers.get(key) + if (!existing) { + changes.push({ coordinate: `@${member.namespace}/${member.slug}`, action: 'add', toVersion: member.version }) + } else if (existing.version !== member.version || existing.fingerprint !== member.fingerprint) { + changes.push({ + coordinate: `@${member.namespace}/${member.slug}`, + action: 'change', + fromVersion: existing.version, + toVersion: member.version + }) + } + } + for (const [key, member] of currentMembers) { + if (!remoteMembers.has(key)) { + changes.push({ coordinate: `@${member.namespace}/${member.slug}`, action: 'remove', fromVersion: member.version }) + } + } + return { current, remote, targets: installedSuiteTargets(inventory, current), changes } +} + +export async function upgradeSuite(options: { + registry: string + token?: string | undefined + namespace: string + slug: string + home?: string | undefined + client?: SkillHubClient | undefined +}): Promise<{ upgrade: SuiteUpgradePlan; result?: SuiteInstallResult }> { + const upgrade = await planSuiteUpgrade(options) + if (upgrade.current.version === upgrade.remote.version && upgrade.changes.length === 0) { + return { upgrade } + } + const result = await installSuite({ + ...options, + version: upgrade.remote.version, + targets: upgrade.targets, + force: true + }) + return { upgrade, result } +} + +function findInstalledSuite( + inventory: Inventory, + registry: string, + namespace: string, + slug: string +): InventorySuite { + const suite = installedSuites(inventory).find(candidate => + candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug) + if (!suite) { + throw new CliError(`Suite @${namespace}/${slug} is not installed`, EXIT.validation, { + next: `run \`skillhub suite install @${namespace}/${slug}\`` + }) + } + return suite +} + +function installedSuiteTargets(inventory: Inventory, suite: InventorySuite): AgentCandidate[] { + const dirs = new Set(suite.members.flatMap(member => member.installDirs).map(installDir => dirname(resolve(installDir)))) + const targets = new Map() + for (const item of inventory.items) { + for (const target of item.targets) { + if (!dirs.has(resolve(target.rootDir))) continue + targets.set(resolve(target.rootDir), { + agent: target.agent, + rootDir: resolve(target.rootDir), + scope: 'user', + source: 'explicit' + }) + } + } + if (targets.size === 0) { + throw new CliError('installed Suite has no recoverable target directories', EXIT.validation, { + next: 'remove the stale Suite inventory entry and install it again' + }) + } + return [...targets.values()] +} + +function assertNoTargetCollisions(plan: SuiteInstallPlan): void { + const seen = new Map() + for (const member of plan.members) { + const coordinate = `@${member.namespace}/${member.slug}` + const existing = seen.get(member.slug) + if (existing && existing !== coordinate) { + throw new CliError(`suite members ${existing} and ${coordinate} use the same local directory`, EXIT.validation, { + slug: member.slug, + next: 'publish a Suite version without colliding member slugs' + }) + } + seen.set(member.slug, coordinate) + } +} + +async function preflightExistingTargets( + inventory: Inventory, + registry: string, + plan: SuiteInstallPlan, + targets: AgentCandidate[], + force: boolean +): Promise { + for (const member of plan.members) { + const selectedDirs = new Set(targets.map(target => join(resolve(target.rootDir), member.slug))) + const sameItem = inventory.items.find(item => + item.registry === registry && + item.namespace === member.namespace && item.slug === member.slug) + if (sameItem && sameItem.version !== member.version) { + const retained = sameItem.targets.filter(target => !selectedDirs.has(resolve(target.installDir))) + if (retained.length > 0) { + throw new CliError(`partial Suite install would split versions for @${member.namespace}/${member.slug}`, EXIT.validation, { + retainedTargets: retained.map(target => target.installDir), + next: 'select every installed target or keep the existing Suite version' + }) + } + } + + for (const target of targets) { + const installDir = join(resolve(target.rootDir), member.slug) + const owner = inventory.items.find(item => + item.targets.some(existing => resolve(existing.installDir) === installDir)) + if (!owner && await pathExists(installDir)) { + throw new CliError(`unmanaged directory already exists at ${installDir}`, EXIT.validation, { + path: installDir, + next: 'move the directory or import it with `skillhub doctor` before installing the Suite' + }) + } + if (owner && (owner.registry !== registry || owner.namespace !== member.namespace || owner.slug !== member.slug)) { + throw new CliError(`install target is owned by @${owner.namespace}/${owner.slug}`, EXIT.validation, { + path: installDir, + next: 'choose another target or remove the conflicting Skill explicitly' + }) + } + const currentSuitePrefix = `suite:@${plan.namespace}/${plan.slug}@` + const ownerTarget = owner?.targets.find(existing => resolve(existing.installDir) === installDir) + if (owner && ownerTarget && owner.version !== member.version && targetInstalledBy(owner, ownerTarget).some(source => + source.startsWith('suite:') && !source.startsWith(currentSuitePrefix))) { + throw new CliError(`shared Suite member @${member.namespace}/${member.slug} cannot change version in place`, EXIT.validation, { + path: installDir, + next: 'install the Suite into another target or upgrade the sharing Suite first' + }) + } + if (owner && owner.version !== member.version && !force) { + throw new CliError(`different Skill version already installed at ${installDir}`, EXIT.validation, { + currentVersion: owner.version, + requestedVersion: member.version, + next: 'pass --force only if replacing this same Skill is intended' + }) + } + } + } +} + +async function isReusable( + inventory: Inventory, + registry: string, + member: SuiteInstallPlan['members'][number], + installDir: string +): Promise { + const item = inventory.items.find(candidate => + candidate.registry === registry && candidate.namespace === member.namespace && candidate.slug === member.slug && + candidate.version === member.version && candidate.fingerprint === member.fingerprint && + candidate.targets.some(target => resolve(target.installDir) === installDir)) + if (!item || !(await pathExists(installDir))) return false + return (await snapshotSkillDirectory(installDir)).fingerprint === member.fingerprint +} + +function commitInventory( + inventory: Inventory, + registry: string, + plan: SuiteInstallPlan, + source: string, + prepared: PreparedTarget[], + retired: RetiredTarget[] +): void { + const suitePrefix = `suite:@${plan.namespace}/${plan.slug}@` + for (const item of inventory.items) { + if (item.registry === registry) { + item.targets = item.targets.map(target => ({ + ...target, + installedBy: targetInstalledBy(item, target) + .filter(candidate => !candidate.startsWith(suitePrefix)) + })) + item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? []))) + } + } + for (const member of plan.members) { + let item = inventory.items.find(candidate => + candidate.registry === registry && candidate.namespace === member.namespace && candidate.slug === member.slug) + if (!item) { + item = { + registry, + namespace: member.namespace, + slug: member.slug, + version: member.version, + fingerprint: member.fingerprint, + installedBy: [source], + targets: [] + } + inventory.items.push(item) + } + item.version = member.version + item.fingerprint = member.fingerprint + item.installedBy = Array.from(new Set([...installedBy(item), source])) + for (const preparedTarget of prepared.filter(candidate => candidate.member === member)) { + const target: InventoryTarget = { + agent: preparedTarget.target.agent, + rootDir: preparedTarget.target.rootDir, + installDir: preparedTarget.installDir, + installedAt: new Date().toISOString(), + installedBy: [source] + } + const index = item.targets.findIndex(existing => resolve(existing.installDir) === preparedTarget.installDir) + if (index >= 0) { + target.installedBy = Array.from(new Set([ + ...targetInstalledBy(item, item.targets[index]!), + source + ])) + item.targets[index] = target + } + else item.targets.push(target) + } + item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? []))) + } + + const suite: InventorySuite = { + registry, + namespace: plan.namespace, + slug: plan.slug, + version: plan.version, + fingerprint: plan.fingerprint, + members: plan.members.map(member => ({ + namespace: member.namespace, + slug: member.slug, + version: member.version, + fingerprint: member.fingerprint, + installDirs: prepared.filter(item => item.member === member).map(item => item.installDir) + })) + } + inventory.suites = installedSuites(inventory).filter(candidate => + candidate.registry !== registry || candidate.namespace !== plan.namespace || candidate.slug !== plan.slug) + inventory.suites.push(suite) + + const retiredDirs = new Set(retired.map(item => resolve(item.target.installDir))) + for (const item of inventory.items) { + item.targets = item.targets.filter(target => !retiredDirs.has(resolve(target.installDir))) + } + inventory.items = inventory.items.filter(item => item.targets.length > 0) +} + +async function rollbackTransaction( + prepared: PreparedTarget[], + retired: RetiredTarget[], + originalError: unknown, + renameOperation: typeof rename +): Promise { + const failures: Array<{ path: string; error: string }> = [] + for (const item of [...retired].reverse()) { + if (!item.moved) continue + try { + await renameOperation(item.backupDir, item.target.installDir) + item.moved = false + } catch (error) { + failures.push({ path: item.backupDir, error: describe(error) }) + } + } + for (const item of [...prepared].reverse()) { + try { + if (item.committed) await rm(item.installDir, { recursive: true, force: true }) + if (item.backupDir) await renameOperation(item.backupDir, item.installDir) + item.committed = false + item.backupDir = undefined + } catch (error) { + failures.push({ path: item.backupDir ?? item.installDir, error: describe(error) }) + } + } + if (failures.length > 0) { + throw new CliError('Suite installation failed and rollback was incomplete', EXIT.filesystem, { + originalError: describe(originalError), + rollbackFailures: failures, + next: 'restore the retained backup directories before retrying' + }) + } +} + +async function prepareRetiredTargets( + inventory: Inventory, + previous: InventorySuite | undefined, + next: SuiteInstallPlan, + token: string +): Promise { + if (!previous) return [] + const nextCoordinates = new Set(next.members.map(member => `${member.namespace}\u0000${member.slug}`)) + const oldSource = suiteSource(previous.namespace, previous.slug, previous.version) + const retired: RetiredTarget[] = [] + for (const member of previous.members) { + if (nextCoordinates.has(`${member.namespace}\u0000${member.slug}`)) continue + const item = inventory.items.find(candidate => + candidate.registry === previous.registry && candidate.namespace === member.namespace && candidate.slug === member.slug) + if (!item) continue + for (const installDir of member.installDirs) { + const target = item.targets.find(candidate => resolve(candidate.installDir) === resolve(installDir)) + if (!target || !(await pathExists(installDir))) continue + if (targetInstalledBy(item, target).some(source => source !== oldSource)) continue + if ((await snapshotSkillDirectory(installDir)).fingerprint !== member.fingerprint) continue + retired.push({ + item, + target, + backupDir: `${installDir}.skillhub-suite-retired-${token}`, + moved: false + }) + } + } + return retired +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/cli/src/stores/inventory-store.ts b/cli/src/stores/inventory-store.ts index 8d540744..6ef4b0fb 100644 --- a/cli/src/stores/inventory-store.ts +++ b/cli/src/stores/inventory-store.ts @@ -8,6 +8,8 @@ export interface InventoryTarget { rootDir: string installDir: string installedAt: string + /** Sources that own this exact target. Missing values inherit the legacy item-level sources. */ + installedBy?: string[] } export interface InventoryItem { @@ -16,11 +18,54 @@ export interface InventoryItem { slug: string version: string fingerprint?: string + /** Missing on legacy records and therefore interpreted as a direct install. */ + installedBy?: string[] targets: InventoryTarget[] } +export interface InventorySuiteMember { + namespace: string + slug: string + version: string + fingerprint: string + installDirs: string[] +} + +export interface InventorySuite { + registry: string + namespace: string + slug: string + version: string + fingerprint: string + members: InventorySuiteMember[] +} + export interface Inventory { items: InventoryItem[] + suites?: InventorySuite[] +} + +export function installedBy(item: InventoryItem): string[] { + return item.installedBy ?? ['direct'] +} + +export function targetInstalledBy(item: InventoryItem, target: InventoryTarget): string[] { + return target.installedBy ?? installedBy(item) +} + +function addDirectSource(item: InventoryItem, target: InventoryTarget): InventoryTarget { + return { + ...target, + installedBy: Array.from(new Set([...targetInstalledBy(item, target), 'direct'])) + } +} + +function refreshItemSources(item: InventoryItem): void { + item.installedBy = Array.from(new Set(item.targets.flatMap(target => targetInstalledBy(item, target)))) +} + +export function installedSuites(inventory: Inventory): InventorySuite[] { + return inventory.suites ?? [] } export class InventoryVersionConflictError extends Error { @@ -58,7 +103,7 @@ export class InventoryStore { } } - private async mutateAtomic(mutate: (inventory: Inventory) => T): Promise { + async mutateAtomic(mutate: (inventory: Inventory) => T): Promise { await ensureDir(dirname(this.path)) let release: (() => Promise) | null = null try { @@ -118,8 +163,11 @@ export class InventoryStore { 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 - else item.targets.push(target) + const previous = existingIdx >= 0 ? item.targets[existingIdx]! : target + const next = addDirectSource(item, { ...target, installedBy: targetInstalledBy(item, previous) }) + if (existingIdx >= 0) item.targets[existingIdx] = next + else item.targets.push(next) + refreshItemSources(item) }) } @@ -165,12 +213,13 @@ export class InventoryStore { let item = inventory.items.find(candidate => candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug) if (!item) { - item = { registry, namespace, slug, version, targets: [] } + item = { registry, namespace, slug, version, installedBy: ['direct'], targets: [] } inventory.items.push(item) } item.version = version if (fingerprint !== undefined) item.fingerprint = fingerprint - item.targets.push(target) + item.targets.push({ ...target, installedBy: ['direct'] }) + refreshItemSources(item) }) } @@ -204,12 +253,13 @@ export class InventoryStore { let item = inventory.items.find(candidate => candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug) if (!item) { - item = { registry, namespace, slug, version, targets: [] } + item = { registry, namespace, slug, version, installedBy: ['direct'], targets: [] } inventory.items.push(item) } item.version = version if (fingerprint !== undefined) item.fingerprint = fingerprint - item.targets.push(...targets) + item.targets.push(...targets.map(target => ({ ...target, installedBy: ['direct'] }))) + refreshItemSources(item) }) } } diff --git a/cli/test/unit/services/suite-service.test.ts b/cli/test/unit/services/suite-service.test.ts new file mode 100644 index 00000000..5b789560 --- /dev/null +++ b/cli/test/unit/services/suite-service.test.ts @@ -0,0 +1,750 @@ +import { createHash } from 'node:crypto' +import { access, mkdir, mkdtemp, readFile, readdir, rename, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, test } from 'bun:test' +import { zipSync } from 'fflate' +import { SkillHubClient, type SuiteInstallPlan } from '../../../src/clients/skillhub-client' +import { InventoryStore } from '../../../src/stores/inventory-store' +import { + checkSuite, + installSuite, + planSuiteUpgrade, + removeSuite, + upgradeSuite +} from '../../../src/services/suite-service' + +const registry = 'http://registry.test' + +function archive(content: string): { bytes: Uint8Array; fingerprint: string } { + const bytes = zipSync({ 'SKILL.md': new TextEncoder().encode(content) }) + const aggregate = createHash('sha256') + const fileHash = createHash('sha256').update(content).digest('hex') + aggregate.update(`SKILL.md:${fileHash}\n`, 'utf8') + return { bytes, fingerprint: `sha256:${aggregate.digest('hex')}` } +} + +function clientFor( + plan: SuiteInstallPlan, + downloads: Record, + capabilities: string[] = ['skill-suite-v1'], + available = true +): SkillHubClient { + const fetchImpl = (async (input: URL | RequestInfo) => { + const url = new URL(String(input)) + if (url.pathname === '/.well-known/clawhub.json') { + return Response.json({ apiBase: '/api/v1', capabilities }) + } + if (url.pathname.endsWith('/install-plan')) return Response.json({ code: 0, data: plan }) + if (url.pathname === `/api/v1/suites/${plan.namespace}/${plan.slug}`) { + return Response.json({ code: 0, data: { + id: 1, + versionId: 2, + namespace: plan.namespace, + slug: plan.slug, + displayName: plan.slug, + version: plan.version, + status: 'PUBLISHED', + visibility: 'PUBLIC', + available, + members: plan.members.map(member => ({ + ...member, + blockingReason: available ? null : 'member_unavailable' + })) + } }) + } + const bytes = downloads[url.pathname] + return bytes + ? new Response(bytes.slice().buffer as ArrayBuffer, { status: 200 }) + : Response.json({ code: 404 }, { status: 404 }) + }) as unknown as typeof fetch + return new SkillHubClient(registry, undefined, fetchImpl) +} + +function clientWithPlanFailure(status: number, message: string): SkillHubClient { + const fetchImpl = (async (input: URL | RequestInfo) => { + const url = new URL(String(input)) + if (url.pathname === '/.well-known/clawhub.json') { + return Response.json({ apiBase: '/api/v1', capabilities: ['skill-suite-v1'] }) + } + if (url.pathname.endsWith('/install-plan')) { + return Response.json({ code: status, msg: message }, { status }) + } + return Response.json({ code: 404 }, { status: 404 }) + }) as unknown as typeof fetch + return new SkillHubClient(registry, undefined, fetchImpl) +} + +function makePlan(): { plan: SuiteInstallPlan; downloads: Record } { + const alpha = archive('# Alpha') + const beta = archive('# Beta') + return { + plan: { + operationId: 'operation-1', + namespace: 'global', + slug: 'starter-pack', + version: '1.0.0', + fingerprint: 'sha256:suite', + members: [ + { + skillId: 10, + skillVersionId: 11, + namespace: 'global', + slug: 'alpha', + version: '1.0.0', + fingerprint: alpha.fingerprint, + downloadUrl: '/downloads/alpha', + position: 0, + entry: true + }, + { + skillId: 20, + skillVersionId: 21, + namespace: 'global', + slug: 'beta', + version: '2.0.0', + fingerprint: beta.fingerprint, + downloadUrl: '/downloads/beta', + position: 1, + entry: false + } + ] + }, + downloads: { '/downloads/alpha': alpha.bytes, '/downloads/beta': beta.bytes } + } +} + +async function exists(path: string): Promise { + try { + await access(path) + return true + } catch { + return false + } +} + +describe('Suite local lifecycle', () => { + test('installs every exact member and writes one Suite inventory snapshot', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + + const result = await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + expect(result.installed).toHaveLength(2) + expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') + expect(await readFile(join(rootDir, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta') + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory.suites).toEqual([expect.objectContaining({ slug: 'starter-pack', version: '1.0.0' })]) + expect(inventory.items).toHaveLength(2) + expect(inventory.items[0].installedBy).toEqual(['suite:@global/starter-pack@1.0.0']) + + const checked = await checkSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + home, + client: clientFor(plan, downloads) + }) + expect(checked.current).toBe(true) + expect(checked.members.every(member => member.status === 'ok')).toBe(true) + }) + + test('does not change live directories or inventory when a member fingerprint fails', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + plan.members[1]!.fingerprint = 'sha256:wrong' + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + })).rejects.toThrow('fingerprint does not match') + + expect(await exists(join(rootDir, 'alpha'))).toBe(false) + expect(await exists(join(rootDir, 'beta'))).toBe(false) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('sends one idempotency key with the install-plan request', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + let idempotencyKey: string | null = null + const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => { + const url = new URL(String(input)) + if (url.pathname === '/.well-known/clawhub.json') { + return Response.json({ capabilities: ['skill-suite-v1'] }) + } + if (url.pathname.endsWith('/install-plan')) { + idempotencyKey = new Headers(init?.headers).get('Idempotency-Key') + return Response.json({ code: 0, data: plan }) + } + const bytes = downloads[url.pathname] + return bytes + ? new Response(bytes.slice().buffer as ArrayBuffer) + : Response.json({ code: 404 }, { status: 404 }) + }) as unknown as typeof fetch + + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: new SkillHubClient(registry, undefined, fetchImpl) + }) + + expect(idempotencyKey).toMatch(/^[0-9a-f-]{36}$/) + }) + + test('retries a transient install-plan failure with the same idempotency key', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + const keys: Array = [] + let planAttempts = 0 + const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => { + const url = new URL(String(input)) + if (url.pathname === '/.well-known/clawhub.json') { + return Response.json({ capabilities: ['skill-suite-v1'] }) + } + if (url.pathname.endsWith('/install-plan')) { + keys.push(new Headers(init?.headers).get('Idempotency-Key')) + planAttempts += 1 + if (planAttempts === 1) return Response.json({ msg: 'temporary outage' }, { status: 503 }) + return Response.json({ code: 0, data: plan }) + } + const bytes = downloads[url.pathname] + return bytes + ? new Response(bytes.slice().buffer as ArrayBuffer) + : Response.json({ code: 404 }, { status: 404 }) + }) as unknown as typeof fetch + + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: new SkillHubClient(registry, undefined, fetchImpl) + }) + + expect(keys).toHaveLength(2) + expect(keys[0]).toMatch(/^[0-9a-f-]{36}$/) + expect(keys[1]).toBe(keys[0]) + }) + + test('reports an installed exact version as stale when a member becomes unavailable', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + const checked = await checkSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + home, + client: clientFor(plan, downloads, ['skill-suite-v1'], false) + }) + + expect(checked.current).toBe(false) + expect(checked.installedVersionAvailable).toBe(false) + expect(checked.blockingReasons).toContain('member_unavailable') + }) + + test('checks the installed version when the suite has no latest published version', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + const fetchImpl = (async (input: URL | RequestInfo) => { + const url = new URL(String(input)) + if (url.pathname === '/.well-known/clawhub.json') { + return Response.json({ capabilities: ['skill-suite-v1'] }) + } + if (url.pathname === `/api/v1/suites/${plan.namespace}/${plan.slug}`) { + if (!url.searchParams.has('version')) { + return Response.json({ code: 404, msg: 'no latest version' }, { status: 404 }) + } + return Response.json({ code: 0, data: { + id: 1, + versionId: 2, + namespace: plan.namespace, + slug: plan.slug, + displayName: plan.slug, + version: plan.version, + status: 'YANKED', + visibility: 'PUBLIC', + available: false, + members: plan.members.map(member => ({ ...member, blockingReason: 'VERSION_UNAVAILABLE' })) + } }) + } + return Response.json({ code: 404 }, { status: 404 }) + }) as unknown as typeof fetch + + const checked = await checkSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + home, + client: new SkillHubClient(registry, undefined, fetchImpl) + }) + + expect(checked.remoteVersion).toBeUndefined() + expect(checked.current).toBe(false) + expect(checked.installedVersionAvailable).toBe(false) + expect(checked.blockingReasons).toContain('VERSION_UNAVAILABLE') + }) + + test('rejects an old Server before resolving or writing Suite members', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads, []) + })).rejects.toThrow('registry does not support Skill Suites') + + expect(await exists(join(rootDir, 'alpha'))).toBe(false) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('treats a non-JSON metadata response as an unsupported old Server', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const fetchImpl = (async () => new Response('legacy registry', { + status: 200, + headers: { 'Content-Type': 'text/html' } + })) as unknown as typeof fetch + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: new SkillHubClient(registry, undefined, fetchImpl) + })).rejects.toThrow('registry does not support Skill Suites') + + expect(await exists(join(rootDir, 'starter-pack'))).toBe(false) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('keeps every target untouched when the Server denies a member plan', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + + await expect(installSuite({ + registry, + namespace: 'private-team', + slug: 'restricted-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientWithPlanFailure(403, 'access denied') + })).rejects.toThrow('access denied') + + expect(await readdir(rootDir)).toEqual([]) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('blocks an upgrade when the exact remote snapshot has an unavailable member', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + await expect(planSuiteUpgrade({ + registry, + namespace: 'global', + slug: 'starter-pack', + home, + client: clientFor(plan, downloads, ['skill-suite-v1'], false) + })).rejects.toThrow('is unavailable') + + expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') + }) + + test('rolls back all live members when a filesystem commit fails midway', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + const renameOperation: typeof rename = async (source, target): Promise => { + if (String(source).includes('.skillhub-suite-stage-') && String(target) === join(rootDir, 'beta')) { + throw new Error('injected rename failure') + } + await rename(source, target) + } + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads), + renameOperation + })).rejects.toThrow('injected rename failure') + + expect(await exists(join(rootDir, 'alpha'))).toBe(false) + expect(await exists(join(rootDir, 'beta'))).toBe(false) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('reports retained backup paths when rollback cannot restore a replaced member', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + const old = archive('# Old Alpha') + const alphaDir = join(rootDir, 'alpha') + await mkdir(alphaDir, { recursive: true }) + await writeFile(join(alphaDir, 'SKILL.md'), '# Old Alpha') + await mkdir(join(home, '.skillhub'), { recursive: true }) + await writeFile(join(home, '.skillhub', 'inventory.json'), JSON.stringify({ + items: [{ + registry, + namespace: 'global', + slug: 'alpha', + version: '0.9.0', + fingerprint: old.fingerprint, + installedBy: ['direct'], + targets: [{ agent: 'codex', rootDir, installDir: alphaDir, installedAt: '2026-09-01T00:00:00Z' }] + }], + suites: [] + })) + const renameOperation: typeof rename = async (source, target): Promise => { + const from = String(source) + const to = String(target) + if (from.includes('.skillhub-suite-stage-') && to === join(rootDir, 'beta')) { + throw new Error('injected commit failure') + } + if (from.includes('.skillhub-suite-backup-') && to === alphaDir) { + throw new Error('injected restore failure') + } + await rename(source, target) + } + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true, + home, + client: clientFor(plan, downloads), + renameOperation + })).rejects.toThrow('rollback was incomplete') + + const retainedBackup = (await readdir(rootDir)).find(name => name.startsWith('alpha.skillhub-suite-backup-')) + expect(retainedBackup).toBeDefined() + expect(await readFile(join(rootDir, retainedBackup!, 'SKILL.md'), 'utf8')).toBe('# Old Alpha') + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory.items[0]).toMatchObject({ slug: 'alpha', version: '0.9.0', installedBy: ['direct'] }) + }) + + test('installs one exact snapshot across multiple Agent targets', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const codexRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-codex-')) + const claudeRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-claude-')) + const { plan, downloads } = makePlan() + + const result = await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [ + { agent: 'codex', rootDir: codexRoot, scope: 'project', source: 'explicit' }, + { agent: 'claude', rootDir: claudeRoot, scope: 'project', source: 'explicit' } + ], + force: false, + home, + client: clientFor(plan, downloads) + }) + + expect(result.installed).toHaveLength(4) + expect(await readFile(join(codexRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') + expect(await readFile(join(claudeRoot, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta') + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory.suites[0].members[0].installDirs).toHaveLength(2) + }) + + test('rejects member coordinates that collide in the local directory layout', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + plan.members[1] = { ...plan.members[1]!, namespace: 'team', slug: 'alpha' } + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + })).rejects.toThrow('use the same local directory') + + expect(await exists(join(rootDir, 'alpha'))).toBe(false) + }) + + test('does not create a Suite directory when a Skill and Suite share one coordinate', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + plan.slug = 'marketing' + plan.members = [plan.members[1]!] + const directSkillDir = join(rootDir, 'marketing') + await mkdir(directSkillDir, { recursive: true }) + await writeFile(join(directSkillDir, 'SKILL.md'), '# Direct marketing Skill') + + await installSuite({ + registry, + namespace: 'global', + slug: 'marketing', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + expect(await readFile(join(directSkillDir, 'SKILL.md'), 'utf8')).toBe('# Direct marketing Skill') + expect(await readFile(join(rootDir, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta') + }) + + test('preserves a member shared by two installed Suites', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const first = makePlan() + first.plan.members = [first.plan.members[0]!] + await installSuite({ + registry, + namespace: 'global', + slug: first.plan.slug, + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(first.plan, first.downloads) + }) + const secondPlan = { ...first.plan, slug: 'editor-pack', fingerprint: 'sha256:editor-pack' } + await installSuite({ + registry, + namespace: 'global', + slug: secondPlan.slug, + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(secondPlan, first.downloads) + }) + + const removed = await removeSuite({ + registry, + namespace: 'global', + slug: first.plan.slug, + home + }) + + expect(removed.preserved).toEqual([{ dir: join(rootDir, 'alpha'), reason: 'shared' }]) + expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory.items[0].installedBy).toEqual(['suite:@global/editor-pack@1.0.0']) + }) + + test('reuses a matching legacy direct install and preserves it when Suite is removed', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + plan.members = [plan.members[0]!] + const skillDir = join(rootDir, 'alpha') + await mkdir(skillDir, { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), '# Alpha') + await mkdir(join(home, '.skillhub'), { recursive: true }) + await writeFile(join(home, '.skillhub', 'inventory.json'), JSON.stringify({ + items: [{ + registry, + namespace: 'global', + slug: 'alpha', + version: '1.0.0', + fingerprint: plan.members[0]!.fingerprint, + targets: [{ agent: 'codex', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z' }] + }] + })) + + const installed = await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + expect(installed.reused).toHaveLength(1) + + const removed = await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) + expect(removed.preserved).toEqual([{ dir: skillDir, reason: 'shared' }]) + expect(await exists(skillDir)).toBe(true) + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory.suites).toEqual([]) + expect(inventory.items[0].installedBy).toEqual(['direct']) + }) + + test('tracks direct and Suite ownership independently for each Agent target', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const directRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-direct-root-')) + const suiteOnlyRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-only-root-')) + const { plan, downloads } = makePlan() + const alpha = plan.members[0]! + await mkdir(join(directRoot, alpha.slug), { recursive: true }) + await writeFile(join(directRoot, alpha.slug, 'SKILL.md'), '# Alpha') + const store = new InventoryStore(home) + await store.upsertTarget(registry, alpha.namespace, alpha.slug, alpha.version, { + agent: 'codex', rootDir: directRoot, installDir: join(directRoot, alpha.slug), + installedAt: new Date().toISOString() + }, alpha.fingerprint) + + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [ + { agent: 'codex', rootDir: directRoot, scope: 'project', source: 'explicit' }, + { agent: 'claude', rootDir: suiteOnlyRoot, scope: 'project', source: 'explicit' } + ], + force: false, + home, + client: clientFor(plan, downloads) + }) + await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) + + expect(await access(join(directRoot, 'alpha')).then(() => true)).toBe(true) + await expect(access(join(suiteOnlyRoot, 'alpha'))).rejects.toThrow() + const inventory = await store.read() + const alphaItem = inventory.items.find(item => item.slug === 'alpha')! + expect(alphaItem.targets).toHaveLength(1) + expect(alphaItem.targets[0]!.installedBy).toEqual(['direct']) + }) + + test('removes unmodified members that are owned only by the Suite', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + const removed = await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) + + expect(removed.removed.sort()).toEqual([join(rootDir, 'alpha'), join(rootDir, 'beta')].sort()) + expect(await exists(join(rootDir, 'alpha'))).toBe(false) + expect(await exists(join(rootDir, 'beta'))).toBe(false) + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory).toMatchObject({ items: [], suites: [] }) + }) + + test('upgrades the exact snapshot and retires members removed by the new Suite version', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const first = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(first.plan, first.downloads) + }) + + const alphaV2 = archive('# Alpha v2') + const gamma = archive('# Gamma') + const second: SuiteInstallPlan = { + ...first.plan, + operationId: 'operation-2', + version: '2.0.0', + fingerprint: 'sha256:suite-v2', + members: [ + { ...first.plan.members[0]!, skillVersionId: 12, version: '2.0.0', fingerprint: alphaV2.fingerprint }, + { + skillId: 30, + skillVersionId: 31, + namespace: 'global', + slug: 'gamma', + version: '1.0.0', + fingerprint: gamma.fingerprint, + downloadUrl: '/downloads/gamma', + position: 1, + entry: false + } + ] + } + const client = clientFor(second, { + '/downloads/alpha': alphaV2.bytes, + '/downloads/gamma': gamma.bytes + }) + + const upgraded = await upgradeSuite({ registry, namespace: 'global', slug: 'starter-pack', home, client }) + + expect(upgraded.upgrade.changes.map(change => change.action).sort()).toEqual(['add', 'change', 'remove']) + expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha v2') + expect(await exists(join(rootDir, 'beta'))).toBe(false) + expect(await readFile(join(rootDir, 'gamma', 'SKILL.md'), 'utf8')).toBe('# Gamma') + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory.suites[0]).toMatchObject({ version: '2.0.0', fingerprint: 'sha256:suite-v2' }) + expect(inventory.items.map((item: { slug: string }) => item.slug).sort()).toEqual(['alpha', 'gamma']) + }) +}) diff --git a/cli/test/unit/stores/inventory-store.test.ts b/cli/test/unit/stores/inventory-store.test.ts index 58ccaf21..3b6f9750 100644 --- a/cli/test/unit/stores/inventory-store.test.ts +++ b/cli/test/unit/stores/inventory-store.test.ts @@ -122,6 +122,6 @@ describe('InventoryStore', () => { const inventory = await store.read() expect(inventory.items).toHaveLength(1) expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: 'fp-v1' }) - expect(inventory.items[0]?.targets).toEqual([retained]) + expect(inventory.items[0]?.targets).toEqual([{ ...retained, installedBy: ['direct'] }]) }) }) diff --git a/compose.release.yml b/compose.release.yml index 97381d11..317226b5 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -71,6 +71,8 @@ services: SPRING_DATA_REDIS_TIMEOUT: SPRING_DATA_REDIS_CLIENT_NAME: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST: + # Compose replaces its single Server without an old/new mixed-version window. + SKILLHUB_SUITE_REVIEW_WRITES_ENABLED: ${SKILLHUB_SUITE_REVIEW_WRITES_ENABLED:-true} SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-} DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-} diff --git a/docs/25-skill-suites.md b/docs/25-skill-suites.md new file mode 100644 index 00000000..b91119ca --- /dev/null +++ b/docs/25-skill-suites.md @@ -0,0 +1,142 @@ +# Skill Suite 设计与使用 + +## 定位 + +Skill Suite 是一个有独立身份和版本的 Skill 集合。它只引用当前 SkillHub 中已经发布的精确 +SkillVersion,不复制 Skill 文件,也不替成员重新执行扫描或审核。 + +Skill 与 Suite 的完整身份都包含资源类型,因此下列两个资源可以同时存在: + +```text +SKILL @global/marketing +SUITE @global/marketing +``` + +原有 `skillhub install @global/marketing` 始终安装 Skill;Suite 必须使用 +`skillhub suite install @global/marketing`,不会根据名称猜测类型。 + +## 版本和生命周期 + +SuiteVersion 固定成员的 Skill ID、SkillVersion ID、坐标、版本和 fingerprint。成员发布新版本不会 +改变现有 SuiteVersion;调整成员、顺序、Entry Skill 或可见范围都需要创建新的 SuiteVersion。 + +```text +DRAFT -> PENDING_REVIEW -> PUBLISHED -> YANKED + \-> REJECTED -> DRAFT +``` + +- PRIVATE Suite 可以从 DRAFT 直接发布。 +- PUBLIC 和 NAMESPACE_ONLY Suite 需要一次 Suite 级审核。 +- Suite 没有可执行包,因此没有 SCANNING 或 SCAN_FAILED 状态。 +- 隐藏、归档、下架或删除 Suite 不会改变任何成员 Skill。 +- 成员失效后,已发布 SuiteVersion 保留历史快照并显示为不可安装,不会自动切换到成员最新版本。 + +## 成员与权限 + +一个 SuiteVersion 最多包含 100 个不同 Skill。Entry Skill 是可选成员,必须明确选自成员列表。 +v1 不支持嵌套 Suite、版本范围、外部 Registry 成员或条件成员。 + +Suite 的可见范围不能宽于成员: + +| Suite 可见性 | 允许的成员 | +| --- | --- | +| PUBLIC | 仅 PUBLIC Skill | +| NAMESPACE_ONLY | PUBLIC,或同 Namespace 的 NAMESPACE_ONLY Skill | +| PRIVATE | PUBLIC,或同 Namespace 的 NAMESPACE_ONLY/PRIVATE Skill | + +创建、提交、审核和安装时都会重新检查成员资格。成员被下架、隐藏、归档、收窄权限或硬删除后, +SuiteVersion 仍为 PUBLISHED,但安装计划会整体失败。硬删除只清空成员外键;坐标、版本和 fingerprint +快照继续用于历史展示和审计。 + +## `suite.yaml` 定义 + +`suite.yaml` 是可移植的 Suite 创作格式,不是上传到 Agent 的多 Skill ZIP: + +```yaml +apiVersion: skillhub.iflytek.com/v1alpha1 +kind: SkillSuite +metadata: + namespace: global + slug: superpowers + version: 1.0.0 + displayName: Superpowers +spec: + visibility: PUBLIC + entrySkill: "@global/using-superpowers@1.0.0" + members: + - skill: "@global/using-superpowers" + version: 1.0.0 + - skill: "@global/brainstorming" + version: 2.1.0 +``` + +当前版本通过 Web 编辑器或 Suite API 创建同一份定义;CLI v1 负责安装生命周期,尚不读取或发布 +`suite.yaml`。保留该格式是为了后续增加 CLI 导入时不改变服务端领域模型。 + +## CLI 安装生命周期 + +```bash +skillhub suite install @global/superpowers --version 1.0.0 +skillhub suite check @global/superpowers +skillhub suite upgrade @global/superpowers --check +skillhub suite upgrade @global/superpowers +skillhub suite remove @global/superpowers +``` + +安装先解析精确计划并下载、校验全部成员,再按稳定顺序锁定目标目录并整体提交。提交中途失败时, +CLI 恢复本次替换的目录并保持安装前 inventory。卸载只移除当前 Suite 的来源;直接安装、被其他 +Suite 共享或已被本地修改的成员目录会保留。 + +CLI inventory 向后兼容旧记录。旧记录没有 `installedBy` 时按直接安装处理,不会在移除 Suite 时被 +误删。新 CLI 在 Server 未声明 `skill-suite-v1` 能力时会明确停止 Suite 命令,普通 Skill 命令不受影响。 + +CLI 获取安装计划时会发送独立的 `Idempotency-Key`,遇到网络错误或 502/503/504 时使用同一个 key +重试一次。Server 按调用者隔离该 key,并为计划生成 `operationId`,在 24 小时窗口内避免重复记录 +Suite 安装请求和审计。 +安装计划本身不预增成员下载数;每个成员仍由原有 Skill 下载接口按实际请求计数。 + +本地 `local` profile 可直接运行 `make suite-smoke`。验证 release Compose 时必须使用真实管理员会话: + +```bash +SMOKE_ADMIN_USERNAME=admin \ +SMOKE_ADMIN_PASSWORD='' \ +./scripts/suite-smoke-test.sh http://localhost:8080 +``` + +脚本不会输出密码,并在结束时删除其创建的临时 Suite 和 Skill。 + +## API 与发现 + +- Suite 管理与详情:`/api/v1/suites/**`、`/api/web/suites/**` +- 当前用户可管理的 Suite:`/api/v1/me/suites`、`/api/web/me/suites` +- 类型化资源发现:`/api/v1/resources`、`/api/web/resources` +- 原有 Skill 搜索接口继续只返回 Skill。 + +类型化发现结果通过 `resourceType=SKILL|SUITE` 区分同名资源。Suite 详情返回固定版本、按顺序排列 +的成员快照、Entry Skill、实时可安装状态和阻塞原因。 + +## 部署顺序 + +数据库迁移会先把既有审核任务回填为 `SKILL_VERSION`,并保留旧 Skill 专用列。官方单实例 +`compose.release.yml` 和本地开发 profile 已默认开启 Suite 审核写入,因为它们不会同时运行新旧 Server。 + +其他部署方式默认保持关闭。全新安装、单实例升级或停机升级可直接设置: + +```bash +SKILLHUB_SUITE_REVIEW_WRITES_ENABLED=true +``` + +旧版与新版 Server 会同时运行的滚动升级,应在发布新版前保持: + +```bash +SKILLHUB_SUITE_REVIEW_WRITES_ENABLED=false +``` + +用该配置完成所有 Server 实例升级;确认不再有旧版实例后,将其改为 `true` 并再次滚动重启。开关关闭 +期间,现有 Skill 审核保持可用,Suite 草稿和 PRIVATE 直发不受影响,PUBLIC 与 NAMESPACE_ONLY +Suite 的新审核提交会被拒绝。Server 启动时会记录明确告警,避免门禁被长期遗忘。 + +## 日志与审计 + +Suite 创建、编辑、提交、审核、发布、下架、隐藏、恢复、归档和删除都会写审计记录。业务日志仅记录 +Suite ID、SuiteVersion ID、actor ID 和 request ID 等定位字段,不记录成员内容、Token 或下载地址。 diff --git a/openspec/changes/add-skill-suites/design.md b/openspec/changes/add-skill-suites/design.md index e454324a..8ed7a8fc 100644 --- a/openspec/changes/add-skill-suites/design.md +++ b/openspec/changes/add-skill-suites/design.md @@ -95,7 +95,7 @@ skillhub suite install @global/marketing ### 3. SuiteVersion 引用精确的已发布 SkillVersion -作者可以在 `suite.yaml` 中选择成员坐标和版本: +Suite 定义可稳定映射为以下 `suite.yaml` 交换格式: ```yaml apiVersion: skillhub.iflytek.com/v1alpha1 @@ -107,6 +107,9 @@ metadata: displayName: Superpowers spec: visibility: PUBLIC + overview: | + ## How to use this suite + Run the entry skill first, then use the remaining skills as needed. entrySkill: "@global/using-superpowers@1.0.0" members: - skill: "@global/using-superpowers" @@ -115,11 +118,13 @@ spec: version: 2.1.0 ``` -该文件是 API/CLI 的创作输入,不是下载到 Agent 的包。服务端在创建 SuiteVersion 时解析并保存精确 `skillVersionId` 和 fingerprint。一个 SuiteVersion 最多包含 100 个不同 Skill;同一 Skill 不允许重复出现。 +该文件不是下载到 Agent 的包。v1 通过 Web/API 提交等价字段;CLI 导入 `suite.yaml` 属于后续增量能力。服务端在创建 SuiteVersion 时解析并保存精确 `skillVersionId` 和 fingerprint。一个 SuiteVersion 最多包含 100 个不同 Skill;同一 Skill 不允许重复出现。 + +SuiteVersion 同时保存短 `summary` 和可选的 Markdown `overview`。`summary` 用于搜索卡片和详情页首屏摘要;`overview` 用于说明成员组合方式、推荐顺序、输入输出和使用边界,并作为审核快照的一部分随 SuiteVersion 冻结。详情页将概述与成员列表分开呈现,成员列表批量解析实时展示名称和摘要,但仅向有权读取该 Skill 的当前查看者返回;安装与审计仍以快照坐标、精确版本和 fingerprint 为准,受限或硬删除成员只保留不可点击且不含实时元数据的历史快照。 成员发布新版本不会改变已有 SuiteVersion。采用新版本、添加、删除、重排成员或修改 Entry Skill 都必须创建新的 SuiteVersion。 -为了降低创作成本,Web/CLI 在添加 Member 时默认推荐该 Skill 当前可安装的最新版本,但保存时立即解析为精确 `skillVersionId`、version 和 fingerprint,并向作者展示实际固定的版本。作者可以显式选择其他仍处于 PUBLISHED 的历史版本。 +为了降低创作成本,Web 在添加 Member 时默认推荐该 Skill 当前可安装的最新版本,但保存时立即解析为精确 `skillVersionId`、version 和 fingerprint,并向作者展示实际固定的版本。作者可以显式选择其他仍处于 PUBLISHED 的历史版本。 不得在已发布 SuiteVersion 中保存 `latest` 或在安装时重新解析最新版本。可以提供“更新成员版本”辅助操作,但该操作必须先展示版本差异,并创建或修改 DRAFT SuiteVersion;它不是后台自动升级。 @@ -174,6 +179,8 @@ Suite 容器复用 `ACTIVE/ARCHIVED` 和独立的 hidden 治理覆盖。Suite 这意味着 Suite 是成员 Skill 的“版本化清单”,不是它们的父生命周期。删除 Suite 不删除 Skill;更新 Skill 不更新 Suite;更新 Suite 也不重新发布 Skill。 +Suite 删除是容器级硬删除:与 Skill 硬删除保持一致,删除其版本、成员关系和 Suite 审核任务,避免审核中心保留无法解析的多态目标;`DELETE_SKILL_SUITE` 审计记录独立保留。该规则不影响 REJECTED Suite 在删除前保留并查询全部审核轮次。 + ### 5. 审核任务支持类型化目标 现有 `review_task` 只指向 SkillVersion。为了保留一个审核中心和一致权限规则,将其扩展为类型化审核目标: @@ -287,6 +294,8 @@ inventory schema 增加 `suites`,并让 Skill 安装目标记录来源集合 新增的类型化资源发现入口返回 `resourceType`,Web 使用类型徽标及独立 `/skills/...`、`/suites/...` 页面。现有 Skill 搜索接口继续只返回 Skill,避免旧 CLI 或第三方客户端把 Suite 响应按 Skill 反序列化。Suite 详情显示版本、精确成员、Entry Skill、可用状态和阻塞原因。 +Suite 详情响应同时返回服务端计算的管理能力,Web 不自行推断 Namespace 角色。作者可编辑草稿、显式重开被拒版本并基于已发布快照创建新版本;Namespace 管理员还可下架版本、隐藏、归档和删除 Suite。后端继续对每个命令独立鉴权,响应能力只用于正确展示入口,不作为安全边界。 + `suite check` 比较 inventory 快照、磁盘 fingerprint 和远端 SuiteVersion;`suite upgrade` 先显示成员增删改计划,再使用与安装相同的原子流程应用新的精确 SuiteVersion。升级不会单独追随 Member 的最新版本。 ### 11. 生命周期兼容通过隔离状态与能力协商保证 @@ -311,7 +320,7 @@ Server 和 CLI 按以下组合兼容: SuiteVersion 被拒绝后允许由管理者退回 DRAFT,保留原审核记录并修改未发布版本后再次提交。PUBLISHED/YANKED SuiteVersion 永远不可编辑;这些版本的任何变化都创建新 SuiteVersion。每次重新提交创建新的审核轮次,不覆盖旧决定。 -一次 Suite 安装计划使用服务端生成的唯一 `operationId` 串联一条 Suite 安装计划审计和多条 Member 下载审计。服务端成功签发完整安装计划后,Suite 安装请求数增加一次;计划内每个 Member SkillVersion 按现有下载统计口径增加一次,并标记 `source=SUITE`。同一 `operationId` 的安全重试不得重复计数。 +CLI 为一次安装计划生成独立的 `Idempotency-Key`,网络重试复用该 key;服务端按调用者隔离 retry key,并生成唯一 `operationId` 串联 Suite 安装计划及 Member 下载意图审计。成功签发完整计划后仅增加一次 Suite 安装请求数,不预增 Member 下载数。Member 继续由现有 Skill 下载接口按实际请求计数,避免计划签发和文件下载对同一 Member 重复计数。服务端保留 retry-key 映射 24 小时,并复用现有幂等清理任务删除过期映射,避免无界增长。 服务端无法可靠知道 CLI 最终是否完成本地文件提交,因此该指标表示“安装计划/下载已签发”,不宣称是本地安装成功数。CLI 后续校验或提交失败不反向扣减服务端计数;v1 不增加客户端完成回调或遥测上报。 @@ -329,7 +338,8 @@ SuiteVersion 被拒绝后允许由管理者退回 DRAFT,保留原审核记录 ## Migration Plan 1. 新增 Suite 三张表、索引和类型化审核字段;回填现有审核任务为 `SKILL_VERSION`。 -2. 先发布兼容旧 API 的 Server;新表为空时现有行为不变。 +2. 先发布兼容旧 API 的 Server;新表为空时现有行为不变。单实例 Compose 和本地 profile 默认开放 + Suite 审核;未知或多实例部署保持 fail-closed,并在确认所有实例升级后显式开放。 3. 发布 Web 的 Suite 管理和类型化审核展示,重新生成 OpenAPI 类型。 4. 发布支持 Suite 和新版 inventory 的 CLI;读取旧 inventory 时将缺少的 `suites`、`installedBy` 视为空。 5. 用本地 exact-SHA 镜像验证 Skill 正常流、Suite 生命周期、成员失效和整组回滚。 diff --git a/openspec/changes/add-skill-suites/proposal.md b/openspec/changes/add-skill-suites/proposal.md index da2a30e7..567a520c 100644 --- a/openspec/changes/add-skill-suites/proposal.md +++ b/openspec/changes/add-skill-suites/proposal.md @@ -11,7 +11,7 @@ SkillHub 当前只能逐个发布和安装 Skill,无法把一组已经发布 - 新增 `skillhub suite install/check/upgrade/remove`,成员继续安装为标准 Agent Skill;Suite 本身不生成同名 `SKILL.md`。 - Suite 安装采用完整预检、全部暂存、fingerprint 校验和整体提交/回滚,避免部分安装。 - CLI inventory 记录 Suite 快照以及每个成员的直接安装和 Suite 来源,安全处理共享成员的卸载。 -- 新增人类可编辑的 `suite.yaml`,仅作为 Suite 定义和发布输入,不改变现有单 Skill ZIP 协议。 +- 定义人类可编辑的 `suite.yaml` 交换格式,为后续 CLI 导入预留稳定边界;v1 仍通过 Web/API 创作,不改变现有单 Skill ZIP 协议。 ## Decision Relative to Issue #715 diff --git a/openspec/changes/add-skill-suites/specs/skill-suites/spec.md b/openspec/changes/add-skill-suites/specs/skill-suites/spec.md index 1ba0fce9..c0b5c2d2 100644 --- a/openspec/changes/add-skill-suites/specs/skill-suites/spec.md +++ b/openspec/changes/add-skill-suites/specs/skill-suites/spec.md @@ -360,6 +360,37 @@ Suite SHALL 有独立 API 和 Web URL。新的类型化资源发现结果中, - **WHEN** 授权用户通过 Suite 专用接口解析某个版本 - **THEN** 响应包含 SuiteVersion 身份以及有序的精确 Member 版本、fingerprint 和可下载状态 +### Requirement: Suite authors SHALL have a complete Web management flow + +Web SHALL expose only the Suite actions authorized by the Server. An authorized author SHALL be +able to create and inspect a Suite, edit a DRAFT, reopen a REJECTED version before editing, and +create a new immutable version from a published snapshot. Namespace administrators SHALL additionally +be able to yank a published version, hide or restore discovery, archive or restore the Suite container, +and delete a Suite when no review is pending. These actions SHALL NOT modify Member Skills. + +#### Scenario: Author manages editable and immutable versions +- **WHEN** an authorized author opens a DRAFT, REJECTED, PUBLISHED, or YANKED SuiteVersion +- **THEN** Web shows only actions permitted for that actor and state +- **AND** REJECTED is explicitly reopened before editing +- **AND** PUBLISHED and YANKED versions remain immutable and changes create a new version + +#### Scenario: Administrator governs or deletes a Suite +- **WHEN** a Namespace administrator yanks, hides, restores, archives, unarchives, or deletes a Suite +- **THEN** Web requires confirmation for destructive container or publication actions +- **AND** a yank requires an audit reason +- **AND** deletion is unavailable while a review is pending +- **AND** hard deletion removes Suite-owned review tasks while retaining the deletion audit record +- **AND** no Member Skill lifecycle or content changes + +#### Scenario: Public reader views a manageable Suite +- **WHEN** an unauthenticated or unauthorized reader opens a public Suite detail page +- **THEN** Web shows the Suite summary and its author-provided Markdown overview as separate information levels +- **AND** Web shows every ordered Member as a Skill card with its exact pinned version, Entry Skill marker, and current availability +- **AND** an available Member exposes live display metadata and links to Skill detail only when the current viewer can read that Skill +- **AND** a viewer-restricted or deleted Member remains a non-navigable snapshot without exposing live display metadata +- **AND** Web does not display edit, version creation, governance, or deletion controls +- **AND** Server authorization remains the enforcement boundary + ### Requirement: Suite operations SHALL be authorized and audited Suite 创建、编辑、提交、审核、发布、下架、隐藏、恢复、归档和删除 SHALL 使用现有 Namespace 与平台角色原则,并 SHALL 产生包含 Suite 类型、Suite ID、SuiteVersion ID、操作者和变更摘要的审计记录。 @@ -413,16 +444,21 @@ REJECTED SuiteVersion MAY 由有权限的管理者退回 DRAFT、修改并重新 - **THEN** 系统拒绝修改 - **AND** 提示创建新的 SuiteVersion -### Requirement: Suite download metrics SHALL remain attributable and idempotent +### Requirement: Suite plan and Member download metrics SHALL remain attributable and idempotent -一次 Suite 安装计划 SHALL 使用服务端生成的唯一 operation ID 关联 Suite 请求与 Member 下载。服务端成功签发完整安装计划后,SHALL 记录一次 Suite 安装请求,并 SHALL 按现有下载口径为计划内每个 Member SkillVersion 记录一次来源为 SUITE 的下载。同一 operation ID 的重试 SHALL NOT 重复计数。该指标 SHALL 表示服务端计划/下载签发,不得标记为 CLI 本地安装成功。 +客户端 SHALL 为一次安装计划生成独立的 idempotency key,并在安全重试时复用;服务端 SHALL 按调用者隔离该 key,并生成 operation ID 关联该计划的审计记录。服务端成功签发完整计划后 SHALL 记录一次 Suite 安装请求,但 SHALL NOT 在此时预增 Member 下载数。每个 Member 继续通过现有 Skill 下载接口按实际下载请求计数,避免计划签发与文件下载对同一 Member 重复计数。这些指标表示服务端计划签发和实际下载请求,不表示 CLI 本地安装成功。 #### Scenario: Issue a complete Suite install plan - **WHEN** 服务端完成 Suite 和全部 Member 的权限、状态及可下载性预检并签发完整安装计划 - **THEN** Suite 安装请求数增加一次 -- **AND** 每个计划内 Member SkillVersion 下载数按现有口径增加一次并记录 Suite 来源 +- **AND** 此时不增加 Member SkillVersion 下载数 - **AND** 相关审计记录共享同一个 operation ID +#### Scenario: Download an exact Member from the issued plan +- **WHEN** CLI 使用安装计划中的下载地址请求某个精确 Member SkillVersion +- **THEN** 现有 Skill 下载接口按原有口径记录一次该 Member 的下载 +- **AND** 同一 Member 不因此前签发安装计划而重复计数 + #### Scenario: Suite plan preflight fails - **WHEN** 服务端因权限、状态或成员不可用而无法签发完整安装计划 - **THEN** 不增加 Suite 安装请求数 @@ -430,13 +466,15 @@ REJECTED SuiteVersion MAY 由有权限的管理者退回 DRAFT、修改并重新 #### Scenario: Local installation fails after plan issuance - **WHEN** CLI 在服务端签发计划后因下载、校验或文件提交失败并回滚 -- **THEN** 服务端已记录的计划和下载计数保持不变 +- **THEN** 服务端已记录的计划计数保持不变 +- **AND** 仅实际发出的 Member 下载请求按现有口径保留计数 - **AND** 系统不将这些计数描述为本地安装成功数 -#### Scenario: Retry an already recorded operation -- **WHEN** 客户端使用相同 operation ID 安全重试已经记录成功的安装 -- **THEN** 系统返回已有计划或幂等成功 -- **AND** Suite 和 Member 统计不重复增加 +#### Scenario: Retry an already issued plan +- **WHEN** 客户端因网络或响应读取失败,使用相同 idempotency key 重试安装计划请求 +- **THEN** 服务端返回同一 operation ID 对应的计划或幂等成功 +- **AND** Suite 安装请求数和审计记录不重复增加 +- **AND** 该重试保证至少覆盖服务端约定的 24 小时幂等窗口 ### Requirement: Existing Skill workflows SHALL remain compatible @@ -490,6 +528,11 @@ Suite 能力 SHALL 以增量方式提供。旧 CLI 使用新 Server 时 SHALL - **THEN** 新版本应用仍按原 SkillVersion 读取和处理该任务 - **AND** 其审核决定、权限和审计语义保持不变 +#### Scenario: Enable Suite review in a non-overlapping deployment +- **WHEN** 用户通过官方单实例 Compose 或本地 profile 运行 Server +- **THEN** Suite 审核写入默认可用 +- **AND** 用户无需修改环境变量或数据库才能提交 Suite 审核 + #### Scenario: Mixed application versions during rollout - **WHEN** 部署期间同时存在支持和不支持 Suite subject 的应用实例 - **THEN** 现有 Skill 审核流程保持可用 diff --git a/openspec/changes/add-skill-suites/tasks.md b/openspec/changes/add-skill-suites/tasks.md index 80f16154..aaac48c4 100644 --- a/openspec/changes/add-skill-suites/tasks.md +++ b/openspec/changes/add-skill-suites/tasks.md @@ -1,49 +1,52 @@ ## 1. Persistence and domain model -- [ ] 1.1 Add Flyway migrations for `skill_suite`, `skill_suite_version`, and `skill_suite_version_member`, including per-type slug uniqueness, version uniqueness, version-level visibility, ordering, snapshot fields, indexes, and `ON DELETE SET NULL` member references. -- [ ] 1.2 Implement Suite aggregate entities, statuses, repositories, package boundaries, and domain invariants for 100-member limits, exact versions, duplicate detection, and Entry Skill membership. -- [ ] 1.3 Add focused repository and domain tests for same-slug Skill/Suite coexistence, immutable published versions, hard-deleted member snapshots, and latest-version recalculation. -- [ ] 1.4 Implement computed Suite availability and blocking reasons without adding degraded to the persisted SuiteVersion lifecycle enum. +- [x] 1.1 Add Flyway migrations for `skill_suite`, `skill_suite_version`, and `skill_suite_version_member`, including per-type slug uniqueness, version uniqueness, version-level visibility, ordering, snapshot fields, indexes, and `ON DELETE SET NULL` member references. +- [x] 1.2 Implement Suite aggregate entities, statuses, repositories, package boundaries, and domain invariants for 100-member limits, exact versions, duplicate detection, and Entry Skill membership. +- [x] 1.3 Add focused repository and domain tests for same-slug Skill/Suite coexistence, immutable published versions, hard-deleted member snapshots, and latest-version recalculation. +- [x] 1.4 Implement computed Suite availability and blocking reasons without adding degraded to the persisted SuiteVersion lifecycle enum. ## 2. Lifecycle, authorization, and review -- [ ] 2.1 Implement Suite draft, submit, approve, reject, direct-private-publish, yank, hide, restore, archive, and delete workflows without Member lifecycle side effects. -- [ ] 2.2 Generalize review tasks to typed subjects, backfill existing rows as `SKILL_VERSION`, and preserve all existing Skill review behavior and queries. -- [ ] 2.3 Reuse Namespace/platform authorization rules and add Suite-specific audit events for every material lifecycle action. -- [ ] 2.4 Add tests covering roles, self-review rules, member eligibility revalidation at submit/approve/publish, visibility compatibility, namespace freeze/archive, and non-cascading governance. -- [ ] 2.5 Add a rollout compatibility gate so Suite review writes are enabled only after all active application versions support typed review subjects. -- [ ] 2.6 Implement rejected-to-draft resubmission with immutable review rounds, and prevent published/yanked version edits. +- [x] 2.1 Implement Suite draft, submit, approve, reject, direct-private-publish, yank, hide, restore, archive, and delete workflows without Member lifecycle side effects. +- [x] 2.2 Generalize review tasks to typed subjects, backfill existing rows as `SKILL_VERSION`, and preserve all existing Skill review behavior and queries. +- [x] 2.3 Reuse Namespace/platform authorization rules and add Suite-specific audit events for every material lifecycle action. +- [x] 2.4 Add tests covering roles, self-review rules, member eligibility revalidation at submit/approve/publish, visibility compatibility, namespace freeze/archive, and non-cascading governance. +- [x] 2.5 Add a rollout compatibility gate so Suite review writes are enabled only after all active application versions support typed review subjects. +- [x] 2.6 Implement rejected-to-draft resubmission with immutable review rounds, and prevent published/yanked version edits. ## 3. Server API and search -- [ ] 3.1 Add transport-only Suite controllers and application services for management, version history, review actions, detail, and typed resolution of install plans. -- [ ] 3.2 Add a typed resource discovery projection with `resourceType` and Suite metadata while keeping the existing Skill search endpoint Skill-only. -- [ ] 3.3 Return ordered Member snapshots, Entry Skill, availability, and degraded reasons without N+1 member resolution. -- [ ] 3.4 Regenerate `web/src/api/generated/schema.d.ts` with `make generate-api` and run the OpenAPI drift check. -- [ ] 3.5 Record idempotent Suite-plan and Member-download audit/statistics using a shared operation ID, preserving the existing server-side download-count semantics. -- [ ] 3.6 Add a server-filtered Member candidate query scoped by caller access, Suite Namespace, target visibility, current installability, and exact versions. +- [x] 3.1 Add transport-only Suite controllers and application services for management, version history, review actions, detail, and typed resolution of install plans. +- [x] 3.2 Add a typed resource discovery projection with `resourceType` and Suite metadata while keeping the existing Skill search endpoint Skill-only. +- [x] 3.3 Return ordered Member snapshots, Entry Skill, availability, and degraded reasons without N+1 member resolution. +- [x] 3.4 Regenerate `web/src/api/generated/schema.d.ts` with `make generate-api` and run the OpenAPI drift check. +- [x] 3.5 Record idempotent Suite-plan statistics with a client retry key and server operation ID; keep Member counters on actual existing download requests. +- [x] 3.6 Add a server-filtered Member candidate query scoped by caller access, Suite Namespace, target visibility, current installability, and exact versions. ## 4. Web experience -- [ ] 4.1 Add typed Skill/Suite search cards and independent Suite list/detail/version routes. -- [ ] 4.2 Add Suite creation and draft editing with the server-filtered Member picker, exact published versions, ordering, visibility, and optional Entry Skill. -- [ ] 4.3 Extend the review center with typed Suite review details and ensure existing Skill review actions remain unchanged. -- [ ] 4.4 Add install instructions using `skillhub suite install`, degraded-member explanations, and responsive/error/loading/empty states. -- [ ] 4.5 Default Member selection to the current installable version, display the pinned exact version, and provide an explicit version-diff update action for drafts. +- [x] 4.1 Add typed Skill/Suite search cards and independent Suite list/detail/version routes. +- [x] 4.2 Add Suite creation and draft editing with the server-filtered Member picker, exact published versions, ordering, visibility, and optional Entry Skill. +- [x] 4.3 Extend the review center with typed Suite review details and ensure existing Skill review actions remain unchanged. +- [x] 4.4 Add install instructions using `skillhub suite install`, degraded-member explanations, and responsive/error/loading/empty states. +- [x] 4.5 Default Member selection to the current installable version, display the pinned exact version, and provide an explicit version-diff update action for drafts. +- [x] 4.6 Complete Web management for new versions, rejected-version reopen, yank, hide/restore, archive/unarchive, and guarded deletion using Server-derived capabilities. +- [x] 4.7 Add a versioned Markdown overview and browsable Member Skill cards with display metadata, pinned versions, Entry markers, and tombstone handling. ## 5. CLI and local lifecycle -- [ ] 5.1 Add `skillhub suite install/check/upgrade/remove` and a typed Suite resolver without changing `skillhub install` resolution. -- [ ] 5.2 Extend the existing staged installer to preflight, download, fingerprint-check, lock, commit, and roll back all Members and selected Agent targets as one operation. -- [ ] 5.3 Extend inventory with backward-compatible Suite snapshots and multi-source `installedBy` provenance. -- [ ] 5.4 Implement safe Suite removal that preserves direct-installed, shared, unknown-source, or locally modified Member directories. -- [ ] 5.5 Add CLI tests for same-slug Skill/Suite, missing permissions, unavailable members, checksum failure, disk/rename failure, incomplete rollback reporting, shared members, legacy inventory, and multi-Agent targets. +- [x] 5.1 Add `skillhub suite install/check/upgrade/remove` and a typed Suite resolver without changing `skillhub install` resolution. +- [x] 5.2 Extend the existing staged installer to preflight, download, fingerprint-check, lock, commit, and roll back all Members and selected Agent targets as one operation. +- [x] 5.3 Extend inventory with backward-compatible Suite snapshots and multi-source `installedBy` provenance. +- [x] 5.4 Implement safe Suite removal that preserves direct-installed, shared, unknown-source, or locally modified Member directories. +- [x] 5.5 Add CLI tests for same-slug Skill/Suite, missing permissions, unavailable members, checksum failure, disk/rename failure, incomplete rollback reporting, shared members, legacy inventory, and multi-Agent targets. - [ ] 5.6 Add Server capability detection and old-Server/new-CLI plus new-Server/old-CLI compatibility tests. ## 6. Documentation and validation -- [ ] 6.1 Document `suite.yaml`, Suite/Skill terminology, typed coordinates, lifecycle boundaries, CLI commands, compatibility, and operator limits. -- [ ] 6.2 Run targeted backend tests, `make test-backend-app`, frontend unit/type/lint checks, CLI tests/build, and OpenAPI drift validation. -- [ ] 6.3 Build exact-SHA local Server/Web images and run authenticated Compose smoke tests for ordinary Skill and Suite flows. -- [ ] 6.4 Execute the OpenSpec scenario matrix, including normal flow, same-slug compatibility, lifecycle independence, degraded members, authorization, atomic failure/recovery, upgrade, removal, rolling review migration, legacy inventory, and old/new Server/CLI combinations. -- [ ] 6.5 Complete independent implementation review, manual Web/CLI retest instructions, privacy/readiness checks, and the Chinese merge-readiness report before requesting merge authorization. +- [x] 6.1 Document the reserved `suite.yaml` interchange format, Suite/Skill terminology, typed coordinates, lifecycle boundaries, CLI commands, compatibility, and operator limits. +- [x] 6.2 Run targeted backend tests, `make test-backend-app`, frontend unit/type/lint checks, CLI tests/build, and OpenAPI drift validation. +- [x] 6.3 Build exact-SHA local Server/Web images and run authenticated Compose smoke tests for ordinary Skill and Suite flows. +- [x] 6.4 Execute the Server/Web OpenSpec scenario matrix, including normal flow, same-slug compatibility, lifecycle independence, degraded members, authorization, review-history retention, transactional rollback, rolling review migration, and non-cascading deletion. +- [ ] 6.5 Execute the deferred CLI scenario matrix, including atomic failure/recovery, upgrade, removal, legacy inventory, and old/new Server/CLI combinations. +- [ ] 6.6 Complete independent implementation review, manual Web retest instructions, privacy/readiness checks, and the Chinese merge-readiness report before requesting merge authorization. Additional CLI compatibility work remains deferred under 5.6 and 6.5. diff --git a/scripts/suite-smoke-test.sh b/scripts/suite-smoke-test.sh new file mode 100755 index 00000000..0fdb9a68 --- /dev/null +++ b/scripts/suite-smoke-test.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +COOKIE_FILE="$(mktemp)" +WORK_DIR="$(mktemp -d)" +TOKEN="$(date +%s)${RANDOM}" +SKILL_NAME="suite-smoke-member-${TOKEN}" +SUITE_SLUG="suite-smoke-${TOKEN}" +SKILL_ID="" +SKILL_VERSION_ID="" +SUITE_ID="" +SMOKE_ADMIN_USERNAME="${SMOKE_ADMIN_USERNAME:-}" +SMOKE_ADMIN_PASSWORD="${SMOKE_ADMIN_PASSWORD:-}" +AUTH_HEADERS=() + +json_field() { + JSON_INPUT="$1" python3 - "$2" <<'PY' +import json +import os +import sys + +value = json.loads(os.environ["JSON_INPUT"]) +for part in sys.argv[1].split("."): + value = value[int(part)] if part.isdigit() else value[part] +print(json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else value) +PY +} + +assert_code() { + local description="$1" + local body="$2" + local expected="$3" + local actual + actual="$(json_field "$body" code)" + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: $description (expected code $expected, got $actual)" + exit 1 + fi + echo "PASS: $description" +} + +cleanup() { + if [[ -n "$SUITE_ID" && -n "${CSRF_TOKEN:-}" ]]; then + curl -sS -o /dev/null -b "$COOKIE_FILE" -c "$COOKIE_FILE" \ + "${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -X DELETE "$BASE_URL/api/web/suites/$SUITE_ID" || true + fi + if [[ -n "$SKILL_ID" && -n "${CSRF_TOKEN:-}" ]]; then + curl -sS -o /dev/null -b "$COOKIE_FILE" -c "$COOKIE_FILE" \ + "${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -X DELETE "$BASE_URL/api/v1/skills/id/$SKILL_ID" || true + fi + rm -f "$COOKIE_FILE" + rm -rf "$WORK_DIR" +} + +trap cleanup EXIT + +echo "=== Skill Suite Smoke Test ===" +echo "Target: $BASE_URL" +echo "Suite: @global/$SUITE_SLUG" + +if [[ -n "$SMOKE_ADMIN_USERNAME" || -n "$SMOKE_ADMIN_PASSWORD" ]]; then + if [[ -z "$SMOKE_ADMIN_USERNAME" || -z "$SMOKE_ADMIN_PASSWORD" ]]; then + echo "FAIL: SMOKE_ADMIN_USERNAME and SMOKE_ADMIN_PASSWORD must be set together" + exit 1 + fi + curl -sS -c "$COOKIE_FILE" "$BASE_URL/api/v1/auth/me" >/dev/null +else + AUTH_HEADERS=(-H "X-Mock-User-Id: local-admin") + curl -sS -c "$COOKIE_FILE" "${AUTH_HEADERS[@]}" \ + "$BASE_URL/api/v1/auth/providers" >/dev/null +fi +CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_FILE" | tail -n 1)" +if [[ -z "$CSRF_TOKEN" ]]; then + echo "FAIL: could not bootstrap CSRF token" + exit 1 +fi + +if [[ -n "$SMOKE_ADMIN_USERNAME" ]]; then + LOGIN_PAYLOAD="$(SMOKE_ADMIN_USERNAME="$SMOKE_ADMIN_USERNAME" SMOKE_ADMIN_PASSWORD="$SMOKE_ADMIN_PASSWORD" \ + python3 - <<'PY' +import json +import os + +print(json.dumps({ + "username": os.environ["SMOKE_ADMIN_USERNAME"], + "password": os.environ["SMOKE_ADMIN_PASSWORD"], +})) +PY +)" + LOGIN_STATUS="$(curl -sS -o "$WORK_DIR/login.json" -w '%{http_code}' \ + -b "$COOKIE_FILE" -c "$COOKIE_FILE" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Content-Type: application/json" -X POST \ + "$BASE_URL/api/v1/auth/local/login" -d "$LOGIN_PAYLOAD")" + if [[ "$LOGIN_STATUS" != "200" ]]; then + echo "FAIL: local administrator login returned HTTP $LOGIN_STATUS" + exit 1 + fi + CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_FILE" | tail -n 1)" + echo "PASS: authenticated with the local administrator account" +else + echo "PASS: authenticated with the local mock administrator" +fi + +cat > "$WORK_DIR/SKILL.md" < clawhubConfig(HttpServletRequest request) { + public Map clawhubConfig(HttpServletRequest request) { // Honor the deployment sub-path so CLI clients discover //api/v1 instead of // the domain-root /api/v1. With forward-headers-strategy=framework, an upstream // X-Forwarded-Prefix is reflected into the request context path. String contextPath = request.getContextPath(); String prefix = (contextPath == null) ? "" : contextPath; - return Map.of("apiBase", prefix + "/api/v1"); + return Map.of( + "apiBase", prefix + "/api/v1", + "capabilities", List.of("skill-suite-v1") + ); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MySkillSuiteController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MySkillSuiteController.java new file mode 100644 index 00000000..5b476f99 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MySkillSuiteController.java @@ -0,0 +1,45 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.MySkillSuiteSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.SkillSuiteAppService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** Current-user dashboard transport for manageable Suite drafts and published versions. */ +@RestController +@Tag(name = "My Skill Suites") +@RequestMapping({"/api/v1/me/suites", "/api/web/me/suites"}) +public class MySkillSuiteController extends BaseApiController { + + private final SkillSuiteAppService appService; + + public MySkillSuiteController(SkillSuiteAppService appService, ApiResponseFactory responseFactory) { + super(responseFactory); + this.appService = appService; + } + + @GetMapping + @Operation(summary = "List Suite versions manageable by the current user") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Manageable Suite page returned") + public ApiResponse> list( + @RequestParam(required = false) String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles + ) { + return ok("response.success.read", appService.listMine( + userId, roles == null ? Map.of() : roles, q, page, size)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ResourceDiscoveryController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ResourceDiscoveryController.java new file mode 100644 index 00000000..e333c7b8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ResourceDiscoveryController.java @@ -0,0 +1,52 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.ResourceSearchResponse; +import com.iflytek.skillhub.ratelimit.RateLimit; +import com.iflytek.skillhub.service.ResourceDiscoveryAppService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** Type-explicit discovery endpoint for clients that understand both Skills and Suites. */ +@RestController +@Tag(name = "Resource discovery") +@RequestMapping({"/api/v1/resources", "/api/web/resources"}) +public class ResourceDiscoveryController extends BaseApiController { + + private final ResourceDiscoveryAppService appService; + + public ResourceDiscoveryController( + ResourceDiscoveryAppService appService, + ApiResponseFactory responseFactory + ) { + super(responseFactory); + this.appService = appService; + } + + @GetMapping + @Operation(summary = "Search Skills and Suites with explicit resource types") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Resource page returned") + @RateLimit(category = "search", authenticated = 60, anonymous = 20) + public ApiResponse search( + @RequestParam(required = false) String q, + @RequestParam(required = false) String namespace, + @RequestParam(required = false) String resourceType, + @RequestParam(defaultValue = "newest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute(value = "userNsRoles", required = false) Map roles + ) { + return ok("response.success.read", appService.search( + q, namespace, resourceType, sort, page, size, + roles == null ? java.util.Set.of() : roles.keySet())); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index 8923664b..bf0bbcb8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -100,8 +100,11 @@ public class ReviewController extends BaseApiController { @PostMapping("/{id}/withdraw") public ApiResponse withdrawReview(@PathVariable Long id, @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) + Map userNsRoles, HttpServletRequest httpRequest) { - governanceWorkflowAppService.withdrawReviewTask(id, userId, AuditRequestContext.from(httpRequest)); + governanceWorkflowAppService.withdrawReviewTask( + id, userId, userNsRoles, AuditRequestContext.from(httpRequest)); return ok("response.success.updated", null); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteController.java new file mode 100644 index 00000000..d7b87256 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteController.java @@ -0,0 +1,341 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.MessageResponse; +import com.iflytek.skillhub.dto.SkillSuiteCreateRequest; +import com.iflytek.skillhub.dto.SkillSuiteResponse; +import com.iflytek.skillhub.dto.SkillSuiteReviewRequest; +import com.iflytek.skillhub.dto.SkillSuiteReasonRequest; +import com.iflytek.skillhub.dto.SkillSuiteInstallPlanResponse; +import com.iflytek.skillhub.dto.SkillSuiteMemberCandidateResponse; +import com.iflytek.skillhub.dto.SkillSuiteVersionSummaryResponse; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.service.SkillSuiteAppService; +import com.iflytek.skillhub.ratelimit.RateLimit; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.Set; +import java.util.List; + +/** Transport-only endpoints for Suite creation, publication, and review decisions. */ +@RestController +@Tag(name = "Skill Suites") +@RequestMapping({"/api/v1/suites", "/api/web/suites"}) +public class SkillSuiteController extends BaseApiController { + + private final SkillSuiteAppService appService; + + public SkillSuiteController(SkillSuiteAppService appService, ApiResponseFactory responseFactory) { + super(responseFactory); + this.appService = appService; + } + + @GetMapping("/{namespace}/{slug}") + @Operation(summary = "Get one visible Suite version") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite version returned") + public ApiResponse getDetail( + @PathVariable String namespace, + @PathVariable String slug, + @RequestParam(required = false) String version, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok("response.success.read", appService.getDetail( + namespace, slug, version, userId, roles(roles), platformRoles(principal))); + } + + @GetMapping("/{namespace}/{slug}/versions") + @Operation(summary = "List visible Suite versions") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite version history returned") + public ApiResponse> listVersions( + @PathVariable String namespace, + @PathVariable String slug, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok("response.success.read", appService.listVersions( + namespace, slug, userId, roles(roles), platformRoles(principal))); + } + + @GetMapping("/member-candidates") + @Operation(summary = "Search exact Skill versions eligible for a Suite draft") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Eligible member candidates returned") + public ApiResponse> searchCandidates( + @RequestParam String suiteNamespace, + @RequestParam SkillVisibility visibility, + @RequestParam(required = false) String q, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok("response.success.read", appService.searchCandidates( + suiteNamespace, visibility, q, size, userId, roles(roles), platformRoles(principal))); + } + + @PostMapping("/{namespace}/{slug}/install-plan") + @Operation(summary = "Issue an idempotent exact-member Suite install plan") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Install plan issued") + @RateLimit(category = "download", authenticated = 120, anonymous = 30) + public ApiResponse createInstallPlan( + @PathVariable String namespace, + @PathVariable String slug, + @RequestParam(required = false) String version, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestHeader(value = "Idempotency-Key", required = false) String clientRequestId, + HttpServletRequest request + ) { + return ok("response.success.read", appService.createInstallPlan( + namespace, slug, version, userId, roles(roles), platformRoles(principal), + clientRequestId, request)); + } + + @PostMapping + @Operation(summary = "Create a Suite and its first draft version") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite draft created") + public ApiResponse create( + @Valid @RequestBody SkillSuiteCreateRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest + ) { + return ok("response.success.created", appService.create( + request, userId, roles(roles), platformRoles(principal), httpRequest)); + } + + @PostMapping("/{suiteId}/versions") + @Operation(summary = "Create a new draft version for a Suite") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite version draft created") + public ApiResponse createVersion( + @PathVariable Long suiteId, + @Valid @RequestBody SkillSuiteCreateRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest + ) { + return ok("response.success.created", appService.createVersion( + suiteId, request, userId, roles(roles), platformRoles(principal), httpRequest)); + } + + @PutMapping("/{suiteId}/versions/{versionId}") + @Operation(summary = "Update an editable Suite draft") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite draft updated") + public ApiResponse updateDraft( + @PathVariable Long suiteId, + @PathVariable Long versionId, + @Valid @RequestBody SkillSuiteCreateRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest + ) { + return ok("response.success.updated", appService.updateDraft( + suiteId, versionId, request, userId, roles(roles), platformRoles(principal), httpRequest)); + } + + @PostMapping("/{suiteId}/versions/{versionId}/submit") + @Operation(summary = "Submit a public or namespace Suite draft for review") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite draft submitted") + public ApiResponse submit( + @PathVariable Long suiteId, + @PathVariable Long versionId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request + ) { + appService.submitForReview( + suiteId, versionId, userId, roles(roles), platformRoles(principal), request); + return ok("response.success.updated", new MessageResponse("Suite submitted for review")); + } + + @PostMapping("/{suiteId}/versions/{versionId}/publish") + @Operation(summary = "Publish a private Suite draft directly") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Private Suite published") + public ApiResponse publishPrivate( + @PathVariable Long suiteId, + @PathVariable Long versionId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request + ) { + appService.publishPrivate( + suiteId, versionId, userId, roles(roles), platformRoles(principal), request); + return ok("response.success.updated", new MessageResponse("Suite published")); + } + + @PostMapping("/reviews/{reviewTaskId}/approve") + @Operation(summary = "Approve a pending Suite review") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite review approved") + public ApiResponse approve( + @PathVariable Long reviewTaskId, + @Valid @RequestBody(required = false) SkillSuiteReviewRequest body, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request + ) { + appService.approve( + reviewTaskId, body == null ? null : body.comment(), userId, + roles(roles), platformRoles(principal), request); + return ok("response.success.updated", new MessageResponse("Suite review approved")); + } + + @PostMapping("/reviews/{reviewTaskId}/reject") + @Operation(summary = "Reject a pending Suite review") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite review rejected") + public ApiResponse reject( + @PathVariable Long reviewTaskId, + @Valid @RequestBody(required = false) SkillSuiteReviewRequest body, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request + ) { + appService.reject( + reviewTaskId, body == null ? null : body.comment(), userId, + roles(roles), platformRoles(principal), request); + return ok("response.success.updated", new MessageResponse("Suite review rejected")); + } + + @PostMapping("/{suiteId}/versions/{versionId}/reopen") + @Operation(summary = "Reopen a rejected Suite version as a draft") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite draft reopened") + public ApiResponse reopen( + @PathVariable Long suiteId, + @PathVariable Long versionId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request + ) { + appService.reopen( + suiteId, versionId, userId, roles(roles), platformRoles(principal), request); + return ok("response.success.updated", new MessageResponse("Suite draft reopened")); + } + + @PostMapping("/{suiteId}/versions/{versionId}/yank") + @Operation(summary = "Yank a published Suite version") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite version yanked") + public ApiResponse yank( + @PathVariable Long suiteId, + @PathVariable Long versionId, + @Valid @RequestBody SkillSuiteReasonRequest body, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request + ) { + appService.yank( + suiteId, versionId, body.reason(), userId, + roles(roles), platformRoles(principal), request); + return ok("response.success.updated", new MessageResponse("Suite version yanked")); + } + + @PostMapping("/{suiteId}/hide") + @Operation(summary = "Hide a Suite from discovery") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite hidden") + public ApiResponse hide( + @PathVariable Long suiteId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request + ) { + appService.setHidden(suiteId, true, userId, roles(roles), platformRoles(principal), request); + return ok("response.success.updated", new MessageResponse("Suite hidden")); + } + + @PostMapping("/{suiteId}/restore") + @Operation(summary = "Restore a hidden Suite") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite restored") + public ApiResponse restore( + @PathVariable Long suiteId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request + ) { + appService.setHidden(suiteId, false, userId, roles(roles), platformRoles(principal), request); + return ok("response.success.updated", new MessageResponse("Suite restored")); + } + + @PostMapping("/{suiteId}/archive") + @Operation(summary = "Archive a Suite container") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite archived") + public ApiResponse archive( + @PathVariable Long suiteId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request + ) { + appService.setArchived(suiteId, true, userId, roles(roles), platformRoles(principal), request); + return ok("response.success.updated", new MessageResponse("Suite archived")); + } + + @PostMapping("/{suiteId}/unarchive") + @Operation(summary = "Restore an archived Suite container") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite unarchived") + public ApiResponse unarchive( + @PathVariable Long suiteId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request + ) { + appService.setArchived(suiteId, false, userId, roles(roles), platformRoles(principal), request); + return ok("response.success.updated", new MessageResponse("Suite unarchived")); + } + + @DeleteMapping("/{suiteId}") + @Operation(summary = "Delete a Suite without changing member Skills") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite deleted") + public ApiResponse delete( + @PathVariable Long suiteId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request + ) { + appService.delete(suiteId, userId, roles(roles), platformRoles(principal), request); + return ok("response.success.updated", new MessageResponse("Suite deleted")); + } + + private Map roles(Map roles) { + return roles == null ? Map.of() : roles; + } + + private Set platformRoles(PlatformPrincipal principal) { + return principal == null || principal.platformRoles() == null + ? Set.of() + : principal.platformRoles(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceInboxItemResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceInboxItemResponse.java index c9298a5e..2291089f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceInboxItemResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceInboxItemResponse.java @@ -7,6 +7,21 @@ public record GovernanceInboxItemResponse( String subtitle, String timestamp, String namespace, - String skillSlug + String skillSlug, + String resourceType, + String resourceSlug ) { + /** Backward-compatible constructor for existing Skill-centric inbox items. */ + public GovernanceInboxItemResponse( + String type, + Long id, + String title, + String subtitle, + String timestamp, + String namespace, + String skillSlug + ) { + this(type, id, title, subtitle, timestamp, namespace, skillSlug, + skillSlug == null ? null : "SKILL", skillSlug); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MySkillSuiteSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MySkillSuiteSummaryResponse.java new file mode 100644 index 00000000..d6366230 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MySkillSuiteSummaryResponse.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +/** Latest manageable version of one Suite shown in the current user's dashboard. */ +public record MySkillSuiteSummaryResponse( + Long id, + Long versionId, + String namespace, + String slug, + String displayName, + String summary, + String version, + String versionStatus, + String suiteStatus, + String visibility, + boolean hidden, + Instant updatedAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResourceSearchResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResourceSearchResponse.java new file mode 100644 index 00000000..fe6d9d49 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResourceSearchResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +public record ResourceSearchResponse( + List items, + long total, + int page, + int size +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResourceSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResourceSummaryResponse.java new file mode 100644 index 00000000..159b64c1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResourceSummaryResponse.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +/** Type-explicit discovery item used by new clients without changing the legacy Skill search API. */ +public record ResourceSummaryResponse( + String resourceType, + String detailUrl, + Long id, + String namespace, + String slug, + String displayName, + String summary, + String version, + String visibility, + long installCount, + boolean available, + Instant updatedAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressResponse.java index f69885b9..6ba4a122 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressResponse.java @@ -2,9 +2,7 @@ package com.iflytek.skillhub.dto; import java.time.Instant; -/** - * Author-facing summary for one skill version's review attempts. - */ +/** Author-facing summary for one typed resource version's review attempts. */ public record ReviewProgressResponse( Long latestReviewTaskId, Long skillId, @@ -15,5 +13,27 @@ public record ReviewProgressResponse( String latestReviewComment, Instant latestSubmittedAt, Instant latestReviewedAt, - long attemptCount -) {} + long attemptCount, + String subjectType, + Long subjectId, + Long subjectVersionId, + String subjectSlug +) { + /** Backward-compatible constructor for existing Skill-only callers and tests. */ + public ReviewProgressResponse( + Long latestReviewTaskId, + Long skillId, + String namespace, + String skillSlug, + String skillVersion, + String latestStatus, + String latestReviewComment, + Instant latestSubmittedAt, + Instant latestReviewedAt, + long attemptCount + ) { + this(latestReviewTaskId, skillId, namespace, skillSlug, skillVersion, + latestStatus, latestReviewComment, latestSubmittedAt, latestReviewedAt, + attemptCount, "SKILL_VERSION", skillId, null, skillSlug); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java index 6ea9c896..b1c7f685 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java @@ -15,5 +15,31 @@ public record ReviewTaskResponse( String reviewedByName, String reviewComment, Instant submittedAt, - Instant reviewedAt -) {} + Instant reviewedAt, + String subjectType, + Long subjectId, + Long subjectVersionId, + String subjectSlug +) { + /** Backward-compatible constructor for Skill-only callers and tests. */ + public ReviewTaskResponse( + Long id, + Long skillVersionId, + String namespace, + String skillSlug, + String version, + String status, + String submittedBy, + String submittedByName, + String reviewedBy, + String reviewedByName, + String reviewComment, + Instant submittedAt, + Instant reviewedAt + ) { + this(id, skillVersionId, namespace, skillSlug, version, status, + submittedBy, submittedByName, reviewedBy, reviewedByName, + reviewComment, submittedAt, reviewedAt, + "SKILL_VERSION", null, skillVersionId, skillSlug); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteCreateRequest.java new file mode 100644 index 00000000..53063f9e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteCreateRequest.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** Request to create a Suite and its first exact-version draft. */ +public record SkillSuiteCreateRequest( + @NotBlank String namespace, + @NotBlank String slug, + @NotBlank @Size(max = 256) String displayName, + @Size(max = 4000) String summary, + @Size(max = 20000) String overview, + @NotBlank String version, + @NotNull SkillVisibility visibility, + @Size(max = 4000) String changelog, + @Valid SkillSuiteMemberRequest entrySkill, + @NotEmpty @Size(max = 100) List<@Valid SkillSuiteMemberRequest> members +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteInstallMemberResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteInstallMemberResponse.java new file mode 100644 index 00000000..ecf48cd4 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteInstallMemberResponse.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.dto; + +/** One exact downloadable Skill in a Suite install plan. */ +public record SkillSuiteInstallMemberResponse( + Long skillId, + Long skillVersionId, + String namespace, + String slug, + String version, + String fingerprint, + String downloadUrl, + int position, + boolean entry +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteInstallPlanResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteInstallPlanResponse.java new file mode 100644 index 00000000..26519238 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteInstallPlanResponse.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +/** Fully preflighted exact-version plan consumed by the CLI staged installer. */ +public record SkillSuiteInstallPlanResponse( + String operationId, + String namespace, + String slug, + String version, + String fingerprint, + List members +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteMemberCandidateResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteMemberCandidateResponse.java new file mode 100644 index 00000000..d7166c71 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteMemberCandidateResponse.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; + +/** One exact published Skill version eligible for the target Suite audience. */ +public record SkillSuiteMemberCandidateResponse( + Long skillId, + Long skillVersionId, + String namespace, + String slug, + String displayName, + String version, + SkillVisibility visibility, + boolean recommended +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteMemberRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteMemberRequest.java new file mode 100644 index 00000000..32ad5acc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteMemberRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +/** Exact Skill coordinate selected for a Suite draft. */ +public record SkillSuiteMemberRequest( + @NotBlank String namespace, + @NotBlank String slug, + @NotBlank String version +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteMemberResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteMemberResponse.java new file mode 100644 index 00000000..3bfd7070 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteMemberResponse.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.dto; + +/** + * Immutable member snapshot returned from a Suite draft or published version. + * Live display metadata and browsing permission are viewer-specific; mutation responses therefore + * keep them empty until the client fetches the detail projection. + */ +public record SkillSuiteMemberResponse( + Long skillId, + Long skillVersionId, + String namespace, + String slug, + String displayName, + String summary, + String version, + String fingerprint, + int position, + boolean entry, + boolean browsable, + String blockingReason +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReasonRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReasonRequest.java new file mode 100644 index 00000000..3164c1c6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReasonRequest.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** Required operator reason for destructive Suite lifecycle actions. */ +public record SkillSuiteReasonRequest( + @NotBlank @Size(max = 2000) String reason +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteResponse.java new file mode 100644 index 00000000..619df458 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteResponse.java @@ -0,0 +1,27 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.SkillSuiteAllowedAction; + +import java.util.List; +import java.util.Set; + +/** Suite container and one concrete version snapshot. */ +public record SkillSuiteResponse( + Long id, + Long versionId, + String namespace, + String slug, + String displayName, + String summary, + String overview, + String version, + String status, + SkillVisibility visibility, + String suiteStatus, + boolean hidden, + Set allowedActions, + boolean available, + List members +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReviewRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReviewRequest.java new file mode 100644 index 00000000..f9deee9f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReviewRequest.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Size; + +/** Optional reviewer explanation for a Suite review decision. */ +public record SkillSuiteReviewRequest(@Size(max = 2000) String comment) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteVersionSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteVersionSummaryResponse.java new file mode 100644 index 00000000..2ba2f75d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteVersionSummaryResponse.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; + +import java.time.Instant; + +/** Viewer-filtered Suite version history item. */ +public record SkillSuiteVersionSummaryResponse( + Long id, + String version, + String status, + SkillVisibility visibility, + Instant publishedAt, + Instant yankedAt, + Instant createdAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java index 8b7edab7..43043c74 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java @@ -5,11 +5,16 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.report.SkillReport; import com.iflytek.skillhub.domain.review.PromotionRequest; import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewSubjectType; 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.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.GovernanceInboxItemResponse; @@ -32,15 +37,21 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { private final SkillVersionRepository skillVersionRepository; private final NamespaceRepository namespaceRepository; private final UserAccountRepository userAccountRepository; + private final SkillSuiteRepository suiteRepository; + private final SkillSuiteVersionRepository suiteVersionRepository; public JpaGovernanceQueryRepository(SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, NamespaceRepository namespaceRepository, - UserAccountRepository userAccountRepository) { + UserAccountRepository userAccountRepository, + SkillSuiteRepository suiteRepository, + SkillSuiteVersionRepository suiteVersionRepository) { this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.namespaceRepository = namespaceRepository; this.userAccountRepository = userAccountRepository; + this.suiteRepository = suiteRepository; + this.suiteVersionRepository = suiteVersionRepository; } @Override @@ -111,8 +122,26 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { ? Map.of() : skillRepository.findByIdIn(List.copyOf(skillIds)).stream() .collect(Collectors.toMap(Skill::getId, Function.identity())); + List suiteVersionIds = distinct(tasks.stream() + .filter(this::isSuiteReview) + .map(ReviewTask::getSubjectVersionId) + .toList()); + Map suiteVersionsById = suiteVersionIds.isEmpty() + ? Map.of() + : suiteVersionRepository.findByIdIn(suiteVersionIds).stream() + .collect(Collectors.toMap(SkillSuiteVersion::getId, Function.identity())); + Set suiteIds = new LinkedHashSet<>(distinct(tasks.stream() + .filter(this::isSuiteReview) + .map(ReviewTask::getSubjectId) + .toList())); + suiteIds.addAll(suiteVersionsById.values().stream().map(SkillSuiteVersion::getSuiteId).toList()); + Map suitesById = suiteIds.isEmpty() + ? Map.of() + : suiteRepository.findByIdIn(List.copyOf(suiteIds)).stream() + .collect(Collectors.toMap(SkillSuite::getId, Function.identity())); Set namespaceIds = new LinkedHashSet<>(distinct( skillsById.values().stream().map(Skill::getNamespaceId).toList())); + namespaceIds.addAll(suitesById.values().stream().map(SkillSuite::getNamespaceId).toList()); namespaceIds.addAll(distinct(tasks.stream().map(ReviewTask::getNamespaceId).toList())); Map namespacesById = namespaceIds.isEmpty() ? Map.of() @@ -126,7 +155,8 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { ? Map.of() : userAccountRepository.findByIdIn(userIds).stream() .collect(Collectors.toMap(UserAccount::getId, Function.identity())); - return new ReviewReadBundle(versionsById, skillsById, namespacesById, usersById); + return new ReviewReadBundle( + versionsById, skillsById, suiteVersionsById, suitesById, namespacesById, usersById); } private PromotionReadBundle loadPromotionBundle(List requests) { @@ -172,6 +202,20 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { } private ReviewTaskResponse toReviewTaskResponse(ReviewTask task, ReviewReadBundle bundle) { + if (isSuiteReview(task)) { + SkillSuite suite = require(bundle.suitesById(), task.getSubjectId(), "error.suite.notFound"); + Namespace namespace = require(bundle.namespacesById(), task.getNamespaceId(), "namespace.not_found"); + UserAccount submittedBy = bundle.usersById().get(task.getSubmittedBy()); + UserAccount reviewedBy = task.getReviewedBy() != null ? bundle.usersById().get(task.getReviewedBy()) : null; + return new ReviewTaskResponse( + task.getId(), null, namespace.getSlug(), null, task.getSubjectVersion(), + task.getStatus().name(), task.getSubmittedBy(), + submittedBy != null ? submittedBy.getDisplayName() : null, + task.getReviewedBy(), reviewedBy != null ? reviewedBy.getDisplayName() : null, + task.getReviewComment(), task.getSubmittedAt(), task.getReviewedAt(), + task.getSubjectType().name(), task.getSubjectId(), + task.getSubjectVersionId(), suite.getSlug()); + } Long skillId = task.getSkillId() != null ? task.getSkillId() : require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getSkillId(); @@ -195,7 +239,10 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { reviewedBy != null ? reviewedBy.getDisplayName() : null, task.getReviewComment(), task.getSubmittedAt(), - task.getReviewedAt() + task.getReviewedAt(), + subjectType(task), task.getSubjectId() != null ? task.getSubjectId() : skillId, + task.getSubjectVersionId() != null ? task.getSubjectVersionId() : task.getSkillVersionId(), + skill.getSlug() ); } @@ -232,6 +279,17 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { } private GovernanceInboxItemResponse toReviewInboxItem(ReviewTask task, ReviewReadBundle bundle) { + if (isSuiteReview(task)) { + SkillSuite suite = bundle.suitesById().get(task.getSubjectId()); + Namespace namespace = bundle.namespacesById().get(task.getNamespaceId()); + String namespaceSlug = namespace != null ? namespace.getSlug() : null; + String suiteSlug = suite != null ? suite.getSlug() : null; + return new GovernanceInboxItemResponse( + "REVIEW", task.getId(), join(namespaceSlug, suiteSlug, task.getSubjectVersion()), + "Pending Suite review", + task.getSubmittedAt() != null ? task.getSubmittedAt().toString() : null, + namespaceSlug, null, "SUITE", suiteSlug); + } SkillVersion version = bundle.versionsById().get(task.getSkillVersionId()); Skill skill = version != null ? bundle.skillsById().get(version.getSkillId()) : null; Namespace namespace = skill != null ? bundle.namespacesById().get(skill.getNamespaceId()) : null; @@ -306,8 +364,20 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { return version != null ? path + "@" + version : path; } + private boolean isSuiteReview(ReviewTask task) { + return task.getSubjectType() == ReviewSubjectType.SUITE_VERSION; + } + + private String subjectType(ReviewTask task) { + return task.getSubjectType() != null + ? task.getSubjectType().name() + : ReviewSubjectType.SKILL_VERSION.name(); + } + private record ReviewReadBundle(Map versionsById, Map skillsById, + Map suiteVersionsById, + Map suitesById, Map namespacesById, Map usersById) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java index 04c09be0..bcd2c423 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java @@ -16,7 +16,7 @@ import org.springframework.transaction.annotation.Transactional; /** * PostgreSQL read-model query for review progress. * - *

Direct SQL is intentional here: the page boundary applies to grouped skill-version attempts, + *

Direct SQL is intentional here: the page boundary applies to grouped resource-version attempts, * not individual review tasks. Window functions keep grouping, latest-attempt selection, counts, * filtering, and pagination in the database instead of loading an author's full history.

*/ @@ -27,18 +27,21 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo WITH ranked AS ( SELECT task.id, task.skill_id, + task.subject_type, + task.subject_id, + task.subject_version_id, + task.subject_version, task.namespace_id, - task.skill_version, task.status, task.review_comment, task.submitted_at, task.reviewed_at, ROW_NUMBER() OVER ( - PARTITION BY task.skill_id, task.skill_version + PARTITION BY task.subject_type, task.subject_id, task.subject_version ORDER BY task.submitted_at DESC, task.id DESC ) AS attempt_rank, COUNT(*) OVER ( - PARTITION BY task.skill_id, task.skill_version + PARTITION BY task.subject_type, task.subject_id, task.subject_version ) AS attempt_count FROM review_task task WHERE task.submitted_by = :userId @@ -54,18 +57,25 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo latest.skill_id, namespace.slug, skill.slug, - latest.skill_version, + latest.subject_version, latest.status, latest.review_comment, latest.submitted_at, latest.reviewed_at, - latest.attempt_count + latest.attempt_count, + latest.subject_type, + latest.subject_id, + latest.subject_version_id, + COALESCE(skill.slug, suite.slug) AS subject_slug FROM latest - JOIN skill ON skill.id = latest.skill_id + LEFT JOIN skill + ON latest.subject_type = 'SKILL_VERSION' AND skill.id = latest.subject_id + LEFT JOIN skill_suite suite + ON latest.subject_type = 'SUITE_VERSION' AND suite.id = latest.subject_id JOIN namespace ON namespace.id = latest.namespace_id WHERE ( :query = '' - OR LOWER(skill.slug) LIKE :queryPattern + OR LOWER(COALESCE(skill.slug, suite.slug)) LIKE :queryPattern OR LOWER(namespace.slug) LIKE :queryPattern ) AND (:status = '' OR latest.status = :status) @@ -79,10 +89,13 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo COUNT(*) FILTER (WHERE latest.status = 'APPROVED') AS approved_count, COUNT(*) FILTER (WHERE latest.status = 'REJECTED') AS rejected_count FROM latest - JOIN skill ON skill.id = latest.skill_id + LEFT JOIN skill + ON latest.subject_type = 'SKILL_VERSION' AND skill.id = latest.subject_id + LEFT JOIN skill_suite suite + ON latest.subject_type = 'SUITE_VERSION' AND suite.id = latest.subject_id JOIN namespace ON namespace.id = latest.namespace_id WHERE :query = '' - OR LOWER(skill.slug) LIKE :queryPattern + OR LOWER(COALESCE(skill.slug, suite.slug)) LIKE :queryPattern OR LOWER(namespace.slug) LIKE :queryPattern """; @@ -147,7 +160,7 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo private ReviewProgressResponse mapRow(Object[] row) { return new ReviewProgressResponse( number(row[0]).longValue(), - number(row[1]).longValue(), + row[1] == null ? null : number(row[1]).longValue(), (String) row[2], (String) row[3], (String) row[4], @@ -155,7 +168,11 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo (String) row[6], instant(row[7]), instant(row[8]), - number(row[9]).longValue() + number(row[9]).longValue(), + String.valueOf(row[10]), + number(row[11]).longValue(), + row[12] == null ? null : number(row[12]).longValue(), + (String) row[13] ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillSuiteQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillSuiteQueryRepository.java new file mode 100644 index 00000000..168ed6a9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillSuiteQueryRepository.java @@ -0,0 +1,113 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.dto.MySkillSuiteSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * Dashboard read model for Suite versions manageable by the current Namespace role. + * + *

The native query selects the newest version each caller may manage in one round trip. This + * avoids loading every Suite and then resolving version ownership and Namespace roles with N+1 + * repository calls.

+ */ +@Repository +public class MySkillSuiteQueryRepository { + + private static final String CTE = """ + WITH manageable AS ( + SELECT DISTINCT ON (suite.id) + suite.id, version.id AS version_id, namespace.slug AS namespace_slug, + suite.slug, version.display_name, version.summary, version.version, + version.status AS version_status, suite.status AS suite_status, + version.visibility, suite.hidden, suite.updated_at + FROM skill_suite suite + JOIN namespace ON namespace.id = suite.namespace_id + JOIN skill_suite_version version ON version.suite_id = suite.id + WHERE suite.namespace_id IN (:memberNamespaceIds) + AND (suite.created_by = :userId OR suite.namespace_id IN (:adminNamespaceIds)) + ORDER BY suite.id, version.created_at DESC, version.id DESC + ) + """; + + private static final String FILTER = """ + WHERE (:query = '' OR LOWER(slug) LIKE :pattern + OR LOWER(display_name) LIKE :pattern + OR LOWER(COALESCE(summary, '')) LIKE :pattern) + """; + + private final EntityManager entityManager; + + public MySkillSuiteQueryRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Transactional(readOnly = true) + public PageResponse findMine( + String userId, + Set memberNamespaceIds, + Set adminNamespaceIds, + String keyword, + int page, + int size + ) { + String queryText = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT); + Query select = bind(entityManager.createNativeQuery(CTE + """ + SELECT id, version_id, namespace_slug, slug, display_name, summary, version, + version_status, suite_status, visibility, hidden, updated_at + FROM manageable + """ + FILTER + " ORDER BY updated_at DESC, id DESC OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY"), + userId, memberNamespaceIds, adminNamespaceIds, queryText); + select.setParameter("offset", (long) page * size).setParameter("size", size); + Query count = bind(entityManager.createNativeQuery( + CTE + "SELECT COUNT(*) FROM manageable " + FILTER), + userId, memberNamespaceIds, adminNamespaceIds, queryText); + + @SuppressWarnings("unchecked") + List rows = select.getResultList(); + List items = rows.stream().map(this::map).toList(); + return new PageResponse<>(items, ((Number) count.getSingleResult()).longValue(), page, size); + } + + private Query bind( + Query query, + String userId, + Set memberNamespaceIds, + Set adminNamespaceIds, + String keyword + ) { + return query.setParameter("userId", userId) + .setParameter("memberNamespaceIds", idsOrSentinel(memberNamespaceIds)) + .setParameter("adminNamespaceIds", idsOrSentinel(adminNamespaceIds)) + .setParameter("query", keyword) + .setParameter("pattern", "%" + keyword + "%"); + } + + private Set idsOrSentinel(Set ids) { + return ids.isEmpty() ? Set.of(-1L) : ids; + } + + private MySkillSuiteSummaryResponse map(Object[] row) { + return new MySkillSuiteSummaryResponse( + ((Number) row[0]).longValue(), ((Number) row[1]).longValue(), + (String) row[2], (String) row[3], (String) row[4], (String) row[5], + (String) row[6], String.valueOf(row[7]), String.valueOf(row[8]), + String.valueOf(row[9]), (Boolean) row[10], instant(row[11])); + } + + private Instant instant(Object value) { + if (value instanceof Instant instant) return instant; + if (value instanceof OffsetDateTime offsetDateTime) return offsetDateTime.toInstant(); + if (value instanceof Timestamp timestamp) return timestamp.toInstant(); + throw new IllegalStateException("Expected Suite update timestamp, got " + value); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteCandidateQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteCandidateQueryRepository.java new file mode 100644 index 00000000..e5568657 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteCandidateQueryRepository.java @@ -0,0 +1,105 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.dto.SkillSuiteMemberCandidateResponse; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Server-side candidate projection for Suite authoring. + * + *

Direct SQL keeps authorization, installability, audience compatibility, and result limiting + * in one database query; assembling this through aggregate repositories would load inaccessible + * Skills before filtering and introduce an N+1 version lookup. + */ +@Repository +public class SkillSuiteCandidateQueryRepository { + + private final NamedParameterJdbcTemplate jdbcTemplate; + + public SkillSuiteCandidateQueryRepository(NamedParameterJdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List search( + Long suiteNamespaceId, + SkillVisibility suiteVisibility, + String query, + String userId, + List memberNamespaceIds, + List adminNamespaceIds, + boolean superAdmin, + int size + ) { + String normalizedQuery = query == null ? "" : query.trim().toLowerCase(); + MapSqlParameterSource parameters = new MapSqlParameterSource() + .addValue("suiteNamespaceId", suiteNamespaceId) + .addValue("suiteVisibility", suiteVisibility.name()) + .addValue("query", "%" + normalizedQuery + "%") + .addValue("userId", userId) + .addValue("memberNamespaceIds", nonEmpty(memberNamespaceIds)) + .addValue("adminNamespaceIds", nonEmpty(adminNamespaceIds)) + .addValue("superAdmin", superAdmin) + .addValue("size", size); + + return jdbcTemplate.query(""" + SELECT s.id AS skill_id, + sv.id AS skill_version_id, + n.slug AS namespace_slug, + s.slug AS skill_slug, + COALESCE(s.display_name, s.slug) AS display_name, + sv.version, + s.visibility, + (s.latest_version_id = sv.id) AS recommended + FROM skill s + JOIN namespace n ON n.id = s.namespace_id + JOIN skill_version sv ON sv.skill_id = s.id + WHERE s.status = 'ACTIVE' + AND s.hidden = FALSE + AND n.status = 'ACTIVE' + AND sv.status = 'PUBLISHED' + AND sv.download_ready = TRUE + AND sv.yanked_at IS NULL + AND ( + :superAdmin = TRUE + OR s.visibility = 'PUBLIC' + OR (s.visibility = 'NAMESPACE_ONLY' AND s.namespace_id IN (:memberNamespaceIds)) + OR (s.visibility = 'PRIVATE' AND ( + s.owner_id = :userId OR s.namespace_id IN (:adminNamespaceIds) + )) + ) + AND ( + s.visibility = 'PUBLIC' + OR (:suiteVisibility = 'NAMESPACE_ONLY' + AND s.namespace_id = :suiteNamespaceId + AND s.visibility = 'NAMESPACE_ONLY') + OR (:suiteVisibility = 'PRIVATE' + AND s.namespace_id = :suiteNamespaceId + AND s.visibility IN ('NAMESPACE_ONLY', 'PRIVATE')) + ) + AND ( + :query = '%%' + OR LOWER(n.slug) LIKE :query + OR LOWER(s.slug) LIKE :query + OR LOWER(COALESCE(s.display_name, '')) LIKE :query + ) + ORDER BY recommended DESC, LOWER(COALESCE(s.display_name, s.slug)), sv.published_at DESC, sv.id DESC + LIMIT :size + """, parameters, (resultSet, rowNumber) -> new SkillSuiteMemberCandidateResponse( + resultSet.getLong("skill_id"), + resultSet.getLong("skill_version_id"), + resultSet.getString("namespace_slug"), + resultSet.getString("skill_slug"), + resultSet.getString("display_name"), + resultSet.getString("version"), + SkillVisibility.valueOf(resultSet.getString("visibility")), + resultSet.getBoolean("recommended"))); + } + + private List nonEmpty(List values) { + return values.isEmpty() ? List.of(-1L) : values; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index ae578ae8..ededf424 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -69,8 +69,11 @@ public class GovernanceWorkflowAppService { return reviewPortalAppService.rejectReview(reviewTaskId, comment, userId, userNsRoles, auditContext); } - public void withdrawReviewTask(Long reviewTaskId, String userId, AuditRequestContext auditContext) { - reviewPortalAppService.withdrawReview(reviewTaskId, userId, auditContext); + public void withdrawReviewTask(Long reviewTaskId, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + reviewPortalAppService.withdrawReview(reviewTaskId, userId, userNsRoles, auditContext); } public PageResponse listReviews(String status, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ResourceDiscoveryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ResourceDiscoveryAppService.java new file mode 100644 index 00000000..facea2d8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ResourceDiscoveryAppService.java @@ -0,0 +1,50 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.dto.ResourceSearchResponse; +import com.iflytek.skillhub.dto.ResourceSummaryResponse; +import com.iflytek.skillhub.search.ResourceDiscoveryQueryService; +import com.iflytek.skillhub.search.ResourceDiscoveryQueryService.ResourceQuery; +import java.util.Set; +import org.springframework.stereotype.Service; + +/** Maps the replaceable resource-search result into the public API projection. */ +@Service +public class ResourceDiscoveryAppService { + + private final ResourceDiscoveryQueryService queryService; + + public ResourceDiscoveryAppService(ResourceDiscoveryQueryService queryService) { + this.queryService = queryService; + } + + public ResourceSearchResponse search( + String keyword, + String namespace, + String resourceType, + String sort, + int page, + int size, + Set memberNamespaceIds + ) { + int safePage = Math.max(0, page); + int safeSize = Math.min(Math.max(size, 1), 100); + var result = queryService.search(new ResourceQuery( + keyword, namespace, resourceType, sort, safePage, safeSize, memberNamespaceIds)); + return new ResourceSearchResponse( + result.items().stream().map(item -> new ResourceSummaryResponse( + item.resourceType(), + "/" + ("SUITE".equals(item.resourceType()) ? "suite" : "space") + + "/" + item.namespace() + "/" + item.slug(), + item.id(), + item.namespace(), + item.slug(), + item.displayName(), + item.summary(), + item.version(), + item.visibility(), + item.installCount(), + item.available(), + item.updatedAt())).toList(), + result.total(), result.page(), result.size()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java index 6d68193f..074c51d8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java @@ -10,8 +10,11 @@ import com.iflytek.skillhub.domain.review.ReviewService; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.review.ReviewSubjectType; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.suite.SkillSuiteActionContext; +import com.iflytek.skillhub.domain.suite.SkillSuiteLifecycleService; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.ReviewProgressPageResponse; import com.iflytek.skillhub.dto.ReviewTaskResponse; @@ -40,6 +43,7 @@ public class ReviewPortalAppService { private final RbacService rbacService; private final AuditLogService auditLogService; private final RequestIdAccessor requestIdAccessor; + private final SkillSuiteLifecycleService suiteLifecycleService; public ReviewPortalAppService(ReviewService reviewService, ReviewTaskRepository reviewTaskRepository, @@ -48,7 +52,8 @@ public class ReviewPortalAppService { ReviewProgressQueryRepository reviewProgressQueryRepository, RbacService rbacService, AuditLogService auditLogService, - RequestIdAccessor requestIdAccessor) { + RequestIdAccessor requestIdAccessor, + SkillSuiteLifecycleService suiteLifecycleService) { this.reviewService = reviewService; this.reviewTaskRepository = reviewTaskRepository; this.namespaceRepository = namespaceRepository; @@ -57,6 +62,7 @@ public class ReviewPortalAppService { this.rbacService = rbacService; this.auditLogService = auditLogService; this.requestIdAccessor = requestIdAccessor; + this.suiteLifecycleService = suiteLifecycleService; } @Transactional @@ -80,6 +86,12 @@ public class ReviewPortalAppService { String userId, Map userNsRoles, AuditRequestContext auditContext) { + ReviewTask existing = findReview(reviewTaskId); + if (existing.getSubjectType() == ReviewSubjectType.SUITE_VERSION) { + ReviewTask task = suiteLifecycleService.approveReview( + reviewTaskId, comment, suiteContext(userId, userNsRoles, auditContext)); + return governanceQueryRepository.getReviewTaskResponse(task); + } ReviewTask task = reviewService.approveReview( reviewTaskId, userId, @@ -97,6 +109,12 @@ public class ReviewPortalAppService { String userId, Map userNsRoles, AuditRequestContext auditContext) { + ReviewTask existing = findReview(reviewTaskId); + if (existing.getSubjectType() == ReviewSubjectType.SUITE_VERSION) { + ReviewTask task = suiteLifecycleService.rejectReview( + reviewTaskId, comment, suiteContext(userId, userNsRoles, auditContext)); + return governanceQueryRepository.getReviewTaskResponse(task); + } ReviewTask task = reviewService.rejectReview( reviewTaskId, userId, @@ -111,9 +129,14 @@ public class ReviewPortalAppService { @Transactional public void withdrawReview(Long reviewTaskId, String userId, + Map userNsRoles, AuditRequestContext auditContext) { - ReviewTask task = reviewTaskRepository.findById(reviewTaskId) - .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId)); + ReviewTask task = findReview(reviewTaskId); + if (task.getSubjectType() == ReviewSubjectType.SUITE_VERSION) { + suiteLifecycleService.withdrawReview( + reviewTaskId, suiteContext(userId, userNsRoles, auditContext)); + return; + } reviewService.withdrawReview(task.getSkillVersionId(), userId); recordAudit( "REVIEW_WITHDRAW", @@ -244,7 +267,12 @@ public class ReviewPortalAppService { throw new DomainForbiddenException("review.no_permission"); } - List attempts = reviewTaskRepository + List attempts = anchor.getSubjectType() == ReviewSubjectType.SUITE_VERSION + ? reviewTaskRepository + .findBySubmittedByAndSubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc( + userId, ReviewSubjectType.SUITE_VERSION, + anchor.getSubjectId(), anchor.getSubjectVersion()) + : reviewTaskRepository .findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( userId, anchor.getSkillId(), anchor.getSkillVersion()); return governanceQueryRepository.getReviewTaskResponses(attempts); @@ -268,7 +296,12 @@ public class ReviewPortalAppService { throw new DomainForbiddenException("review.no_permission"); } - List attempts = reviewTaskRepository + List attempts = anchor.getSubjectType() == ReviewSubjectType.SUITE_VERSION + ? reviewTaskRepository + .findBySubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc( + ReviewSubjectType.SUITE_VERSION, + anchor.getSubjectId(), anchor.getSubjectVersion()) + : reviewTaskRepository .findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( anchor.getSkillId(), anchor.getSkillVersion()); return governanceQueryRepository.getReviewTaskResponses(attempts); @@ -308,6 +341,22 @@ public class ReviewPortalAppService { return rbacService.getUserRoleCodes(userId); } + private ReviewTask findReview(Long reviewTaskId) { + return reviewTaskRepository.findById(reviewTaskId) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId)); + } + + private SkillSuiteActionContext suiteContext( + String userId, + Map userNsRoles, + AuditRequestContext auditContext + ) { + return new SkillSuiteActionContext( + userId, normalizeRoles(userNsRoles), platformRoles(userId), requestIdAccessor.current(), + auditContext != null ? auditContext.clientIp() : null, + auditContext != null ? auditContext.userAgent() : null); + } + private boolean hasPlatformReviewRole(Set platformRoles) { return platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java new file mode 100644 index 00000000..2e88f1a6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java @@ -0,0 +1,609 @@ +package com.iflytek.skillhub.service; + +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.NamespaceStatus; +import com.iflytek.skillhub.domain.audit.AuditDetail; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.suite.CreateSkillSuiteDraftCommand; +import com.iflytek.skillhub.domain.suite.SkillSuiteActionContext; +import com.iflytek.skillhub.domain.suite.SkillSuiteDraftService; +import com.iflytek.skillhub.domain.suite.SkillSuiteLifecycleService; +import com.iflytek.skillhub.domain.suite.SkillSuiteInstallMetricsService; +import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperation; +import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperationRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteAllowedAction; +import com.iflytek.skillhub.domain.suite.SkillSuiteMemberSelection; +import com.iflytek.skillhub.domain.suite.SkillSuiteQueryService; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember; +import com.iflytek.skillhub.dto.SkillSuiteCreateRequest; +import com.iflytek.skillhub.dto.SkillSuiteMemberRequest; +import com.iflytek.skillhub.dto.SkillSuiteMemberResponse; +import com.iflytek.skillhub.dto.SkillSuiteResponse; +import com.iflytek.skillhub.dto.SkillSuiteInstallMemberResponse; +import com.iflytek.skillhub.dto.SkillSuiteInstallPlanResponse; +import com.iflytek.skillhub.dto.SkillSuiteMemberCandidateResponse; +import com.iflytek.skillhub.dto.SkillSuiteVersionSummaryResponse; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.repository.SkillSuiteCandidateQueryRepository; +import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository; +import com.iflytek.skillhub.dto.MySkillSuiteSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** Application boundary for resolving Suite inputs and invoking domain workflows. */ +@Service +public class SkillSuiteAppService { + + private static final Logger log = LoggerFactory.getLogger(SkillSuiteAppService.class); + + private final NamespaceRepository namespaceRepository; + private final SkillQueryService skillQueryService; + private final SkillSuiteDraftService draftService; + private final SkillSuiteLifecycleService lifecycleService; + private final SkillSuiteQueryService queryService; + private final SkillSuiteInstallMetricsService installMetricsService; + private final SkillSuiteInstallOperationRepository installOperationRepository; + private final AuditLogService auditLogService; + private final RequestIdAccessor requestIdAccessor; + private final SkillSuiteCandidateQueryRepository candidateQueryRepository; + private final MySkillSuiteQueryRepository mySkillSuiteQueryRepository; + + public SkillSuiteAppService( + NamespaceRepository namespaceRepository, + SkillQueryService skillQueryService, + SkillSuiteDraftService draftService, + SkillSuiteLifecycleService lifecycleService, + SkillSuiteQueryService queryService, + SkillSuiteInstallMetricsService installMetricsService, + SkillSuiteInstallOperationRepository installOperationRepository, + AuditLogService auditLogService, + RequestIdAccessor requestIdAccessor, + SkillSuiteCandidateQueryRepository candidateQueryRepository, + MySkillSuiteQueryRepository mySkillSuiteQueryRepository + ) { + this.namespaceRepository = namespaceRepository; + this.skillQueryService = skillQueryService; + this.draftService = draftService; + this.lifecycleService = lifecycleService; + this.queryService = queryService; + this.installMetricsService = installMetricsService; + this.installOperationRepository = installOperationRepository; + this.auditLogService = auditLogService; + this.requestIdAccessor = requestIdAccessor; + this.candidateQueryRepository = candidateQueryRepository; + this.mySkillSuiteQueryRepository = mySkillSuiteQueryRepository; + } + + public PageResponse listMine( + String userId, + Map namespaceRoles, + String query, + int page, + int size + ) { + Set adminNamespaceIds = namespaceRoles.entrySet().stream() + .filter(entry -> entry.getValue() == NamespaceRole.OWNER + || entry.getValue() == NamespaceRole.ADMIN) + .map(Map.Entry::getKey) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + return mySkillSuiteQueryRepository.findMine( + userId, namespaceRoles.keySet(), adminNamespaceIds, query, + Math.max(0, page), Math.min(Math.max(1, size), 100)); + } + + public List searchCandidates( + String suiteNamespace, + SkillVisibility visibility, + String query, + int size, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + Namespace namespace = namespaceRepository.findBySlug(suiteNamespace) + .orElseThrow(() -> new DomainBadRequestException( + "error.namespace.slug.notFound", suiteNamespace)); + if (namespace.getStatus() != NamespaceStatus.ACTIVE) { + throw new DomainBadRequestException("error.suite.namespace.notWritable", namespace.getStatus()); + } + boolean superAdmin = platformRoles.contains("SUPER_ADMIN"); + if (!superAdmin && !namespaceRoles.containsKey(namespace.getId())) { + throw new DomainBadRequestException("error.suite.lifecycle.noPermission"); + } + int boundedSize = Math.max(1, Math.min(size, 100)); + List memberNamespaceIds = List.copyOf(namespaceRoles.keySet()); + List adminNamespaceIds = namespaceRoles.entrySet().stream() + .filter(entry -> entry.getValue() == NamespaceRole.OWNER + || entry.getValue() == NamespaceRole.ADMIN) + .map(Map.Entry::getKey) + .toList(); + return candidateQueryRepository.search( + namespace.getId(), visibility, query, userId, memberNamespaceIds, + adminNamespaceIds, superAdmin, boundedSize); + } + + @Transactional + public SkillSuiteInstallPlanResponse createInstallPlan( + String namespace, + String slug, + String version, + String userId, + Map namespaceRoles, + Set platformRoles, + String clientRequestId, + HttpServletRequest request + ) { + String retryKey = normalizeClientRequestId(clientRequestId); + String actorKey = idempotencyActorKey(userId); + SkillSuiteInstallOperation existing = installOperationRepository + .findByClientRequestIdAndActorKey(retryKey, actorKey) + .orElse(null); + if (existing != null) { + return replayInstallPlan( + existing, namespace, slug, version, userId, namespaceRoles, platformRoles); + } + + SkillSuiteQueryService.Detail detail = queryService.getDetail( + namespace, slug, version, userId, namespaceRoles, platformRoles); + if (!detail.available()) { + throw new DomainBadRequestException("error.suite.install.unavailable"); + } + List members = resolveInstallMembers(detail, userId, namespaceRoles); + + String operationId = UUID.randomUUID().toString(); + String fingerprint = suiteFingerprint(detail, members); + int inserted = installOperationRepository.insertIfAbsent( + operationId, retryKey, actorKey, detail.suite().getId(), detail.version().getId()); + if (inserted == 0) { + SkillSuiteInstallOperation concurrent = installOperationRepository + .findByClientRequestIdAndActorKey(retryKey, actorKey) + .orElseThrow(() -> new IllegalStateException( + "Suite install operation disappeared after an idempotency conflict")); + return replayInstallPlan( + concurrent, namespace, slug, version, userId, namespaceRoles, platformRoles); + } + + installMetricsService.recordIssuedPlan(detail.suite().getId()); + AuditRequestContext audit = AuditRequestContext.from(request); + for (SkillSuiteQueryService.MemberDetail member : detail.members()) { + auditLogService.record( + userId, "ISSUE_SKILL_DOWNLOAD", "SKILL_VERSION", + member.snapshot().getSkillVersionId(), operationId, audit.clientIp(), audit.userAgent(), + AuditDetail.builder() + .put("source", "SUITE") + .put("suiteId", detail.suite().getId()) + .put("suiteVersionId", detail.version().getId()) + .build()); + } + auditLogService.record( + userId, "ISSUE_SKILL_SUITE_INSTALL_PLAN", "SKILL_SUITE_VERSION", + detail.version().getId(), operationId, audit.clientIp(), audit.userAgent(), + AuditDetail.builder() + .put("suiteId", detail.suite().getId()) + .put("memberCount", members.size()) + .put("source", "SUITE") + .build()); + log.info( + "Suite install plan issued [suiteId={}, versionId={}, actorId={}, memberCount={}, operationId={}]", + detail.suite().getId(), detail.version().getId(), userId, members.size(), operationId); + return new SkillSuiteInstallPlanResponse( + operationId, namespace, slug, detail.version().getVersion(), fingerprint, members); + } + + private SkillSuiteInstallPlanResponse replayInstallPlan( + SkillSuiteInstallOperation existing, + String namespace, + String slug, + String requestedVersion, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + SkillSuiteQueryService.Detail detail; + try { + detail = queryService.getDetailByVersionId( + namespace, slug, existing.getSuiteVersionId(), userId, namespaceRoles, platformRoles); + } catch (com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException exception) { + throw new DomainBadRequestException("error.suite.install.operationConflict"); + } + if (!existing.getSuiteId().equals(detail.suite().getId()) + || (requestedVersion != null && !requestedVersion.isBlank() + && !requestedVersion.equals(detail.version().getVersion()))) { + throw new DomainBadRequestException("error.suite.install.operationConflict"); + } + if (!detail.available()) { + throw new DomainBadRequestException("error.suite.install.unavailable"); + } + List members = resolveInstallMembers(detail, userId, namespaceRoles); + String fingerprint = suiteFingerprint(detail, members); + log.info( + "Suite install plan safely replayed [suiteId={}, versionId={}, actorId={}, memberCount={}, operationId={}]", + detail.suite().getId(), detail.version().getId(), userId, members.size(), existing.getOperationId()); + return new SkillSuiteInstallPlanResponse( + existing.getOperationId(), namespace, slug, detail.version().getVersion(), fingerprint, members); + } + + private List resolveInstallMembers( + SkillSuiteQueryService.Detail detail, + String userId, + Map namespaceRoles + ) { + List members = new ArrayList<>(detail.members().size()); + try { + for (SkillSuiteQueryService.MemberDetail member : detail.members()) { + var snapshot = member.snapshot(); + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + snapshot.getNamespaceSlugSnapshot(), snapshot.getSkillSlugSnapshot(), + snapshot.getSkillVersionSnapshot(), null, snapshot.getFingerprintSnapshot(), + userId, namespaceRoles); + members.add(new SkillSuiteInstallMemberResponse( + snapshot.getSkillId(), snapshot.getSkillVersionId(), + resolved.namespace(), resolved.slug(), resolved.version(), resolved.fingerprint(), + resolved.downloadUrl(), snapshot.getPosition(), + Objects.equals(snapshot.getSkillVersionId(), detail.version().getEntrySkillVersionId()))); + } + } catch (LocalizedDomainException exception) { + // A Suite reader may no longer be allowed to inspect a restricted member. Do not expose + // the member coordinate or the underlying authorization failure through install preflight. + throw new DomainBadRequestException("error.suite.install.unavailable"); + } + return members; + } + + private String normalizeClientRequestId(String clientRequestId) { + if (clientRequestId == null || clientRequestId.isBlank()) { + return UUID.randomUUID().toString(); + } + if (!RequestIdAccessor.isValid(clientRequestId)) { + throw new DomainBadRequestException("error.suite.install.idempotencyKey.invalid"); + } + return clientRequestId; + } + + private String idempotencyActorKey(String userId) { + return userId == null ? "anonymous" : "user:" + userId; + } + + public SkillSuiteResponse getDetail( + String namespace, + String slug, + String version, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + SkillSuiteQueryService.Detail detail = queryService.getDetail( + namespace, slug, version, userId, namespaceRoles, platformRoles); + List members = detail.members().stream() + .map(member -> new SkillSuiteMemberResponse( + member.snapshot().getSkillId(), member.snapshot().getSkillVersionId(), + member.snapshot().getNamespaceSlugSnapshot(), member.snapshot().getSkillSlugSnapshot(), + member.state().viewerCanRead() ? member.state().displayName() : null, + member.state().viewerCanRead() ? member.state().summary() : null, + member.snapshot().getSkillVersionSnapshot(), member.snapshot().getFingerprintSnapshot(), + member.snapshot().getPosition(), + Objects.equals(member.snapshot().getSkillVersionId(), detail.version().getEntrySkillVersionId()), + member.state().viewerCanRead(), + member.availability().reason() == null ? null : member.availability().reason().name())) + .toList(); + Set allowedActions = + lifecycleService.allowedActions( + detail.suite(), detail.version(), detail.namespace(), + authorizationContext(userId, namespaceRoles, platformRoles)); + return new SkillSuiteResponse( + detail.suite().getId(), detail.version().getId(), detail.namespace().getSlug(), + detail.suite().getSlug(), detail.version().getDisplayName(), detail.version().getSummary(), + detail.version().getOverview(), + detail.version().getVersion(), detail.version().getStatus().name(), + detail.version().getVisibility(), detail.suite().getStatus().name(), + detail.suite().isHidden(), allowedActions, detail.available(), members); + } + + public SkillSuiteResponse create( + SkillSuiteCreateRequest request, + String userId, + Map namespaceRoles, + Set platformRoles, + HttpServletRequest httpRequest + ) { + Namespace namespace = namespaceRepository.findBySlug(request.namespace()) + .orElseThrow(() -> new DomainBadRequestException( + "error.namespace.slug.notFound", request.namespace())); + SkillSuiteDraftService.CreatedDraft created = draftService.create( + toCommand(namespace.getId(), request, userId, namespaceRoles), + context(userId, namespaceRoles, platformRoles, httpRequest)); + return toResponse(namespace, created, userId, namespaceRoles, platformRoles); + } + + public SkillSuiteResponse createVersion( + Long suiteId, + SkillSuiteCreateRequest request, + String userId, + Map namespaceRoles, + Set platformRoles, + HttpServletRequest httpRequest + ) { + Namespace namespace = namespaceRepository.findBySlug(request.namespace()) + .orElseThrow(() -> new DomainBadRequestException( + "error.namespace.slug.notFound", request.namespace())); + SkillSuiteDraftService.CreatedDraft created = draftService.createVersion( + suiteId, toCommand(namespace.getId(), request, userId, namespaceRoles), + context(userId, namespaceRoles, platformRoles, httpRequest)); + return toResponse(namespace, created, userId, namespaceRoles, platformRoles); + } + + public SkillSuiteResponse updateDraft( + Long suiteId, + Long versionId, + SkillSuiteCreateRequest request, + String userId, + Map namespaceRoles, + Set platformRoles, + HttpServletRequest httpRequest + ) { + Namespace namespace = namespaceRepository.findBySlug(request.namespace()) + .orElseThrow(() -> new DomainBadRequestException( + "error.namespace.slug.notFound", request.namespace())); + SkillSuiteDraftService.CreatedDraft updated = draftService.updateDraft( + suiteId, versionId, toCommand(namespace.getId(), request, userId, namespaceRoles), + context(userId, namespaceRoles, platformRoles, httpRequest)); + return toResponse(namespace, updated, userId, namespaceRoles, platformRoles); + } + + public List listVersions( + String namespace, + String slug, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + return queryService.listVersions(namespace, slug, userId, namespaceRoles, platformRoles).stream() + .map(version -> new SkillSuiteVersionSummaryResponse( + version.id(), version.version(), version.status().name(), version.visibility(), + version.publishedAt(), version.yankedAt(), version.createdAt())) + .toList(); + } + + public void submitForReview( + Long suiteId, + Long versionId, + String userId, + Map namespaceRoles, + Set platformRoles, + HttpServletRequest request + ) { + lifecycleService.submitForReview( + suiteId, versionId, context(userId, namespaceRoles, platformRoles, request)); + } + + public void publishPrivate( + Long suiteId, + Long versionId, + String userId, + Map namespaceRoles, + Set platformRoles, + HttpServletRequest request + ) { + lifecycleService.confirmPrivatePublish( + suiteId, versionId, context(userId, namespaceRoles, platformRoles, request)); + } + + public void approve( + Long reviewTaskId, + String comment, + String userId, + Map namespaceRoles, + Set platformRoles, + HttpServletRequest request + ) { + lifecycleService.approveReview( + reviewTaskId, comment, context(userId, namespaceRoles, platformRoles, request)); + } + + public void reject( + Long reviewTaskId, + String comment, + String userId, + Map namespaceRoles, + Set platformRoles, + HttpServletRequest request + ) { + lifecycleService.rejectReview( + reviewTaskId, comment, context(userId, namespaceRoles, platformRoles, request)); + } + + public void reopen( + Long suiteId, + Long versionId, + String userId, + Map namespaceRoles, + Set platformRoles, + HttpServletRequest request + ) { + lifecycleService.reopenRejected( + suiteId, versionId, context(userId, namespaceRoles, platformRoles, request)); + } + + public void yank( + Long suiteId, + Long versionId, + String reason, + String userId, + Map namespaceRoles, + Set platformRoles, + HttpServletRequest request + ) { + lifecycleService.yank( + suiteId, versionId, reason, + context(userId, namespaceRoles, platformRoles, request)); + } + + public void setHidden( + Long suiteId, + boolean hidden, + String userId, + Map namespaceRoles, + Set platformRoles, + HttpServletRequest request + ) { + lifecycleService.setHidden( + suiteId, hidden, context(userId, namespaceRoles, platformRoles, request)); + } + + public void setArchived( + Long suiteId, + boolean archived, + String userId, + Map namespaceRoles, + Set platformRoles, + HttpServletRequest request + ) { + lifecycleService.setArchived( + suiteId, archived, context(userId, namespaceRoles, platformRoles, request)); + } + + public void delete( + Long suiteId, + String userId, + Map namespaceRoles, + Set platformRoles, + HttpServletRequest request + ) { + lifecycleService.delete( + suiteId, context(userId, namespaceRoles, platformRoles, request)); + } + + private SkillSuiteMemberSelection resolve( + SkillSuiteMemberRequest member, + String userId, + Map namespaceRoles + ) { + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + member.namespace(), member.slug(), member.version(), null, null, userId, namespaceRoles); + return new SkillSuiteMemberSelection( + resolved.skillId(), resolved.versionId(), resolved.namespace(), resolved.slug(), + resolved.version(), resolved.fingerprint()); + } + + private CreateSkillSuiteDraftCommand toCommand( + Long namespaceId, + SkillSuiteCreateRequest request, + String userId, + Map namespaceRoles + ) { + List selections = request.members().stream() + .map(member -> resolve(member, userId, namespaceRoles)) + .toList(); + Long entryVersionId = resolveEntryVersionId(request.entrySkill(), selections); + return new CreateSkillSuiteDraftCommand( + namespaceId, request.slug(), request.displayName(), request.summary(), request.overview(), + request.version(), request.visibility(), request.changelog(), + entryVersionId, selections); + } + + private Long resolveEntryVersionId( + SkillSuiteMemberRequest entry, + List members + ) { + if (entry == null) { + return null; + } + return members.stream() + .filter(member -> Objects.equals(member.namespaceSlug(), entry.namespace()) + && Objects.equals(member.skillSlug(), entry.slug()) + && Objects.equals(member.version(), entry.version())) + .map(SkillSuiteMemberSelection::skillVersionId) + .findFirst() + .orElseThrow(() -> new DomainBadRequestException("error.suite.entry.notMember")); + } + + private SkillSuiteActionContext context( + String userId, + Map namespaceRoles, + Set platformRoles, + HttpServletRequest request + ) { + AuditRequestContext audit = AuditRequestContext.from(request); + return new SkillSuiteActionContext( + userId, namespaceRoles, platformRoles, requestIdAccessor.current(), + audit.clientIp(), audit.userAgent()); + } + + private SkillSuiteResponse toResponse( + Namespace namespace, + SkillSuiteDraftService.CreatedDraft created, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + List members = new ArrayList<>(); + for (SkillSuiteVersionMember member : created.members()) { + members.add(new SkillSuiteMemberResponse( + member.getSkillId(), member.getSkillVersionId(), member.getNamespaceSlugSnapshot(), + member.getSkillSlugSnapshot(), null, null, member.getSkillVersionSnapshot(), + member.getFingerprintSnapshot(), member.getPosition(), + Objects.equals(member.getSkillVersionId(), created.version().getEntrySkillVersionId()), + false, + null)); + } + Set allowedActions = + lifecycleService.allowedActions( + created.suite(), created.version(), namespace, + authorizationContext(userId, namespaceRoles, platformRoles)); + return new SkillSuiteResponse( + created.suite().getId(), created.version().getId(), namespace.getSlug(), + created.suite().getSlug(), created.version().getDisplayName(), created.version().getSummary(), + created.version().getOverview(), + created.version().getVersion(), created.version().getStatus().name(), + created.version().getVisibility(), created.suite().getStatus().name(), + created.suite().isHidden(), allowedActions, false, members); + } + + private SkillSuiteActionContext authorizationContext( + String userId, + Map namespaceRoles, + Set platformRoles + ) { + return new SkillSuiteActionContext(userId, namespaceRoles, platformRoles, null, null, null); + } + + private String suiteFingerprint( + SkillSuiteQueryService.Detail detail, + List members + ) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(("suite:" + detail.namespace().getSlug() + "/" + detail.suite().getSlug() + + "@" + detail.version().getVersion() + "\n").getBytes(StandardCharsets.UTF_8)); + for (SkillSuiteInstallMemberResponse member : members) { + String line = member.position() + ":" + member.namespace() + "/" + member.slug() + + "@" + member.version() + ":" + member.fingerprint() + + ":entry=" + member.entry() + "\n"; + digest.update(line.getBytes(StandardCharsets.UTF_8)); + } + return "sha256:" + HexFormat.of().formatHex(digest.digest()); + } catch (Exception exception) { + throw new IllegalStateException("Failed to compute Suite fingerprint", exception); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java index c5b320c3..3a79cb66 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.task; import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperationRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -19,12 +20,19 @@ public class IdempotencyCleanupTask { private static final Logger logger = LoggerFactory.getLogger(IdempotencyCleanupTask.class); private static final long STALE_THRESHOLD_MINUTES = 30; + private static final long SUITE_RETRY_RETENTION_HOURS = 24; private final IdempotencyRecordRepository idempotencyRecordRepository; + private final SkillSuiteInstallOperationRepository suiteInstallOperationRepository; private final Clock clock; - public IdempotencyCleanupTask(IdempotencyRecordRepository idempotencyRecordRepository, Clock clock) { + public IdempotencyCleanupTask( + IdempotencyRecordRepository idempotencyRecordRepository, + SkillSuiteInstallOperationRepository suiteInstallOperationRepository, + Clock clock + ) { this.idempotencyRecordRepository = idempotencyRecordRepository; + this.suiteInstallOperationRepository = suiteInstallOperationRepository; this.clock = clock; } @@ -33,7 +41,11 @@ public class IdempotencyCleanupTask { public void cleanupExpiredRecords() { Instant now = Instant.now(clock); int deleted = idempotencyRecordRepository.deleteExpired(now); - logger.info("Cleaned up {} expired idempotency records", deleted); + int suiteOperationsDeleted = suiteInstallOperationRepository.deleteCreatedBefore( + now.minusSeconds(SUITE_RETRY_RETENTION_HOURS * 3600)); + logger.info( + "Cleaned up expired idempotency records [requestRecords={}, suiteOperations={}]", + deleted, suiteOperationsDeleted); } @Scheduled(fixedDelay = 300000) diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index c89151d8..a4fc6bdf 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -24,6 +24,8 @@ spring: client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:local-placeholder} skillhub: + suite: + review-writes-enabled: ${SKILLHUB_SUITE_REVIEW_WRITES_ENABLED:true} auth: mock: enabled: true diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index a1409d1b..0d8575b3 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -96,6 +96,10 @@ spring: enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false} skillhub: + suite: + # Fail closed for deployment modes whose upgrade topology is unknown. Single-instance + # distributions explicitly enable this after ruling out mixed application versions. + review-writes-enabled: ${SKILLHUB_SUITE_REVIEW_WRITES_ENABLED:false} observability: tracing-mode: ${SKILLHUB_TRACING_MODE:none} log-format: ${SKILLHUB_LOG_FORMAT:text} diff --git a/server/skillhub-app/src/main/resources/db/migration/V49__skill_suites.sql b/server/skillhub-app/src/main/resources/db/migration/V49__skill_suites.sql new file mode 100644 index 00000000..b8b02616 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V49__skill_suites.sql @@ -0,0 +1,71 @@ +-- Skill Suites are typed collections of exact published Skill versions. +-- They intentionally use separate tables so a Skill and Suite may share one namespace/slug. + +CREATE TABLE skill_suite ( + id BIGSERIAL PRIMARY KEY, + namespace_id BIGINT NOT NULL REFERENCES namespace(id), + slug VARCHAR(128) NOT NULL, + display_name VARCHAR(256) NOT NULL, + summary TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + latest_version_id BIGINT, + install_request_count BIGINT NOT NULL DEFAULT 0, + hidden BOOLEAN NOT NULL DEFAULT FALSE, + hidden_at TIMESTAMPTZ, + hidden_by VARCHAR(128), + created_by VARCHAR(128) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(128), + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_skill_suite_namespace_slug UNIQUE (namespace_id, slug) +); + +CREATE INDEX idx_skill_suite_namespace_status + ON skill_suite(namespace_id, status); + +CREATE TABLE skill_suite_version ( + id BIGSERIAL PRIMARY KEY, + suite_id BIGINT NOT NULL REFERENCES skill_suite(id) ON DELETE CASCADE, + version VARCHAR(64) NOT NULL, + display_name VARCHAR(256) NOT NULL, + summary TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + visibility VARCHAR(32) NOT NULL, + changelog TEXT, + entry_skill_version_id BIGINT REFERENCES skill_version(id) ON DELETE SET NULL, + published_at TIMESTAMPTZ, + yanked_at TIMESTAMPTZ, + yanked_by VARCHAR(128), + yank_reason TEXT, + created_by VARCHAR(128) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_skill_suite_version UNIQUE (suite_id, version) +); + +CREATE INDEX idx_skill_suite_version_suite_status + ON skill_suite_version(suite_id, status); + +CREATE TABLE skill_suite_version_member ( + id BIGSERIAL PRIMARY KEY, + suite_version_id BIGINT NOT NULL REFERENCES skill_suite_version(id) ON DELETE CASCADE, + skill_id BIGINT REFERENCES skill(id) ON DELETE SET NULL, + skill_version_id BIGINT REFERENCES skill_version(id) ON DELETE SET NULL, + position INT NOT NULL CHECK (position >= 0), + namespace_slug_snapshot VARCHAR(128) NOT NULL, + skill_slug_snapshot VARCHAR(128) NOT NULL, + skill_version_snapshot VARCHAR(64) NOT NULL, + fingerprint_snapshot VARCHAR(255) NOT NULL, + CONSTRAINT uk_skill_suite_member_position UNIQUE (suite_version_id, position) +); + +-- The partial index continues to allow multiple tombstoned historical rows after hard deletion. +CREATE UNIQUE INDEX uk_skill_suite_member_skill + ON skill_suite_version_member(suite_version_id, skill_id) + WHERE skill_id IS NOT NULL; + +CREATE INDEX idx_skill_suite_member_version + ON skill_suite_version_member(skill_version_id); + +ALTER TABLE skill_suite + ADD CONSTRAINT fk_skill_suite_latest_version + FOREIGN KEY (latest_version_id) REFERENCES skill_suite_version(id) ON DELETE SET NULL; diff --git a/server/skillhub-app/src/main/resources/db/migration/V50__typed_review_subjects.sql b/server/skillhub-app/src/main/resources/db/migration/V50__typed_review_subjects.sql new file mode 100644 index 00000000..f55db85f --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V50__typed_review_subjects.sql @@ -0,0 +1,26 @@ +-- Keep legacy Skill columns during the compatibility window while adding one typed review identity. +ALTER TABLE review_task + ADD COLUMN subject_type VARCHAR(32), + ADD COLUMN subject_id BIGINT, + ADD COLUMN subject_version_id BIGINT, + ADD COLUMN subject_version VARCHAR(64); + +UPDATE review_task +SET subject_type = 'SKILL_VERSION', + subject_id = skill_id, + subject_version_id = skill_version_id, + subject_version = skill_version; + +ALTER TABLE review_task + ALTER COLUMN subject_type SET NOT NULL, + ALTER COLUMN subject_id SET NOT NULL, + ALTER COLUMN subject_version SET NOT NULL, + ALTER COLUMN skill_id DROP NOT NULL, + ALTER COLUMN skill_version DROP NOT NULL; + +CREATE INDEX idx_review_task_subject_attempts + ON review_task(subject_type, subject_id, subject_version, submitted_at DESC, id DESC); + +CREATE UNIQUE INDEX idx_review_task_suite_version_pending + ON review_task(subject_type, subject_version_id) + WHERE subject_type = 'SUITE_VERSION' AND status = 'PENDING'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V51__skill_suite_install_operations.sql b/server/skillhub-app/src/main/resources/db/migration/V51__skill_suite_install_operations.sql new file mode 100644 index 00000000..8b2cae7f --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V51__skill_suite_install_operations.sql @@ -0,0 +1,13 @@ +-- Keeps Suite install-plan counters idempotent across safe client retries. +CREATE TABLE skill_suite_install_operation ( + operation_id VARCHAR(64) PRIMARY KEY, + client_request_id VARCHAR(64) NOT NULL, + actor_key VARCHAR(160) NOT NULL, + suite_id BIGINT NOT NULL, + suite_version_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_skill_suite_install_client_actor UNIQUE (client_request_id, actor_key) +); + +CREATE INDEX idx_skill_suite_install_operation_created_at + ON skill_suite_install_operation(created_at); diff --git a/server/skillhub-app/src/main/resources/db/migration/V52__skill_suite_overview.sql b/server/skillhub-app/src/main/resources/db/migration/V52__skill_suite_overview.sql new file mode 100644 index 00000000..0af7a52e --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V52__skill_suite_overview.sql @@ -0,0 +1,2 @@ +ALTER TABLE skill_suite_version + ADD COLUMN overview TEXT; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 0f807c91..3f2c0f43 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -198,3 +198,36 @@ error.skillReview.reason.tooLong=Moderation reason must not exceed {0} character error.pagination.invalid=Page must be non-negative and size must be between 1 and {0} error.request.conflict=The data changed while this request was being processed. Refresh and try again. error.skillReview.notInteractable=Reviews are available only for published skills + +# Skill Suites +error.suite.notFound=Skill Suite not found: {0} +error.suite.version.notFound=Skill Suite version not found: {0} +error.suite.version.mismatch=The Suite version does not belong to this Suite +error.suite.version.immutable=Published or submitted Suite version {0} cannot be edited +error.suite.version.notPublished=Skill Suite version {0} is not published +error.suite.members.empty=A Skill Suite must contain at least one Skill +error.suite.members.limit=A Skill Suite cannot contain more than {0} Skills +error.suite.members.duplicate=A Skill Suite cannot contain multiple versions of the same Skill +error.suite.members.unavailable=One or more Suite members are unavailable: {0} +error.suite.entry.notMember=The Entry Skill must be one of the Suite members +error.suite.namespace.notWritable=The Suite namespace is not writable: {0} +error.suite.lifecycle.noPermission=You do not have permission to manage this Skill Suite +error.suite.review.rolloutDisabled=Suite review submission is disabled until the typed-review rollout is complete +error.suite.review.private=Private Skill Suites do not require review +error.suite.review.notDraft=Skill Suite version {0} is not a draft +error.suite.review.notPending=Skill Suite version {0} is not pending review +error.suite.review.notRejected=Skill Suite version {0} was not rejected +error.suite.review.subjectMismatch=The review task does not target a Skill Suite version +error.suite.publish.notPrivate=Only private Skill Suites can be published without review +error.suite.publish.notDraft=Skill Suite version {0} is not a draft +error.suite.displayName.required=Skill Suite display name is required +error.suite.version.required=Skill Suite version is required +error.suite.slug.exists=A Skill Suite with slug {0} already exists in this namespace +error.suite.access.denied=You do not have permission to view this Skill Suite +error.suite.install.unavailable=The Skill Suite cannot be installed because one or more members are unavailable +error.suite.notActive=Skill Suite is not active: {0} +error.suite.version.exists=Skill Suite version already exists: {0} +error.suite.version.renameNotAllowed=A Skill Suite version cannot be renamed; create a new version instead +error.suite.delete.pendingReview=Skill Suite cannot be deleted while a review is pending +error.suite.install.operationConflict=The idempotency key was already used for another Skill Suite version +error.suite.install.idempotencyKey.invalid=The Suite install idempotency key is invalid diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 5fb0f83f..f78ca09f 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -198,3 +198,36 @@ error.skillReview.reason.tooLong=管理原因不能超过 {0} 个字符 error.pagination.invalid=页码不能为负数,每页数量必须在 1 到 {0} 之间 error.request.conflict=数据在请求处理期间已发生变化,请刷新后重试 error.skillReview.notInteractable=仅已发布的技能可以评价 + +# 技能套件 +error.suite.notFound=未找到技能套件:{0} +error.suite.version.notFound=未找到技能套件版本:{0} +error.suite.version.mismatch=该套件版本不属于当前套件 +error.suite.version.immutable=已提交或已发布的套件版本 {0} 不可编辑 +error.suite.version.notPublished=技能套件版本 {0} 未发布 +error.suite.members.empty=技能套件至少需要包含一个技能 +error.suite.members.limit=技能套件最多包含 {0} 个技能 +error.suite.members.duplicate=技能套件不能包含同一技能的多个版本 +error.suite.members.unavailable=一个或多个套件成员当前不可用:{0} +error.suite.entry.notMember=入口技能必须是套件成员 +error.suite.namespace.notWritable=套件所在命名空间不可写:{0} +error.suite.lifecycle.noPermission=无权管理该技能套件 +error.suite.review.rolloutDisabled=类型化审核完成滚动升级前,暂不开放套件审核提交 +error.suite.review.private=私有技能套件不需要审核 +error.suite.review.notDraft=技能套件版本 {0} 不是草稿 +error.suite.review.notPending=技能套件版本 {0} 不在待审状态 +error.suite.review.notRejected=技能套件版本 {0} 未被驳回 +error.suite.review.subjectMismatch=该审核任务不属于技能套件版本 +error.suite.publish.notPrivate=只有私有技能套件可以免审核发布 +error.suite.publish.notDraft=技能套件版本 {0} 不是草稿 +error.suite.displayName.required=技能套件显示名称不能为空 +error.suite.version.required=技能套件版本不能为空 +error.suite.slug.exists=当前命名空间已存在 slug 为 {0} 的技能套件 +error.suite.access.denied=无权查看该技能套件 +error.suite.install.unavailable=技能套件中存在当前不可用的成员,无法安装 +error.suite.notActive=技能套件当前不可用:{0} +error.suite.version.exists=技能套件版本已存在:{0} +error.suite.version.renameNotAllowed=不能修改技能套件版本号,请创建新版本 +error.suite.delete.pendingReview=技能套件存在待审核版本,暂不能删除 +error.suite.install.operationConflict=该幂等键已用于其他技能套件版本 +error.suite.install.idempotencyKey.invalid=技能套件安装幂等键格式无效 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/WellKnownControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/WellKnownControllerTest.java index 52fb16bf..fcc24bd5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/WellKnownControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/WellKnownControllerTest.java @@ -31,7 +31,8 @@ class WellKnownControllerTest { void clawhubConfig_returns_apiBase() throws Exception { mockMvc.perform(get("/.well-known/clawhub.json")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.apiBase").value("/api/v1")); + .andExpect(jsonPath("$.apiBase").value("/api/v1")) + .andExpect(jsonPath("$.capabilities[0]").value("skill-suite-v1")); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillSuiteReviewConfigurationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillSuiteReviewConfigurationTest.java new file mode 100644 index 00000000..be8536a8 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillSuiteReviewConfigurationTest.java @@ -0,0 +1,61 @@ +package com.iflytek.skillhub.config; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.env.SystemEnvironmentPropertySource; +import org.springframework.core.io.ClassPathResource; + +import static org.assertj.core.api.Assertions.assertThat; + +class SkillSuiteReviewConfigurationTest { + + @Test + void defaultConfigurationKeepsSuiteReviewWritesFailClosed() throws IOException { + ConfigurableEnvironment environment = loadApplicationEnvironment( + List.of("application.yml"), Map.of()); + + assertThat(environment.getProperty("skillhub.suite.review-writes-enabled", Boolean.class)) + .isFalse(); + } + + @Test + void localProfileEnablesSuiteReviewWrites() throws IOException { + ConfigurableEnvironment environment = loadApplicationEnvironment( + List.of("application-local.yml", "application.yml"), Map.of()); + + assertThat(environment.getProperty("skillhub.suite.review-writes-enabled", Boolean.class)) + .isTrue(); + } + + @Test + void localProfileCanExplicitlyDisableSuiteReviewWrites() throws IOException { + ConfigurableEnvironment environment = loadApplicationEnvironment( + List.of("application-local.yml", "application.yml"), + Map.of("SKILLHUB_SUITE_REVIEW_WRITES_ENABLED", "false")); + + assertThat(environment.getProperty("skillhub.suite.review-writes-enabled", Boolean.class)) + .isFalse(); + } + + private ConfigurableEnvironment loadApplicationEnvironment(List resourceNames, + Map environmentVariables) + throws IOException { + ConfigurableEnvironment environment = new StandardEnvironment(); + environment.getPropertySources().addFirst( + new SystemEnvironmentPropertySource("test-env", environmentVariables)); + + YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); + for (String resourceName : resourceNames) { + List> sources = loader.load( + resourceName, + new ClassPathResource(resourceName)); + sources.forEach(environment.getPropertySources()::addLast); + } + return environment; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java index 2674fc2e..b5ae184a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java @@ -160,6 +160,8 @@ class SkillSearchControllerTest { mockMvc.perform(get("/api/web/skills")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items[0].slug").value("demo-skill")) + // Legacy clients keep receiving the Skill-only contract after Suite support ships. + .andExpect(jsonPath("$.data.items[0].resourceType").doesNotExist()) .andExpect(jsonPath("$.data.items[0].labels").doesNotExist()); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteHardDeleteIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteHardDeleteIntegrationTest.java new file mode 100644 index 00000000..beb9d2ef --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteHardDeleteIntegrationTest.java @@ -0,0 +1,345 @@ +package com.iflytek.skillhub.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; + +import com.iflytek.skillhub.domain.audit.AuditLogRepository; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.ReviewPermissionChecker; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteActionContext; +import com.iflytek.skillhub.domain.suite.SkillSuiteLifecycleService; +import com.iflytek.skillhub.domain.suite.SkillSuiteMemberSelection; +import com.iflytek.skillhub.domain.suite.SkillSuitePublicationValidator; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMemberRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionStatus; +import com.iflytek.skillhub.domain.user.UserAccount; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Import({SkillSuiteLifecycleService.class, ReviewPermissionChecker.class, + AuditLogService.class, SkillSuiteHardDeleteIntegrationTest.ClockConfiguration.class}) +@TestPropertySource(properties = "skillhub.suite.review-writes-enabled=true") +class SkillSuiteHardDeleteIntegrationTest { + + @Container + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void configurePostgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + + @Autowired private TestEntityManager entityManager; + @SpyBean private SkillSuiteRepository suiteRepository; + @Autowired private SkillSuiteVersionRepository suiteVersionRepository; + @Autowired private SkillSuiteVersionMemberRepository suiteMemberRepository; + @Autowired private ReviewTaskRepository reviewTaskRepository; + @Autowired private NamespaceRepository namespaceRepository; + @Autowired private AuditLogRepository auditLogRepository; + @Autowired private TransactionTemplate transactionTemplate; + @Autowired private SkillSuiteLifecycleService service; + + @MockBean private SkillSuitePublicationValidator publicationValidator; + @SpyBean private AuditLogService auditLogService; + + private void persistUsers(String ownerId, String authorId) { + entityManager.persist(new UserAccount(ownerId, "Owner", null, null)); + entityManager.persist(new UserAccount(authorId, "Author", null, null)); + entityManager.flush(); + } + + @Test + void hardDeleteRemovesOnlySuiteOwnedGraphAndKeepsAuditAndMemberSkill() { + persistUsers("owner", "author"); + Namespace namespace = entityManager.persistFlushFind( + new Namespace("delete-suite-team", "Delete Suite Team", "owner")); + Skill memberSkill = entityManager.persistFlushFind( + new Skill(namespace.getId(), "member-skill", "author", SkillVisibility.PUBLIC)); + SkillVersion memberVersion = entityManager.persistFlushFind( + new SkillVersion(memberSkill.getId(), "1.0.0", "author")); + + SuiteGraph target = persistSuiteGraph( + namespace, memberSkill, memberVersion, "target-suite", "author"); + SuiteGraph retained = persistSuiteGraph( + namespace, memberSkill, memberVersion, "retained-suite", "author"); + ReviewTask skillReview = new ReviewTask( + memberVersion.getId(), memberSkill.getId(), namespace.getId(), "1.0.0", "author"); + skillReview.setStatus(ReviewTaskStatus.APPROVED); + skillReview = entityManager.persistFlushFind(skillReview); + entityManager.flush(); + + service.delete(target.suite().getId(), ownerContext(namespace.getId(), "owner")); + entityManager.flush(); + entityManager.clear(); + + assertThat(suiteRepository.findById(target.suite().getId())).isEmpty(); + assertThat(suiteVersionRepository.findById(target.version().getId())).isEmpty(); + assertThat(suiteMemberRepository.findBySuiteVersionIdOrderByPosition(target.version().getId())).isEmpty(); + assertThat(reviewTaskRepository.findById(target.reviewTask().getId())).isEmpty(); + + assertThat(suiteRepository.findById(retained.suite().getId())).isPresent(); + assertThat(suiteMemberRepository.findBySuiteVersionIdOrderByPosition(retained.version().getId())) + .extracting(SkillSuiteVersionMember::getId) + .containsExactly(retained.member().getId()); + assertThat(reviewTaskRepository.findById(retained.reviewTask().getId())).isPresent(); + assertThat(reviewTaskRepository.findById(skillReview.getId())).isPresent(); + assertThat(entityManager.find(Skill.class, memberSkill.getId())).isNotNull(); + assertThat(entityManager.find(SkillVersion.class, memberVersion.getId())).isNotNull(); + + assertThat(auditLogRepository.search( + "owner", "DELETE_SKILL_SUITE", PageRequest.of(0, 10))).anySatisfy(log -> { + assertThat(log.getAction()).isEqualTo("DELETE_SKILL_SUITE"); + assertThat(log.getTargetType()).isEqualTo("SKILL_SUITE"); + assertThat(log.getTargetId()).isEqualTo(target.suite().getId()); + }); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void hardDeleteRollsBackReviewCleanupWhenSuiteDeleteFails() { + RollbackFixture fixture = transactionTemplate.execute(status -> { + persistUsers("rollback-owner", "rollback-author"); + Namespace namespace = entityManager.persistFlushFind( + new Namespace("rollback-suite-team", "Rollback Suite Team", "rollback-owner")); + Skill memberSkill = entityManager.persistFlushFind( + new Skill(namespace.getId(), "rollback-member", "rollback-author", SkillVisibility.PUBLIC)); + SkillVersion memberVersion = entityManager.persistFlushFind( + new SkillVersion(memberSkill.getId(), "1.0.0", "rollback-author")); + SuiteGraph graph = persistSuiteGraph( + namespace, memberSkill, memberVersion, "rollback-suite", "rollback-author"); + return new RollbackFixture( + namespace.getId(), graph.suite().getId(), graph.version().getId(), + graph.member().getId(), graph.reviewTask().getId()); + }); + + doThrow(new DataIntegrityViolationException("forced Suite delete failure")) + .when(suiteRepository).delete(argThat( + suite -> suite.getId().equals(fixture.suiteId()))); + + assertThatThrownBy(() -> service.delete( + fixture.suiteId(), ownerContext(fixture.namespaceId(), "rollback-owner"))) + .isInstanceOf(DataIntegrityViolationException.class); + + assertThat(suiteRepository.findById(fixture.suiteId())).isPresent(); + assertThat(suiteVersionRepository.findById(fixture.versionId())).isPresent(); + assertThat(suiteMemberRepository.findBySuiteVersionIdOrderByPosition(fixture.versionId())) + .extracting(SkillSuiteVersionMember::getId) + .containsExactly(fixture.memberId()); + assertThat(reviewTaskRepository.findById(fixture.reviewTaskId())).isPresent(); + assertThat(auditLogRepository.search( + "rollback-owner", "DELETE_SKILL_SUITE", PageRequest.of(0, 10))).isEmpty(); + } + + @Test + void rejectedSuiteCanBeReopenedAndResubmittedWithoutLosingReviewHistory() { + persistUsers("review-owner", "review-author"); + Namespace namespace = entityManager.persistFlushFind( + new Namespace("review-history-team", "Review History Team", "review-owner")); + SkillSuite suite = entityManager.persistFlushFind( + new SkillSuite(namespace.getId(), "review-history-suite", "Review History Suite", "review-author")); + SkillSuiteVersion version = new SkillSuiteVersion( + suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "review-author"); + version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + version = entityManager.persistFlushFind(version); + ReviewTask firstRound = entityManager.persistFlushFind(ReviewTask.forSuiteVersion( + version.getId(), suite.getId(), namespace.getId(), version.getVersion(), "review-author")); + + service.rejectReview( + firstRound.getId(), "Please update the member description", + ownerContext(namespace.getId(), "review-owner")); + service.reopenRejected( + suite.getId(), version.getId(), authorContext(namespace.getId(), "review-author")); + ReviewTask secondRound = service.submitForReview( + suite.getId(), version.getId(), authorContext(namespace.getId(), "review-author")); + entityManager.flush(); + entityManager.clear(); + + assertThat(reviewTaskRepository + .findBySubmittedByAndSubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc( + "review-author", firstRound.getSubjectType(), suite.getId(), version.getVersion())) + .satisfiesExactly( + review -> { + assertThat(review.getId()).isEqualTo(secondRound.getId()); + assertThat(review.getStatus()).isEqualTo(ReviewTaskStatus.PENDING); + }, + review -> { + assertThat(review.getId()).isEqualTo(firstRound.getId()); + assertThat(review.getStatus()).isEqualTo(ReviewTaskStatus.REJECTED); + assertThat(review.getReviewedBy()).isEqualTo("review-owner"); + assertThat(review.getReviewComment()).isEqualTo("Please update the member description"); + }); + assertThat(suiteVersionRepository.findById(version.getId())) + .get() + .extracting(SkillSuiteVersion::getStatus) + .isEqualTo(SkillSuiteVersionStatus.PENDING_REVIEW); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void approveReviewRollsBackTheDecisionAndPublicationWhenAuditFails() { + ApprovalRollbackFixture fixture = transactionTemplate.execute(status -> { + persistUsers("approval-owner", "approval-author"); + Namespace namespace = entityManager.persistFlushFind( + new Namespace("approval-rollback-team", "Approval Rollback Team", "approval-owner")); + SkillSuite suite = entityManager.persistFlushFind( + new SkillSuite(namespace.getId(), "approval-rollback-suite", "Approval Suite", "approval-author")); + SkillSuiteVersion version = new SkillSuiteVersion( + suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "approval-author"); + version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + version = entityManager.persistFlushFind(version); + ReviewTask review = entityManager.persistFlushFind(ReviewTask.forSuiteVersion( + version.getId(), suite.getId(), namespace.getId(), version.getVersion(), "approval-author")); + return new ApprovalRollbackFixture(namespace.getId(), suite.getId(), version.getId(), review.getId()); + }); + + doThrow(new IllegalStateException("forced audit failure after publication")) + .when(auditLogService).record( + eq("approval-owner"), eq("APPROVE_SKILL_SUITE_REVIEW"), + eq("REVIEW_TASK"), eq(fixture.reviewTaskId()), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + + assertThatThrownBy(() -> service.approveReview( + fixture.reviewTaskId(), "Approved", + ownerContext(fixture.namespaceId(), "approval-owner"))) + .isInstanceOf(IllegalStateException.class); + + transactionTemplate.executeWithoutResult(status -> { + ReviewTask review = reviewTaskRepository.findById(fixture.reviewTaskId()).orElseThrow(); + SkillSuiteVersion version = suiteVersionRepository.findById(fixture.versionId()).orElseThrow(); + SkillSuite suite = suiteRepository.findById(fixture.suiteId()).orElseThrow(); + assertThat(review.getStatus()).isEqualTo(ReviewTaskStatus.PENDING); + assertThat(review.getReviewedBy()).isNull(); + assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.PENDING_REVIEW); + assertThat(suite.getLatestVersionId()).isNull(); + assertThat(auditLogRepository.search( + "approval-owner", "APPROVE_SKILL_SUITE_REVIEW", PageRequest.of(0, 10))).isEmpty(); + }); + } + + private SuiteGraph persistSuiteGraph( + Namespace namespace, + Skill memberSkill, + SkillVersion memberVersion, + String slug, + String authorId) { + SkillSuite suite = entityManager.persistFlushFind( + new SkillSuite(namespace.getId(), slug, slug, authorId)); + SkillSuiteVersion version = new SkillSuiteVersion( + suite.getId(), "1.0.0", SkillVisibility.PUBLIC, authorId); + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + version = entityManager.persistFlushFind(version); + SkillSuiteVersionMember member = entityManager.persistFlushFind(new SkillSuiteVersionMember( + version.getId(), + new SkillSuiteMemberSelection( + memberSkill.getId(), memberVersion.getId(), namespace.getSlug(), + memberSkill.getSlug(), memberVersion.getVersion(), "a".repeat(64)), + 0)); + ReviewTask reviewTask = ReviewTask.forSuiteVersion( + version.getId(), suite.getId(), namespace.getId(), version.getVersion(), authorId); + reviewTask.setStatus(ReviewTaskStatus.APPROVED); + reviewTask = entityManager.persistFlushFind(reviewTask); + return new SuiteGraph(suite, version, member, reviewTask); + } + + private SkillSuiteActionContext ownerContext(Long namespaceId, String ownerId) { + return new SkillSuiteActionContext( + ownerId, + Map.of(namespaceId, NamespaceRole.OWNER), + Set.of(), + "delete-suite-request", + "127.0.0.1", + "integration-test"); + } + + private SkillSuiteActionContext authorContext(Long namespaceId, String authorId) { + return new SkillSuiteActionContext( + authorId, + Map.of(namespaceId, NamespaceRole.MEMBER), + Set.of(), + "suite-review-request", + "127.0.0.1", + "integration-test"); + } + + private record SuiteGraph( + SkillSuite suite, + SkillSuiteVersion version, + SkillSuiteVersionMember member, + ReviewTask reviewTask) {} + + private record RollbackFixture( + Long namespaceId, + Long suiteId, + Long versionId, + Long memberId, + Long reviewTaskId) {} + + private record ApprovalRollbackFixture( + Long namespaceId, + Long suiteId, + Long versionId, + Long reviewTaskId) {} + + @TestConfiguration + static class ClockConfiguration { + + @Bean + Clock clock() { + return Clock.fixed(Instant.parse("2026-09-08T06:00:00Z"), ZoneOffset.UTC); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java new file mode 100644 index 00000000..5f0ce92b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java @@ -0,0 +1,280 @@ +package com.iflytek.skillhub.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteMemberSelection; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMemberRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionStatus; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.search.postgres.PostgresResourceDiscoveryQueryService; +import com.iflytek.skillhub.service.ResourceDiscoveryAppService; +import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Import({PostgresResourceDiscoveryQueryService.class, ResourceDiscoveryAppService.class, + MySkillSuiteQueryRepository.class}) +@Testcontainers +@TestPropertySource(properties = { + "spring.flyway.enabled=true", + "spring.jpa.hibernate.ddl-auto=validate" +}) +class SuiteDiscoveryIntegrationTest { + + @Container + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void configurePostgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + } + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private ResourceDiscoveryAppService appService; + + @Autowired + private MySkillSuiteQueryRepository mySuiteRepository; + + @Autowired + private SkillSuiteVersionMemberRepository suiteMemberRepository; + + @BeforeEach + void seedReferencedUsers() { + entityManager.persist(new UserAccount("owner", "Owner", null, null)); + entityManager.persist(new UserAccount("author", "Author", null, null)); + entityManager.persist(new UserAccount("other-author", "Other Author", null, null)); + entityManager.flush(); + } + + @Test + void returnsSkillAndSuiteWithTheSameCoordinateAsDistinctResourceTypes() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("team-ai", "AI Team", "owner")); + Skill skill = new Skill(namespace.getId(), "starter", "owner", SkillVisibility.PUBLIC); + skill.setDisplayName("Starter Skill"); + skill.setSummary("A standalone skill"); + skill = entityManager.persistFlushFind(skill); + + SkillVersion skillVersion = new SkillVersion(skill.getId(), "1.2.0", "owner"); + skillVersion.setStatus(SkillVersionStatus.PUBLISHED); + skillVersion.setDownloadReady(true); + skillVersion.setPublishedAt(Instant.parse("2026-09-01T10:00:00Z")); + skillVersion = entityManager.persistFlushFind(skillVersion); + skill.setLatestVersionId(skillVersion.getId()); + entityManager.persistAndFlush(skill); + + SkillSuite suite = new SkillSuite(namespace.getId(), "starter", "Mutable container name", "owner"); + suite.setSummary("Mutable container summary"); + suite = entityManager.persistFlushFind(suite); + SkillSuiteVersion suiteVersion = new SkillSuiteVersion( + suite.getId(), "2.0.0", "Published snapshot name", "Published snapshot summary", + SkillVisibility.PUBLIC, "owner"); + suiteVersion.setStatus(SkillSuiteVersionStatus.PUBLISHED); + suiteVersion.setPublishedAt(Instant.parse("2026-09-02T10:00:00Z")); + suiteVersion = entityManager.persistFlushFind(suiteVersion); + entityManager.persist(new SkillSuiteVersionMember( + suiteVersion.getId(), + new SkillSuiteMemberSelection( + skill.getId(), skillVersion.getId(), namespace.getSlug(), + skill.getSlug(), skillVersion.getVersion(), "a".repeat(64)), + 0)); + suite.setLatestVersionId(suiteVersion.getId()); + entityManager.persistAndFlush(suite); + entityManager.clear(); + + var result = appService.search("starter", "team-ai", "", "relevance", 0, 20, Set.of()); + + assertThat(result.total()).isEqualTo(2); + assertThat(result.items()).extracting(item -> item.resourceType()) + .containsExactlyInAnyOrder("SKILL", "SUITE"); + assertThat(result.items()).allSatisfy(item -> { + assertThat(item.namespace()).isEqualTo("team-ai"); + assertThat(item.slug()).isEqualTo("starter"); + assertThat(item.available()).isTrue(); + }); + assertThat(result.items()).extracting(item -> item.detailUrl()) + .containsExactlyInAnyOrder("/space/team-ai/starter", "/suite/team-ai/starter"); + assertThat(result.items()).filteredOn(item -> "SUITE".equals(item.resourceType())) + .singleElement() + .satisfies(item -> { + assertThat(item.displayName()).isEqualTo("Published snapshot name"); + assertThat(item.summary()).isEqualTo("Published snapshot summary"); + }); + } + + @Test + void exposesNamespaceOnlyResourcesOnlyToNamespaceMembers() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("private-team", "Private Team", "owner")); + Skill skill = entityManager.persistFlushFind( + new Skill(namespace.getId(), "internal", "owner", SkillVisibility.NAMESPACE_ONLY)); + SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", "owner"); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version = entityManager.persistFlushFind(version); + skill.setLatestVersionId(version.getId()); + entityManager.persistAndFlush(skill); + entityManager.clear(); + + assertThat(appService.search("", "", "SKILL", "newest", 0, 20, Set.of()).items()) + .isEmpty(); + assertThat(appService.search( + "", "", "SKILL", "newest", 0, 20, Set.of(namespace.getId())).items()) + .singleElement() + .satisfies(item -> assertThat(item.slug()).isEqualTo("internal")); + } + + @Test + void dashboardReturnsTheLatestVersionTheCallerCanManage() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("managed-team", "Managed Team", "owner")); + SkillSuite suite = entityManager.persistFlushFind( + new SkillSuite(namespace.getId(), "writers", "Writers", "author")); + SkillSuiteVersion ownVersion = entityManager.persistFlushFind( + new SkillSuiteVersion(suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "author")); + SkillSuiteVersion adminVersion = entityManager.persistFlushFind( + new SkillSuiteVersion( + suite.getId(), "2.0.0", "Writers 2", "Second draft", + SkillVisibility.PUBLIC, "other-author")); + entityManager.clear(); + + var authorPage = mySuiteRepository.findMine( + "author", Set.of(namespace.getId()), Set.of(), "writers", 0, 20); + assertThat(authorPage.items()).singleElement().satisfies(item -> { + assertThat(item.versionId()).isEqualTo(adminVersion.getId()); + assertThat(item.version()).isEqualTo("2.0.0"); + }); + assertThat(mySuiteRepository.findMine( + "other-author", Set.of(namespace.getId()), Set.of(), "writers", 0, 20).items()) + .isEmpty(); + + var adminPage = mySuiteRepository.findMine( + "owner", Set.of(namespace.getId()), Set.of(namespace.getId()), "", 0, 20); + assertThat(adminPage.items()).singleElement().satisfies(item -> { + assertThat(item.versionId()).isEqualTo(adminVersion.getId()); + assertThat(item.version()).isEqualTo("2.0.0"); + assertThat(item.displayName()).isEqualTo("Writers 2"); + assertThat(item.summary()).isEqualTo("Second draft"); + }); + } + + @Test + void hardDeletingMemberSkillPreservesThePublishedSuiteSnapshot() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("snapshot-team", "Snapshot Team", "owner")); + Skill skill = entityManager.persistFlushFind( + new Skill(namespace.getId(), "archived-writer", "owner", SkillVisibility.PUBLIC)); + SkillVersion skillVersion = new SkillVersion(skill.getId(), "3.1.4", "owner"); + skillVersion.setStatus(SkillVersionStatus.PUBLISHED); + skillVersion = entityManager.persistFlushFind(skillVersion); + SkillSuite suite = entityManager.persistFlushFind( + new SkillSuite(namespace.getId(), "historical-pack", "Historical Pack", "owner")); + SkillSuiteVersion suiteVersion = new SkillSuiteVersion( + suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "owner"); + suiteVersion.setStatus(SkillSuiteVersionStatus.PUBLISHED); + suiteVersion = entityManager.persistFlushFind(suiteVersion); + SkillSuiteVersionMember member = entityManager.persistFlushFind( + new SkillSuiteVersionMember( + suiteVersion.getId(), + new SkillSuiteMemberSelection( + skill.getId(), skillVersion.getId(), "snapshot-team", + "archived-writer", "3.1.4", "sha512:" + "b".repeat(128)), + 0)); + + entityManager.getEntityManager().createNativeQuery("DELETE FROM skill_version WHERE id = :id") + .setParameter("id", skillVersion.getId()) + .executeUpdate(); + entityManager.getEntityManager().createNativeQuery("DELETE FROM skill WHERE id = :id") + .setParameter("id", skill.getId()) + .executeUpdate(); + entityManager.flush(); + entityManager.clear(); + + Object[] snapshot = (Object[]) entityManager.getEntityManager().createNativeQuery(""" + SELECT skill_id, skill_version_id, namespace_slug_snapshot, + skill_slug_snapshot, skill_version_snapshot, fingerprint_snapshot + FROM skill_suite_version_member + WHERE id = :id + """).setParameter("id", member.getId()).getSingleResult(); + assertThat(snapshot[0]).isNull(); + assertThat(snapshot[1]).isNull(); + assertThat(snapshot[2]).isEqualTo("snapshot-team"); + assertThat(snapshot[3]).isEqualTo("archived-writer"); + assertThat(snapshot[4]).isEqualTo("3.1.4"); + assertThat(snapshot[5]).isEqualTo("sha512:" + "b".repeat(128)); + } + + @Test + void replacesSuiteMembersAtTheSamePositionWithoutAUniqueConstraintConflict() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("replace-team", "Replace Team", "owner")); + Skill skill = entityManager.persistFlushFind( + new Skill(namespace.getId(), "replacement", "owner", SkillVisibility.PUBLIC)); + SkillVersion firstVersion = entityManager.persistFlushFind( + new SkillVersion(skill.getId(), "1.0.0", "owner")); + SkillVersion secondVersion = entityManager.persistFlushFind( + new SkillVersion(skill.getId(), "2.0.0", "owner")); + SkillSuite suite = entityManager.persistFlushFind( + new SkillSuite(namespace.getId(), "replaceable", "Replaceable", "owner")); + SkillSuiteVersion suiteVersion = entityManager.persistFlushFind( + new SkillSuiteVersion(suite.getId(), "1.0.0", SkillVisibility.PRIVATE, "owner")); + entityManager.persistAndFlush(new SkillSuiteVersionMember( + suiteVersion.getId(), + new SkillSuiteMemberSelection( + skill.getId(), firstVersion.getId(), "replace-team", + "replacement", "1.0.0", "sha256:" + "a".repeat(64)), + 0)); + entityManager.clear(); + + suiteMemberRepository.deleteBySuiteVersionId(suiteVersion.getId()); + suiteMemberRepository.saveAll(List.of(new SkillSuiteVersionMember( + suiteVersion.getId(), + new SkillSuiteMemberSelection( + skill.getId(), secondVersion.getId(), "replace-team", + "replacement", "2.0.0", "sha256:" + "b".repeat(64)), + 0))); + entityManager.clear(); + + assertThat(suiteMemberRepository.findBySuiteVersionIdOrderByPosition(suiteVersion.getId())) + .singleElement() + .satisfies(member -> { + assertThat(member.getPosition()).isZero(); + assertThat(member.getSkillVersionId()).isEqualTo(secondVersion.getId()); + assertThat(member.getSkillVersionSnapshot()).isEqualTo("2.0.0"); + }); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java index 0d019550..bdaa0f82 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java @@ -14,6 +14,9 @@ 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.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import java.time.Instant; @@ -39,6 +42,12 @@ class JpaGovernanceQueryRepositoryTest { @Mock private UserAccountRepository userAccountRepository; + @Mock + private SkillSuiteRepository suiteRepository; + + @Mock + private SkillSuiteVersionRepository suiteVersionRepository; + private JpaGovernanceQueryRepository repository; @BeforeEach @@ -47,7 +56,9 @@ class JpaGovernanceQueryRepositoryTest { skillRepository, skillVersionRepository, namespaceRepository, - userAccountRepository + userAccountRepository, + suiteRepository, + suiteVersionRepository ); } @@ -112,6 +123,31 @@ class JpaGovernanceQueryRepositoryTest { }); } + @Test + void getReviewTaskResponses_assemblesTypedSuiteReviewWithoutSkillColumns() { + ReviewTask task = ReviewTask.forSuiteVersion(301L, 201L, 11L, "1.0.0", "submitter"); + setField(task, "id", 7L); + SkillSuite suite = new SkillSuite(11L, "starter-pack", "Starter Pack", "submitter"); + setField(suite, "id", 201L); + Namespace namespace = new Namespace("team-a", "Team A", "submitter"); + setField(namespace, "id", 11L); + UserAccount submitter = new UserAccount("submitter", "Submitter", "submitter@example.com", null); + + given(suiteVersionRepository.findByIdIn(List.of(301L))).willReturn(List.of()); + given(suiteRepository.findByIdIn(List.of(201L))).willReturn(List.of(suite)); + given(namespaceRepository.findByIdIn(List.of(11L))).willReturn(List.of(namespace)); + given(userAccountRepository.findByIdIn(List.of("submitter"))).willReturn(List.of(submitter)); + + var response = repository.getReviewTaskResponses(List.of(task)).get(0); + + assertThat(response.subjectType()).isEqualTo("SUITE_VERSION"); + assertThat(response.subjectId()).isEqualTo(201L); + assertThat(response.subjectVersionId()).isEqualTo(301L); + assertThat(response.subjectSlug()).isEqualTo("starter-pack"); + assertThat(response.skillVersionId()).isNull(); + assertThat(response.skillSlug()).isNull(); + } + @Test void getPromotionResponses_assemblesPromotionReadModel() { PromotionRequest request = new PromotionRequest(201L, 101L, 12L, "submitter"); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java index b08edf39..343ef8a2 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java @@ -5,6 +5,8 @@ import static org.assertj.core.api.Assertions.assertThat; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillVisibility; import java.time.Instant; @@ -133,6 +135,33 @@ class JpaReviewProgressQueryRepositoryTest { assertThat(searchMiss.statusCounts().rejected()).isZero(); } + @Test + void includesSuiteAttemptsWithoutRequiringLegacySkillColumns() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("team-suite-review", "Suite Review Team", "owner")); + SkillSuite suite = entityManager.persistFlushFind( + new SkillSuite(namespace.getId(), "starter-pack", "Starter Pack", "author-1")); + SkillSuiteVersion suiteVersion = entityManager.persistFlushFind( + new SkillSuiteVersion(suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "author-1")); + ReviewTask task = ReviewTask.forSuiteVersion( + suiteVersion.getId(), suite.getId(), namespace.getId(), suiteVersion.getVersion(), "author-1"); + entityManager.persist(task); + entityManager.flush(); + entityManager.clear(); + + var progress = repository.findMyProgress("author-1", null, "STARTER", 0, 20); + + assertThat(progress.items()).singleElement().satisfies(item -> { + assertThat(item.skillId()).isNull(); + assertThat(item.skillSlug()).isNull(); + assertThat(item.subjectType()).isEqualTo("SUITE_VERSION"); + assertThat(item.subjectId()).isEqualTo(suite.getId()); + assertThat(item.subjectVersionId()).isEqualTo(suiteVersion.getId()); + assertThat(item.subjectSlug()).isEqualTo("starter-pack"); + }); + assertThat(progress.statusCounts().pending()).isEqualTo(1); + } + private void persistAttempt( Skill skill, Namespace namespace, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewPortalAppServiceTest.java new file mode 100644 index 00000000..5c7f6905 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewPortalAppServiceTest.java @@ -0,0 +1,131 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.ReviewService; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteActionContext; +import com.iflytek.skillhub.domain.suite.SkillSuiteLifecycleService; +import com.iflytek.skillhub.dto.ReviewTaskResponse; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.repository.GovernanceQueryRepository; +import com.iflytek.skillhub.repository.ReviewProgressQueryRepository; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ReviewPortalAppServiceTest { + + @Mock private ReviewService reviewService; + @Mock private ReviewTaskRepository reviewTaskRepository; + @Mock private NamespaceRepository namespaceRepository; + @Mock private GovernanceQueryRepository governanceQueryRepository; + @Mock private ReviewProgressQueryRepository reviewProgressQueryRepository; + @Mock private RbacService rbacService; + @Mock private AuditLogService auditLogService; + @Mock private RequestIdAccessor requestIdAccessor; + @Mock private SkillSuiteLifecycleService suiteLifecycleService; + + private ReviewPortalAppService service; + + @BeforeEach + void setUp() { + service = new ReviewPortalAppService( + reviewService, + reviewTaskRepository, + namespaceRepository, + governanceQueryRepository, + reviewProgressQueryRepository, + rbacService, + auditLogService, + requestIdAccessor, + suiteLifecycleService); + } + + @Test + void approveReviewDispatchesSuiteSubjectsToSuiteLifecycle() { + ReviewTask task = suiteTask(91L); + ReviewTaskResponse response = response(91L); + when(reviewTaskRepository.findById(91L)).thenReturn(Optional.of(task)); + when(rbacService.getUserRoleCodes("reviewer")).thenReturn(Set.of("SKILL_ADMIN")); + when(requestIdAccessor.current()).thenReturn("request-1"); + when(suiteLifecycleService.approveReview( + org.mockito.ArgumentMatchers.eq(91L), + org.mockito.ArgumentMatchers.eq("looks good"), + org.mockito.ArgumentMatchers.any(SkillSuiteActionContext.class))) + .thenReturn(task); + when(governanceQueryRepository.getReviewTaskResponse(task)).thenReturn(response); + + ReviewTaskResponse actual = service.approveReview( + 91L, "looks good", "reviewer", Map.of(7L, NamespaceRole.ADMIN), + new AuditRequestContext("127.0.0.1", "test")); + + assertThat(actual).isSameAs(response); + ArgumentCaptor context = ArgumentCaptor.forClass(SkillSuiteActionContext.class); + verify(suiteLifecycleService).approveReview( + org.mockito.ArgumentMatchers.eq(91L), + org.mockito.ArgumentMatchers.eq("looks good"), + context.capture()); + assertThat(context.getValue().requestId()).isEqualTo("request-1"); + assertThat(context.getValue().platformRoles()).containsExactly("SKILL_ADMIN"); + verify(reviewService, never()).approveReview( + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyMap(), + org.mockito.ArgumentMatchers.anySet()); + } + + @Test + void withdrawReviewDispatchesSuiteSubjectsWithoutSkillVersionAccess() { + ReviewTask task = suiteTask(92L); + when(reviewTaskRepository.findById(92L)).thenReturn(Optional.of(task)); + when(rbacService.getUserRoleCodes("author")).thenReturn(Set.of()); + + service.withdrawReview(92L, "author", Map.of(7L, NamespaceRole.MEMBER), null); + + verify(suiteLifecycleService).withdrawReview( + org.mockito.ArgumentMatchers.eq(92L), + org.mockito.ArgumentMatchers.any(SkillSuiteActionContext.class)); + verify(reviewService, never()).withdrawReview( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString()); + } + + private ReviewTask suiteTask(Long id) { + ReviewTask task = ReviewTask.forSuiteVersion(31L, 21L, 7L, "1.0.0", "author"); + setField(task, "id", id); + return task; + } + + private ReviewTaskResponse response(Long id) { + return new ReviewTaskResponse( + id, null, "team", null, "1.0.0", "PENDING", "author", "Author", + null, null, null, null, null, + "SUITE_VERSION", 21L, 31L, "starter-pack"); + } + + private void setField(Object target, String fieldName, Object value) { + try { + var field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java new file mode 100644 index 00000000..9932fb61 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java @@ -0,0 +1,333 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteAllowedAction; +import com.iflytek.skillhub.domain.suite.SkillSuiteDraftService; +import com.iflytek.skillhub.domain.suite.SkillSuiteInstallMetricsService; +import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperation; +import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperationRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteLifecycleService; +import com.iflytek.skillhub.domain.suite.SkillSuiteMemberAvailability; +import com.iflytek.skillhub.domain.suite.SkillSuiteMemberState; +import com.iflytek.skillhub.domain.suite.SkillSuiteQueryService; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember; +import com.iflytek.skillhub.repository.SkillSuiteCandidateQueryRepository; +import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +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.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.Set; + +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.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class SkillSuiteAppServiceTest { + + @Mock private NamespaceRepository namespaceRepository; + @Mock private SkillQueryService skillQueryService; + @Mock private SkillSuiteDraftService draftService; + @Mock private SkillSuiteLifecycleService lifecycleService; + @Mock private SkillSuiteQueryService queryService; + @Mock private SkillSuiteInstallMetricsService installMetricsService; + @Mock private SkillSuiteInstallOperationRepository installOperationRepository; + @Mock private AuditLogService auditLogService; + @Mock private RequestIdAccessor requestIdAccessor; + @Mock private SkillSuiteCandidateQueryRepository candidateQueryRepository; + @Mock private MySkillSuiteQueryRepository mySkillSuiteQueryRepository; + @Mock private HttpServletRequest request; + private SkillSuiteAppService service; + private Namespace namespace; + private SkillSuite suite; + private SkillSuiteVersion version; + private SkillSuiteVersionMember firstMember; + private SkillSuiteVersionMember secondMember; + + @BeforeEach + void setUp() { + service = new SkillSuiteAppService( + namespaceRepository, skillQueryService, draftService, lifecycleService, + queryService, installMetricsService, installOperationRepository, auditLogService, + requestIdAccessor, + candidateQueryRepository, mySkillSuiteQueryRepository); + namespace = new Namespace("global", "Global", "admin"); + setField(namespace, "id", 1L); + suite = new SkillSuite(1L, "starter", "Starter", "user-1"); + setField(suite, "id", 7L); + version = new SkillSuiteVersion(7L, "1.0.0", SkillVisibility.PUBLIC, "user-1"); + setField(version, "id", 70L); + version.setOverview("## Install in order"); + firstMember = member(11L, 101L, "first", "1.0.0", "sha256:first", 0); + secondMember = member(12L, 102L, "second", "2.0.0", "sha256:second", 1); + version.setEntrySkillVersionId(101L); + } + + @Test + void createInstallPlan_resolvesEveryExactMemberBeforeRecordingMetrics() { + SkillSuiteQueryService.Detail detail = detail(true); + given(queryService.getDetail("global", "starter", null, "user-1", Map.of(), Set.of())) + .willReturn(detail); + given(skillQueryService.resolveVersion( + "global", "first", "1.0.0", null, "sha256:first", "user-1", Map.of())) + .willReturn(resolved(11L, 101L, "first", "1.0.0", "sha256:first")); + given(skillQueryService.resolveVersion( + "global", "second", "2.0.0", null, "sha256:second", "user-1", Map.of())) + .willReturn(resolved(12L, 102L, "second", "2.0.0", "sha256:second")); + given(installOperationRepository.insertIfAbsent( + any(), org.mockito.ArgumentMatchers.eq("retry-1"), + org.mockito.ArgumentMatchers.eq("user:user-1"), + org.mockito.ArgumentMatchers.eq(7L), org.mockito.ArgumentMatchers.eq(70L))).willReturn(1); + + var result = service.createInstallPlan( + "global", "starter", null, "user-1", Map.of(), Set.of(), "retry-1", request); + + assertThat(result.operationId()).isNotBlank().isNotEqualTo("retry-1"); + assertThat(result.fingerprint()).startsWith("sha256:"); + assertThat(result.members()).extracting(member -> member.slug()) + .containsExactly("first", "second"); + assertThat(result.members()).extracting(member -> member.entry()) + .containsExactly(true, false); + verify(installMetricsService).recordIssuedPlan(7L); + verify(auditLogService, times(3)).record( + any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void createInstallPlan_replaysTheCapturedVersionWithoutDuplicateMetrics() { + SkillSuiteQueryService.Detail detail = detail(true); + given(queryService.getDetailByVersionId( + "global", "starter", 70L, "user-1", Map.of(), Set.of())).willReturn(detail); + given(skillQueryService.resolveVersion( + "global", "first", "1.0.0", null, "sha256:first", "user-1", Map.of())) + .willReturn(resolved(11L, 101L, "first", "1.0.0", "sha256:first")); + given(skillQueryService.resolveVersion( + "global", "second", "2.0.0", null, "sha256:second", "user-1", Map.of())) + .willReturn(resolved(12L, 102L, "second", "2.0.0", "sha256:second")); + SkillSuiteInstallOperation operation = org.mockito.Mockito.mock(SkillSuiteInstallOperation.class); + given(operation.getOperationId()).willReturn("server-operation-1"); + given(operation.getSuiteId()).willReturn(7L); + given(operation.getSuiteVersionId()).willReturn(70L); + given(installOperationRepository.findByClientRequestIdAndActorKey("retry-1", "user:user-1")) + .willReturn(java.util.Optional.of(operation)); + + var result = service.createInstallPlan( + "global", "starter", null, "user-1", Map.of(), Set.of(), "retry-1", request); + + assertThat(result.operationId()).isEqualTo("server-operation-1"); + assertThat(result.version()).isEqualTo("1.0.0"); + verify(installOperationRepository, never()).insertIfAbsent(any(), any(), any(), any(), any()); + verify(installMetricsService, never()).recordIssuedPlan(any()); + verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void createInstallPlan_replaysTheConcurrentWinnerWithoutDuplicateMetrics() { + SkillSuiteQueryService.Detail detail = detail(true); + given(queryService.getDetail("global", "starter", null, "user-1", Map.of(), Set.of())) + .willReturn(detail); + given(queryService.getDetailByVersionId( + "global", "starter", 70L, "user-1", Map.of(), Set.of())).willReturn(detail); + given(skillQueryService.resolveVersion( + "global", "first", "1.0.0", null, "sha256:first", "user-1", Map.of())) + .willReturn(resolved(11L, 101L, "first", "1.0.0", "sha256:first")); + given(skillQueryService.resolveVersion( + "global", "second", "2.0.0", null, "sha256:second", "user-1", Map.of())) + .willReturn(resolved(12L, 102L, "second", "2.0.0", "sha256:second")); + given(installOperationRepository.insertIfAbsent( + any(), org.mockito.ArgumentMatchers.eq("retry-race"), + org.mockito.ArgumentMatchers.eq("user:user-1"), + org.mockito.ArgumentMatchers.eq(7L), org.mockito.ArgumentMatchers.eq(70L))).willReturn(0); + SkillSuiteInstallOperation operation = org.mockito.Mockito.mock(SkillSuiteInstallOperation.class); + given(operation.getOperationId()).willReturn("concurrent-operation"); + given(operation.getSuiteId()).willReturn(7L); + given(operation.getSuiteVersionId()).willReturn(70L); + given(installOperationRepository.findByClientRequestIdAndActorKey("retry-race", "user:user-1")) + .willReturn(java.util.Optional.empty(), java.util.Optional.of(operation)); + + var result = service.createInstallPlan( + "global", "starter", null, "user-1", Map.of(), Set.of(), "retry-race", request); + + assertThat(result.operationId()).isEqualTo("concurrent-operation"); + verify(installMetricsService, never()).recordIssuedPlan(any()); + verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void createInstallPlan_whenAnyMemberCannotBeResolved_recordsNothingAndHidesMemberDetails() { + SkillSuiteQueryService.Detail detail = detail(true); + given(queryService.getDetail("global", "starter", null, "user-1", Map.of(), Set.of())) + .willReturn(detail); + given(skillQueryService.resolveVersion( + "global", "first", "1.0.0", null, "sha256:first", "user-1", Map.of())) + .willReturn(resolved(11L, 101L, "first", "1.0.0", "sha256:first")); + given(skillQueryService.resolveVersion( + "global", "second", "2.0.0", null, "sha256:second", "user-1", Map.of())) + .willThrow(new DomainForbiddenException("error.skill.access.denied", "private-skill")); + + assertThatThrownBy(() -> service.createInstallPlan( + "global", "starter", null, "user-1", Map.of(), Set.of(), "retry-2", request)) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageNotContaining("private-skill"); + + verify(installMetricsService, never()).recordIssuedPlan(any()); + verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void createInstallPlan_rejectsDegradedSuiteBeforeResolvingMembers() { + SkillSuiteQueryService.Detail detail = detail(false); + given(queryService.getDetail("global", "starter", null, null, Map.of(), Set.of())) + .willReturn(detail); + + assertThatThrownBy(() -> service.createInstallPlan( + "global", "starter", null, null, Map.of(), Set.of(), "retry-3", request)) + .isInstanceOf(DomainBadRequestException.class); + + verify(skillQueryService, never()).resolveVersion(any(), any(), any(), any(), any(), any(), any()); + verify(installMetricsService, never()).recordIssuedPlan(any()); + } + + @Test + void createInstallPlan_rejectsAnInvalidIdempotencyKey() { + assertThatThrownBy(() -> service.createInstallPlan( + "global", "starter", null, "user-1", Map.of(), Set.of(), "not allowed!", request)) + .isInstanceOf(DomainBadRequestException.class); + + verify(installOperationRepository, never()).insertIfAbsent(any(), any(), any(), any(), any()); + verify(installMetricsService, never()).recordIssuedPlan(any()); + } + + @Test + void getDetail_exposesServerDerivedAllowedActions() { + SkillSuiteQueryService.Detail detail = detail(true); + given(queryService.getDetail( + "global", "starter", null, "user-1", Map.of(1L, NamespaceRole.MEMBER), Set.of())) + .willReturn(detail); + given(lifecycleService.allowedActions( + org.mockito.ArgumentMatchers.eq(suite), org.mockito.ArgumentMatchers.eq(version), + org.mockito.ArgumentMatchers.eq(namespace), any())) + .willReturn(Set.of(SkillSuiteAllowedAction.EDIT, SkillSuiteAllowedAction.CREATE_VERSION)); + + var result = service.getDetail( + "global", "starter", null, "user-1", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.allowedActions()) + .containsExactlyInAnyOrder(SkillSuiteAllowedAction.EDIT, SkillSuiteAllowedAction.CREATE_VERSION); + assertThat(result.suiteStatus()).isEqualTo("ACTIVE"); + assertThat(result.hidden()).isFalse(); + assertThat(result.overview()).isEqualTo("## Install in order"); + assertThat(result.members()).extracting(member -> member.displayName()) + .containsExactly("First Skill", "Second Skill"); + } + + @Test + void getDetail_hidesLiveMemberMetadataWhenTheViewerCannotReadThatSkill() { + SkillSuiteMemberState restricted = new SkillSuiteMemberState( + 11L, 101L, 1L, "Private Skill", "Private summary", NamespaceStatus.ACTIVE, + SkillVisibility.PRIVATE, SkillStatus.ACTIVE, false, + SkillVersionStatus.PUBLISHED, true, false, false); + SkillSuiteQueryService.Detail detail = new SkillSuiteQueryService.Detail( + namespace, suite, version, true, + List.of(new SkillSuiteQueryService.MemberDetail( + firstMember, restricted, SkillSuiteMemberAvailability.availableMember()))); + given(queryService.getDetail( + "global", "starter", null, "suite-author", Map.of(1L, NamespaceRole.MEMBER), Set.of())) + .willReturn(detail); + + var result = service.getDetail( + "global", "starter", null, "suite-author", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.members()).singleElement().satisfies(member -> { + assertThat(member.displayName()).isNull(); + assertThat(member.summary()).isNull(); + assertThat(member.browsable()).isFalse(); + }); + } + + private SkillSuiteQueryService.Detail detail(boolean available) { + if (!available) { + return new SkillSuiteQueryService.Detail(namespace, suite, version, false, List.of()); + } + var first = new SkillSuiteQueryService.MemberDetail( + firstMember, memberState(11L, 101L, "First Skill", "First summary"), + SkillSuiteMemberAvailability.availableMember()); + var second = new SkillSuiteQueryService.MemberDetail( + secondMember, memberState(12L, 102L, "Second Skill", "Second summary"), + SkillSuiteMemberAvailability.availableMember()); + return new SkillSuiteQueryService.Detail(namespace, suite, version, true, List.of(first, second)); + } + + private SkillSuiteMemberState memberState( + Long skillId, + Long versionId, + String displayName, + String summary + ) { + return new SkillSuiteMemberState( + skillId, versionId, 1L, displayName, summary, NamespaceStatus.ACTIVE, + SkillVisibility.PUBLIC, SkillStatus.ACTIVE, false, + SkillVersionStatus.PUBLISHED, true, false, true); + } + + private SkillSuiteVersionMember member( + Long skillId, + Long versionId, + String slug, + String memberVersion, + String fingerprint, + int position + ) { + return new SkillSuiteVersionMember( + 70L, + new com.iflytek.skillhub.domain.suite.SkillSuiteMemberSelection( + skillId, versionId, "global", slug, memberVersion, fingerprint), + position); + } + + private SkillQueryService.ResolvedVersionDTO resolved( + Long skillId, + Long versionId, + String slug, + String version, + String fingerprint + ) { + return new SkillQueryService.ResolvedVersionDTO( + skillId, "global", slug, version, versionId, fingerprint, true, + "/api/v1/skills/global/" + slug + "/versions/" + version + "/download"); + } + + private void setField(Object target, String name, Object value) { + try { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException exception) { + throw new AssertionError(exception); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java index eacdef88..47afd1ef 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java @@ -879,6 +879,13 @@ class ScanTaskConsumerTest { throw unsupported(); } + @Override + public void deleteBySubjectTypeAndSubjectId( + com.iflytek.skillhub.domain.review.ReviewSubjectType subjectType, + Long subjectId) { + throw unsupported(); + } + @Override public void delete(ReviewTask reviewTask) { this.deletedTask = reviewTask; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/IdempotencyCleanupTaskTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/IdempotencyCleanupTaskTest.java index 95f0f0ee..6e680946 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/IdempotencyCleanupTaskTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/IdempotencyCleanupTaskTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.task; import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperationRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -20,12 +21,16 @@ class IdempotencyCleanupTaskTest { @Mock private IdempotencyRecordRepository idempotencyRecordRepository; + @Mock + private SkillSuiteInstallOperationRepository suiteInstallOperationRepository; + private IdempotencyCleanupTask cleanupTask; @BeforeEach void setUp() { Clock clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); - cleanupTask = new IdempotencyCleanupTask(idempotencyRecordRepository, clock); + cleanupTask = new IdempotencyCleanupTask( + idempotencyRecordRepository, suiteInstallOperationRepository, clock); } @Test @@ -35,6 +40,8 @@ class IdempotencyCleanupTaskTest { cleanupTask.cleanupExpiredRecords(); verify(idempotencyRecordRepository).deleteExpired(any(Instant.class)); + verify(suiteInstallOperationRepository).deleteCreatedBefore( + Instant.parse("2026-03-17T00:00:00Z")); } @Test diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 23375ed9..eac1e955 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -82,6 +82,46 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/files"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/file"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/labels"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/suites/*/*"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/resources"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/me/suites"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/suites/*/*/versions"), + RouteAuthorizationPolicy.permitAll(HttpMethod.POST, "/api/v1/suites/*/*/install-plan"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/suites/member-candidates"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/versions"), + RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/v1/suites/*/versions/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/versions/*/submit"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/versions/*/publish"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/versions/*/reopen"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/versions/*/yank"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/hide"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/restore"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/archive"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/unarchive"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/suites/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/reviews/*/approve"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/reviews/*/reject"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/suites/*/*"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/resources"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/me/suites"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/suites/*/*/versions"), + RouteAuthorizationPolicy.permitAll(HttpMethod.POST, "/api/web/suites/*/*/install-plan"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/suites/member-candidates"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/versions"), + RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/web/suites/*/versions/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/versions/*/submit"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/versions/*/publish"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/versions/*/reopen"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/versions/*/yank"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/hide"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/restore"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/archive"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/unarchive"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/suites/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/reviews/*/approve"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/reviews/*/reject"), RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/id/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"), @@ -131,6 +171,38 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/suites/**"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resources"), + ApiTokenPolicy.require(HttpMethod.GET, "/api/v1/me/suites", "skill:read"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/suites/*/*/install-plan"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/versions", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.PUT, "/api/v1/suites/*/versions/*", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/versions/*/submit", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/versions/*/publish", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/versions/*/reopen", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/versions/*/yank", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/hide", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/restore", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/archive", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/unarchive", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.DELETE, "/api/v1/suites/*", "skill:delete"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/suites/**"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/resources"), + ApiTokenPolicy.require(HttpMethod.GET, "/api/web/me/suites", "skill:read"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/suites/*/*/install-plan"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/versions", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.PUT, "/api/web/suites/*/versions/*", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/versions/*/submit", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/versions/*/publish", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/versions/*/reopen", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/versions/*/yank", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/hide", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/restore", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/archive", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/unarchive", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.DELETE, "/api/web/suites/*", "skill:delete"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resolve/**"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/download"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/download/**"), @@ -173,6 +245,10 @@ public class RouteSecurityPolicyRegistry { "ANY /api/v1/auth/direct/login", "ANY /api/v1/auth/local/**", "ANY /api/v1/admin/**", + "POST /api/v1/suites/reviews/*/approve", + "POST /api/v1/suites/reviews/*/reject", + "POST /api/web/suites/reviews/*/approve", + "POST /api/web/suites/reviews/*/reject", "DELETE /api/web/skills/id/*", "DELETE /api/web/skills/*/*" ); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 11697808..c6ffd3d1 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -64,6 +64,45 @@ class RouteSecurityPolicyRegistryTest { assertTrue(allowed.allowed()); } + @Test + void suiteRoutes_keepDiscoveryPublicAndProtectMutations() { + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL, + registry.accessLevel("GET", "/api/v1/resources")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL, + registry.accessLevel("GET", "/api/web/resources")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("GET", "/api/web/me/suites")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL, + registry.accessLevel("GET", "/api/v1/suites/global/starter")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL, + registry.accessLevel("POST", "/api/v1/suites/global/starter/install-plan")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("GET", "/api/v1/suites/member-candidates")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("POST", "/api/v1/suites")); + } + + @Test + void suiteApiTokenPolicies_requirePublishScopeAndKeepReviewSessionOnly() { + assertTrue(registry.authorizeApiToken( + "GET", "/api/v1/suites/global/starter", Set.of()).allowed()); + assertTrue(registry.authorizeApiToken( + "POST", "/api/v1/suites/global/starter/install-plan", Set.of()).allowed()); + + var deniedCreate = registry.authorizeApiToken("POST", "/api/v1/suites", Set.of("skill:read")); + var allowedCreate = registry.authorizeApiToken("POST", "/api/v1/suites", Set.of("skill:publish")); + var deniedSubmit = registry.authorizeApiToken( + "POST", "/api/v1/suites/8/versions/42/submit", Set.of("skill:read")); + + assertFalse(deniedCreate.allowed()); + assertEquals("skill:publish", deniedCreate.requiredScope()); + assertTrue(allowedCreate.allowed()); + assertFalse(deniedSubmit.allowed()); + assertEquals("skill:publish", deniedSubmit.requiredScope()); + assertFalse(registry.authorizeApiToken( + "POST", "/api/v1/suites/reviews/7/approve", ALL_SCOPES).allowed()); + } + @Test void authorizeApiToken_requiresPublishScopeForSecurityScanRetry() { var denied = registry.authorizeApiToken( diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewSubjectType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewSubjectType.java new file mode 100644 index 00000000..e5cb4db6 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewSubjectType.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.review; + +/** Resource version types handled by the shared review queue. */ +public enum ReviewSubjectType { + SKILL_VERSION, + SUITE_VERSION +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java index 9b908939..3dd66689 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java @@ -14,12 +14,25 @@ public class ReviewTask { @Column(name = "skill_version_id") private Long skillVersionId; - @Column(name = "skill_id", nullable = false) + @Column(name = "skill_id") private Long skillId; - @Column(name = "skill_version", nullable = false, length = 64) + @Column(name = "skill_version", length = 64) private String skillVersion; + @Enumerated(EnumType.STRING) + @Column(name = "subject_type", nullable = false, length = 32) + private ReviewSubjectType subjectType; + + @Column(name = "subject_id", nullable = false) + private Long subjectId; + + @Column(name = "subject_version_id") + private Long subjectVersionId; + + @Column(name = "subject_version", nullable = false, length = 64) + private String subjectVersion; + @Column(name = "namespace_id", nullable = false) private Long namespaceId; @@ -62,6 +75,28 @@ public class ReviewTask { this.namespaceId = namespaceId; this.skillVersion = skillVersion; this.submittedBy = submittedBy; + this.subjectType = ReviewSubjectType.SKILL_VERSION; + this.subjectId = skillId; + this.subjectVersionId = skillVersionId; + this.subjectVersion = skillVersion; + } + + /** Creates a typed Suite review without populating legacy Skill-specific columns. */ + public static ReviewTask forSuiteVersion( + Long suiteVersionId, + Long suiteId, + Long namespaceId, + String suiteVersion, + String submittedBy + ) { + ReviewTask task = new ReviewTask(); + task.subjectType = ReviewSubjectType.SUITE_VERSION; + task.subjectId = suiteId; + task.subjectVersionId = suiteVersionId; + task.subjectVersion = suiteVersion; + task.namespaceId = namespaceId; + task.submittedBy = submittedBy; + return task; } public Long getId() { return id; } @@ -72,6 +107,14 @@ public class ReviewTask { public String getSkillVersion() { return skillVersion; } + public ReviewSubjectType getSubjectType() { return subjectType; } + + public Long getSubjectId() { return subjectId; } + + public Long getSubjectVersionId() { return subjectVersionId; } + + public String getSubjectVersion() { return subjectVersion; } + public Long getNamespaceId() { return namespaceId; } public ReviewTaskStatus getStatus() { return status; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java index 3f0faccb..8561d64a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java @@ -13,6 +13,10 @@ public interface ReviewTaskRepository { ReviewTask save(ReviewTask reviewTask); Optional findById(Long id); Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); + default Optional findBySubjectTypeAndSubjectVersionIdAndStatus( + ReviewSubjectType subjectType, Long subjectVersionId, ReviewTaskStatus status) { + throw new UnsupportedOperationException("Typed review subjects are not supported by this repository"); + } Page findByStatus(ReviewTaskStatus status, Pageable pageable); Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); @@ -20,9 +24,23 @@ public interface ReviewTaskRepository { String submittedBy, Long skillId, String skillVersion); List findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( Long skillId, String skillVersion); + default List findBySubmittedByAndSubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc( + String submittedBy, + ReviewSubjectType subjectType, + Long subjectId, + String subjectVersion) { + throw new UnsupportedOperationException("Typed review subjects are not supported by this repository"); + } + default List findBySubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc( + ReviewSubjectType subjectType, + Long subjectId, + String subjectVersion) { + throw new UnsupportedOperationException("Typed review subjects are not supported by this repository"); + } boolean existsByNamespaceId(Long namespaceId); void deleteBySkillVersionIdIn(Collection skillVersionIds); void deleteBySkillId(Long skillId); + void deleteBySubjectTypeAndSubjectId(ReviewSubjectType subjectType, Long subjectId); void delete(ReviewTask reviewTask); int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, String reviewComment, Integer expectedVersion); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/CreateSkillSuiteDraftCommand.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/CreateSkillSuiteDraftCommand.java new file mode 100644 index 00000000..9e4529b9 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/CreateSkillSuiteDraftCommand.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; + +import java.util.List; + +/** Fully resolved input for creating a Suite and its first immutable-version draft. */ +public record CreateSkillSuiteDraftCommand( + Long namespaceId, + String slug, + String displayName, + String summary, + String overview, + String version, + SkillVisibility visibility, + String changelog, + Long entrySkillVersionId, + List members +) { + public CreateSkillSuiteDraftCommand { + members = List.copyOf(members); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuite.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuite.java new file mode 100644 index 00000000..f99311db --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuite.java @@ -0,0 +1,116 @@ +package com.iflytek.skillhub.domain.suite; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +import java.time.Clock; +import java.time.Instant; + +/** Namespace-owned container whose published content is represented by immutable Suite versions. */ +@Entity +@Table(name = "skill_suite") +public class SkillSuite { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + + @Column(nullable = false, length = 128) + private String slug; + + @Column(name = "display_name", nullable = false, length = 256) + private String displayName; + + @Column(columnDefinition = "TEXT") + private String summary; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private SkillSuiteStatus status; + + @Column(name = "latest_version_id") + private Long latestVersionId; + + @Column(name = "install_request_count", nullable = false) + private Long installRequestCount = 0L; + + @Column(nullable = false) + private boolean hidden; + + @Column(name = "hidden_at") + private Instant hiddenAt; + + @Column(name = "hidden_by", length = 128) + private String hiddenBy; + + @Column(name = "created_by", nullable = false, length = 128) + private String createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_by", length = 128) + private String updatedBy; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected SkillSuite() { + } + + public SkillSuite(Long namespaceId, String slug, String displayName, String createdBy) { + this.namespaceId = namespaceId; + this.slug = slug; + this.displayName = displayName; + this.createdBy = createdBy; + this.status = SkillSuiteStatus.ACTIVE; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(Clock.systemUTC()); + updatedAt = createdAt; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(Clock.systemUTC()); + } + + public Long getId() { return id; } + public Long getNamespaceId() { return namespaceId; } + public String getSlug() { return slug; } + public String getDisplayName() { return displayName; } + public String getSummary() { return summary; } + public SkillSuiteStatus getStatus() { return status; } + public Long getLatestVersionId() { return latestVersionId; } + public Long getInstallRequestCount() { return installRequestCount; } + public boolean isHidden() { return hidden; } + public Instant getHiddenAt() { return hiddenAt; } + public String getHiddenBy() { return hiddenBy; } + public String getCreatedBy() { return createdBy; } + public Instant getCreatedAt() { return createdAt; } + public String getUpdatedBy() { return updatedBy; } + public Instant getUpdatedAt() { return updatedAt; } + + public void setDisplayName(String displayName) { this.displayName = displayName; } + public void setSummary(String summary) { this.summary = summary; } + public void setStatus(SkillSuiteStatus status) { this.status = status; } + public void setLatestVersionId(Long latestVersionId) { this.latestVersionId = latestVersionId; } + public void setInstallRequestCount(Long installRequestCount) { this.installRequestCount = installRequestCount; } + public void setHidden(boolean hidden) { this.hidden = hidden; } + public void setHiddenAt(Instant hiddenAt) { this.hiddenAt = hiddenAt; } + public void setHiddenBy(String hiddenBy) { this.hiddenBy = hiddenBy; } + public void setUpdatedBy(String updatedBy) { this.updatedBy = updatedBy; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteActionContext.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteActionContext.java new file mode 100644 index 00000000..648240a3 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteActionContext.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; + +import java.util.Map; +import java.util.Set; + +/** Caller authorization and request metadata shared by Suite lifecycle operations. */ +public record SkillSuiteActionContext( + String actorUserId, + Map namespaceRoles, + Set platformRoles, + String requestId, + String clientIp, + String userAgent +) { + public SkillSuiteActionContext { + namespaceRoles = namespaceRoles == null ? Map.of() : Map.copyOf(namespaceRoles); + platformRoles = platformRoles == null ? Set.of() : Set.copyOf(platformRoles); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteAllowedAction.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteAllowedAction.java new file mode 100644 index 00000000..548197ac --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteAllowedAction.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.domain.suite; + +/** Suite actions the current caller may invoke for one concrete version. */ +public enum SkillSuiteAllowedAction { + EDIT, + SUBMIT, + PUBLISH_PRIVATE, + REOPEN, + CREATE_VERSION, + YANK, + HIDE, + RESTORE, + ARCHIVE, + UNARCHIVE, + DELETE +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteAuthorizationPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteAuthorizationPolicy.java new file mode 100644 index 00000000..2470fdb8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteAuthorizationPolicy.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; + +/** Shared caller policy used by both Suite commands and action discovery. */ +public final class SkillSuiteAuthorizationPolicy { + + private SkillSuiteAuthorizationPolicy() { + } + + public static boolean canManageVersion( + SkillSuite suite, + SkillSuiteVersion version, + SkillSuiteActionContext context + ) { + NamespaceRole role = context.namespaceRoles().get(suite.getNamespaceId()); + return isPlatformOrNamespaceAdmin(role, context) + || (context.actorUserId() != null + && role != null + && context.actorUserId().equals(suite.getCreatedBy())); + } + + public static boolean canCreateVersion(SkillSuite suite, SkillSuiteActionContext context) { + NamespaceRole role = context.namespaceRoles().get(suite.getNamespaceId()); + return isPlatformOrNamespaceAdmin(role, context) + || (context.actorUserId() != null + && role != null + && context.actorUserId().equals(suite.getCreatedBy())); + } + + public static boolean canAdminister(SkillSuite suite, SkillSuiteActionContext context) { + return isPlatformOrNamespaceAdmin(context.namespaceRoles().get(suite.getNamespaceId()), context); + } + + private static boolean isPlatformOrNamespaceAdmin( + NamespaceRole role, + SkillSuiteActionContext context + ) { + return context.platformRoles().contains("SUPER_ADMIN") + || role == NamespaceRole.OWNER + || role == NamespaceRole.ADMIN; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteAvailability.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteAvailability.java new file mode 100644 index 00000000..66bdf130 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteAvailability.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; + +import java.util.ArrayList; +import java.util.List; + +/** Live aggregate availability derived from exact member state; never stored as lifecycle state. */ +public record SkillSuiteAvailability(boolean available, List blockedMembers) { + + public SkillSuiteAvailability { + blockedMembers = List.copyOf(blockedMembers); + } + + public static SkillSuiteAvailability evaluate( + Long suiteNamespaceId, + SkillVisibility suiteVisibility, + List members + ) { + SkillSuiteMemberEligibilityPolicy policy = new SkillSuiteMemberEligibilityPolicy(); + List blocked = new ArrayList<>(); + for (SkillSuiteMemberState member : members) { + SkillSuiteMemberAvailability result = policy.evaluate(suiteNamespaceId, suiteVisibility, member); + if (!result.available()) { + blocked.add(new SkillSuiteBlockedMember(member.skillVersionId(), result.reason())); + } + } + return new SkillSuiteAvailability(blocked.isEmpty(), blocked); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteBlockedMember.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteBlockedMember.java new file mode 100644 index 00000000..4000c15b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteBlockedMember.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.domain.suite; + +/** Non-sensitive identity and reason for one blocked exact member. */ +public record SkillSuiteBlockedMember(Long skillVersionId, SkillSuiteMemberBlockingReason reason) { +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicy.java new file mode 100644 index 00000000..b750d455 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicy.java @@ -0,0 +1,40 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Validates rules that depend only on a Suite draft's selected members. + */ +public final class SkillSuiteCompositionPolicy { + + public static final int MAX_MEMBERS = 100; + + private SkillSuiteCompositionPolicy() { + } + + public static void validate(List members, Long entrySkillVersionId) { + if (members.isEmpty()) { + throw new DomainBadRequestException("error.suite.members.empty"); + } + if (members.size() > MAX_MEMBERS) { + throw new DomainBadRequestException("error.suite.members.limit", MAX_MEMBERS); + } + Set skillIds = new HashSet<>(); + boolean entryFound = entrySkillVersionId == null; + for (SkillSuiteMemberSelection member : members) { + if (!skillIds.add(member.skillId())) { + throw new DomainBadRequestException("error.suite.members.duplicate"); + } + if (member.skillVersionId().equals(entrySkillVersionId)) { + entryFound = true; + } + } + if (!entryFound) { + throw new DomainBadRequestException("error.suite.entry.notMember"); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java new file mode 100644 index 00000000..09eead25 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java @@ -0,0 +1,259 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.audit.AuditDetail; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +/** Creates Suite drafts after member coordinates have been resolved to exact Skill versions. */ +@Service +public class SkillSuiteDraftService { + + private static final Logger log = LoggerFactory.getLogger(SkillSuiteDraftService.class); + + private final SkillSuiteRepository suiteRepository; + private final SkillSuiteVersionRepository versionRepository; + private final SkillSuiteVersionMemberRepository memberRepository; + private final NamespaceRepository namespaceRepository; + private final SkillSuitePublicationValidator publicationValidator; + private final AuditLogService auditLogService; + + public SkillSuiteDraftService( + SkillSuiteRepository suiteRepository, + SkillSuiteVersionRepository versionRepository, + SkillSuiteVersionMemberRepository memberRepository, + NamespaceRepository namespaceRepository, + SkillSuitePublicationValidator publicationValidator, + AuditLogService auditLogService + ) { + this.suiteRepository = suiteRepository; + this.versionRepository = versionRepository; + this.memberRepository = memberRepository; + this.namespaceRepository = namespaceRepository; + this.publicationValidator = publicationValidator; + this.auditLogService = auditLogService; + } + + @Transactional + public CreatedDraft create( + CreateSkillSuiteDraftCommand command, + SkillSuiteActionContext context + ) { + requireWritableNamespace(command.namespaceId()); + assertCanCreate(command.namespaceId(), context); + SlugValidator.validate(command.slug()); + validateDefinition(command); + if (suiteRepository.findByNamespaceIdAndSlug(command.namespaceId(), command.slug()).isPresent()) { + throw new DomainBadRequestException("error.suite.slug.exists", command.slug()); + } + + SkillSuite suite = new SkillSuite( + command.namespaceId(), command.slug(), command.displayName(), context.actorUserId()); + suite.setSummary(command.summary()); + suite.setUpdatedBy(context.actorUserId()); + suite = suiteRepository.save(suite); + + SkillSuiteVersion version = new SkillSuiteVersion( + suite.getId(), command.version(), command.displayName(), command.summary(), + command.visibility(), context.actorUserId()); + version.setOverview(command.overview()); + version.setChangelog(command.changelog()); + version.setEntrySkillVersionId(command.entrySkillVersionId()); + version = versionRepository.save(version); + + List members = saveMembers(version, command.members()); + + // Validate after persistence so the same resolver is used for draft creation and publication. + // The transaction rolls the draft back if any exact member changed during creation. + publicationValidator.validate(suite, version); + auditLogService.record( + context.actorUserId(), "CREATE_SKILL_SUITE_DRAFT", "SKILL_SUITE_VERSION", + version.getId(), context.requestId(), context.clientIp(), context.userAgent(), + AuditDetail.builder() + .put("suiteId", suite.getId()) + .put("memberCount", members.size()) + .build()); + log.info("Suite draft created [suiteId={}, versionId={}, namespaceId={}, actorId={}, memberCount={}, requestId={}]", + suite.getId(), version.getId(), suite.getNamespaceId(), context.actorUserId(), + members.size(), context.requestId()); + return new CreatedDraft(suite, version, members); + } + + @Transactional + public CreatedDraft createVersion( + Long suiteId, + CreateSkillSuiteDraftCommand command, + SkillSuiteActionContext context + ) { + SkillSuite suite = suiteRepository.findById(suiteId) + .orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteId)); + Namespace namespace = requireWritableNamespace(suite.getNamespaceId()); + assertMatchesSuite(command, suite); + assertCanManageSuite(suite, context); + if (suite.getStatus() != SkillSuiteStatus.ACTIVE) { + throw new DomainBadRequestException("error.suite.notActive", suite.getSlug()); + } + if (versionRepository.findBySuiteIdAndVersion(suiteId, command.version()).isPresent()) { + throw new DomainBadRequestException("error.suite.version.exists", command.version()); + } + validateDefinition(command); + + CreatedDraft created = persistVersion(suite, command, context); + log.info("Suite version draft created [suiteId={}, versionId={}, namespaceId={}, actorId={}, memberCount={}, requestId={}]", + suiteId, created.version().getId(), namespace.getId(), context.actorUserId(), + created.members().size(), context.requestId()); + return created; + } + + @Transactional + public CreatedDraft updateDraft( + Long suiteId, + Long versionId, + CreateSkillSuiteDraftCommand command, + SkillSuiteActionContext context + ) { + SkillSuite suite = suiteRepository.findById(suiteId) + .orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteId)); + requireWritableNamespace(suite.getNamespaceId()); + SkillSuiteVersion version = versionRepository.findById(versionId) + .orElseThrow(() -> new DomainNotFoundException("error.suite.version.notFound", versionId)); + if (!suiteId.equals(version.getSuiteId())) { + throw new DomainBadRequestException("error.suite.version.mismatch"); + } + assertMatchesSuite(command, suite); + assertCanManageVersion(suite, version, context); + version.assertEditable(); + if (!version.getVersion().equals(command.version())) { + throw new DomainBadRequestException("error.suite.version.renameNotAllowed"); + } + validateDefinition(command); + + version.setDisplayName(command.displayName()); + version.setSummary(command.summary()); + version.setOverview(command.overview()); + version.setVisibility(command.visibility()); + version.setChangelog(command.changelog()); + version.setEntrySkillVersionId(command.entrySkillVersionId()); + versionRepository.save(version); + memberRepository.deleteBySuiteVersionId(versionId); + List members = saveMembers(version, command.members()); + publicationValidator.validate(suite, version); + auditLogService.record( + context.actorUserId(), "UPDATE_SKILL_SUITE_DRAFT", "SKILL_SUITE_VERSION", + versionId, context.requestId(), context.clientIp(), context.userAgent(), + AuditDetail.builder() + .put("suiteId", suiteId) + .put("memberCount", members.size()) + .build()); + log.info("Suite draft updated [suiteId={}, versionId={}, namespaceId={}, actorId={}, memberCount={}, requestId={}]", + suiteId, versionId, suite.getNamespaceId(), context.actorUserId(), + members.size(), context.requestId()); + return new CreatedDraft(suite, version, members); + } + + private Namespace requireWritableNamespace(Long namespaceId) { + Namespace namespace = namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); + if (namespace.getStatus() != NamespaceStatus.ACTIVE) { + throw new DomainBadRequestException("error.suite.namespace.notWritable", namespace.getStatus()); + } + return namespace; + } + + private void assertMatchesSuite(CreateSkillSuiteDraftCommand command, SkillSuite suite) { + if (!suite.getNamespaceId().equals(command.namespaceId()) || !suite.getSlug().equals(command.slug())) { + throw new DomainBadRequestException("error.suite.version.mismatch"); + } + } + + private void assertCanManageSuite(SkillSuite suite, SkillSuiteActionContext context) { + if (!SkillSuiteAuthorizationPolicy.canCreateVersion(suite, context)) { + throw new DomainForbiddenException("error.suite.lifecycle.noPermission"); + } + } + + private void assertCanManageVersion( + SkillSuite suite, + SkillSuiteVersion version, + SkillSuiteActionContext context + ) { + if (!SkillSuiteAuthorizationPolicy.canManageVersion(suite, version, context)) { + throw new DomainForbiddenException("error.suite.lifecycle.noPermission"); + } + } + + private void validateDefinition(CreateSkillSuiteDraftCommand command) { + if (command.displayName() == null || command.displayName().isBlank()) { + throw new DomainBadRequestException("error.suite.displayName.required"); + } + if (command.version() == null || command.version().isBlank()) { + throw new DomainBadRequestException("error.suite.version.required"); + } + SkillSuiteCompositionPolicy.validate(command.members(), command.entrySkillVersionId()); + } + + private CreatedDraft persistVersion( + SkillSuite suite, + CreateSkillSuiteDraftCommand command, + SkillSuiteActionContext context + ) { + SkillSuiteVersion version = new SkillSuiteVersion( + suite.getId(), command.version(), command.displayName(), command.summary(), + command.visibility(), context.actorUserId()); + version.setOverview(command.overview()); + version.setChangelog(command.changelog()); + version.setEntrySkillVersionId(command.entrySkillVersionId()); + version = versionRepository.save(version); + List members = saveMembers(version, command.members()); + publicationValidator.validate(suite, version); + auditLogService.record( + context.actorUserId(), "CREATE_SKILL_SUITE_VERSION_DRAFT", "SKILL_SUITE_VERSION", + version.getId(), context.requestId(), context.clientIp(), context.userAgent(), + AuditDetail.builder() + .put("suiteId", suite.getId()) + .put("memberCount", members.size()) + .build()); + return new CreatedDraft(suite, version, members); + } + + private List saveMembers( + SkillSuiteVersion version, + List selections + ) { + List members = new ArrayList<>(selections.size()); + for (int position = 0; position < selections.size(); position++) { + members.add(new SkillSuiteVersionMember(version.getId(), selections.get(position), position)); + } + return memberRepository.saveAll(members); + } + + private void assertCanCreate(Long namespaceId, SkillSuiteActionContext context) { + NamespaceRole role = context.namespaceRoles().get(namespaceId); + if (!context.platformRoles().contains("SUPER_ADMIN") && role == null) { + throw new DomainForbiddenException("error.suite.lifecycle.noPermission"); + } + } + + public record CreatedDraft( + SkillSuite suite, + SkillSuiteVersion version, + List members + ) { + public CreatedDraft { + members = List.copyOf(members); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteInstallMetricsService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteInstallMetricsService.java new file mode 100644 index 00000000..a4fb0a03 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteInstallMetricsService.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.domain.suite; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Records one successfully issued Suite plan without pre-counting member downloads. */ +@Service +public class SkillSuiteInstallMetricsService { + + private final SkillSuiteRepository suiteRepository; + + public SkillSuiteInstallMetricsService(SkillSuiteRepository suiteRepository) { + this.suiteRepository = suiteRepository; + } + + @Transactional + public void recordIssuedPlan(Long suiteId) { + suiteRepository.incrementInstallRequestCount(suiteId); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteInstallOperation.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteInstallOperation.java new file mode 100644 index 00000000..c4916588 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteInstallOperation.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.domain.suite; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** Durable idempotency marker for one successfully issued Suite install plan. */ +@Entity +@Table(name = "skill_suite_install_operation") +public class SkillSuiteInstallOperation { + + @Id + @Column(name = "operation_id", length = 64) + private String operationId; + + @Column(name = "client_request_id", nullable = false, length = 64) + private String clientRequestId; + + @Column(name = "actor_key", nullable = false, length = 160) + private String actorKey; + + @Column(name = "suite_id", nullable = false) + private Long suiteId; + + @Column(name = "suite_version_id", nullable = false) + private Long suiteVersionId; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected SkillSuiteInstallOperation() { + } + + public String getOperationId() { return operationId; } + public String getClientRequestId() { return clientRequestId; } + public String getActorKey() { return actorKey; } + public Long getSuiteId() { return suiteId; } + public Long getSuiteVersionId() { return suiteVersionId; } + public Instant getCreatedAt() { return createdAt; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteInstallOperationRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteInstallOperationRepository.java new file mode 100644 index 00000000..87709de8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteInstallOperationRepository.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.domain.suite; + +import java.util.Optional; +import java.time.Instant; + +/** Persistence contract for atomic Suite install-plan idempotency claims. */ +public interface SkillSuiteInstallOperationRepository { + + int insertIfAbsent( + String operationId, String clientRequestId, String actorKey, Long suiteId, Long suiteVersionId); + + Optional findByClientRequestIdAndActorKey(String clientRequestId, String actorKey); + + int deleteCreatedBefore(Instant threshold); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java new file mode 100644 index 00000000..5d435d2c --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java @@ -0,0 +1,444 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.audit.AuditDetail; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.review.ReviewPermissionChecker; +import com.iflytek.skillhub.domain.review.ReviewSubjectType; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; +import java.util.ConcurrentModificationException; +import java.util.EnumSet; +import java.util.Set; + +/** Coordinates Suite version review and publication without changing any member lifecycle. */ +@Service +public class SkillSuiteLifecycleService { + + private static final Logger log = LoggerFactory.getLogger(SkillSuiteLifecycleService.class); + + private final SkillSuiteRepository suiteRepository; + private final SkillSuiteVersionRepository versionRepository; + private final ReviewTaskRepository reviewTaskRepository; + private final ReviewPermissionChecker reviewPermissionChecker; + private final NamespaceRepository namespaceRepository; + private final SkillSuitePublicationValidator publicationValidator; + private final AuditLogService auditLogService; + private final Clock clock; + private final boolean reviewWritesEnabled; + + public SkillSuiteLifecycleService( + SkillSuiteRepository suiteRepository, + SkillSuiteVersionRepository versionRepository, + ReviewTaskRepository reviewTaskRepository, + ReviewPermissionChecker reviewPermissionChecker, + NamespaceRepository namespaceRepository, + SkillSuitePublicationValidator publicationValidator, + AuditLogService auditLogService, + Clock clock, + @Value("${skillhub.suite.review-writes-enabled}") boolean reviewWritesEnabled + ) { + this.suiteRepository = suiteRepository; + this.versionRepository = versionRepository; + this.reviewTaskRepository = reviewTaskRepository; + this.reviewPermissionChecker = reviewPermissionChecker; + this.namespaceRepository = namespaceRepository; + this.publicationValidator = publicationValidator; + this.auditLogService = auditLogService; + this.clock = clock; + this.reviewWritesEnabled = reviewWritesEnabled; + if (!reviewWritesEnabled) { + log.warn("Suite review writes are disabled for a mixed-version rolling upgrade"); + } + } + + @Transactional + public ReviewTask submitForReview(Long suiteId, Long versionId, SkillSuiteActionContext context) { + if (!reviewWritesEnabled) { + throw new DomainBadRequestException("error.suite.review.rolloutDisabled"); + } + Loaded loaded = load(suiteId, versionId); + assertNamespaceWritable(loaded.namespace()); + assertCanManageDraft(loaded.suite(), loaded.version(), context); + if (loaded.version().getVisibility() == SkillVisibility.PRIVATE) { + throw new DomainBadRequestException("error.suite.review.private"); + } + if (loaded.version().getStatus() != SkillSuiteVersionStatus.DRAFT) { + throw new DomainBadRequestException("error.suite.review.notDraft", loaded.version().getVersion()); + } + publicationValidator.validate(loaded.suite(), loaded.version()); + + loaded.version().setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + versionRepository.save(loaded.version()); + ReviewTask task = reviewTaskRepository.save(ReviewTask.forSuiteVersion( + versionId, suiteId, loaded.suite().getNamespaceId(), + loaded.version().getVersion(), context.actorUserId())); + audit(context, "SUBMIT_SKILL_SUITE_REVIEW", "SKILL_SUITE_VERSION", versionId, null); + log.info("Suite review submitted [suiteId={}, versionId={}, actorId={}, requestId={}]", + suiteId, versionId, context.actorUserId(), context.requestId()); + return task; + } + + @Transactional + public SkillSuiteVersion confirmPrivatePublish( + Long suiteId, + Long versionId, + SkillSuiteActionContext context + ) { + Loaded loaded = load(suiteId, versionId); + assertNamespaceWritable(loaded.namespace()); + assertCanManageDraft(loaded.suite(), loaded.version(), context); + if (loaded.version().getVisibility() != SkillVisibility.PRIVATE) { + throw new DomainBadRequestException("error.suite.publish.notPrivate"); + } + if (loaded.version().getStatus() != SkillSuiteVersionStatus.DRAFT) { + throw new DomainBadRequestException("error.suite.publish.notDraft", loaded.version().getVersion()); + } + publicationValidator.validate(loaded.suite(), loaded.version()); + publish(loaded.suite(), loaded.version(), context.actorUserId()); + audit(context, "PUBLISH_SKILL_SUITE_VERSION", "SKILL_SUITE_VERSION", versionId, null); + log.info("Private Suite published [suiteId={}, versionId={}, actorId={}, requestId={}]", + suiteId, versionId, context.actorUserId(), context.requestId()); + return loaded.version(); + } + + @Transactional + public ReviewTask approveReview(Long reviewTaskId, String comment, SkillSuiteActionContext context) { + ReviewTask task = loadPendingSuiteReview(reviewTaskId); + Loaded loaded = load(task.getSubjectId(), task.getSubjectVersionId()); + assertNamespaceWritable(loaded.namespace()); + assertCanReview(task, loaded.namespace(), context); + if (loaded.version().getStatus() != SkillSuiteVersionStatus.PENDING_REVIEW) { + throw new DomainBadRequestException("error.suite.review.notPending", loaded.version().getVersion()); + } + publicationValidator.validate(loaded.suite(), loaded.version()); + completeTask(task, ReviewTaskStatus.APPROVED, context.actorUserId(), comment); + publish(loaded.suite(), loaded.version(), context.actorUserId()); + audit(context, "APPROVE_SKILL_SUITE_REVIEW", "REVIEW_TASK", reviewTaskId, + AuditDetail.of("suiteVersionId", loaded.version().getId())); + log.info("Suite review approved [reviewTaskId={}, suiteId={}, versionId={}, actorId={}, requestId={}]", + reviewTaskId, loaded.suite().getId(), loaded.version().getId(), + context.actorUserId(), context.requestId()); + return task; + } + + @Transactional + public ReviewTask rejectReview(Long reviewTaskId, String comment, SkillSuiteActionContext context) { + ReviewTask task = loadPendingSuiteReview(reviewTaskId); + Loaded loaded = load(task.getSubjectId(), task.getSubjectVersionId()); + assertNamespaceWritable(loaded.namespace()); + assertCanReview(task, loaded.namespace(), context); + if (loaded.version().getStatus() != SkillSuiteVersionStatus.PENDING_REVIEW) { + throw new DomainBadRequestException("error.suite.review.notPending", loaded.version().getVersion()); + } + completeTask(task, ReviewTaskStatus.REJECTED, context.actorUserId(), comment); + loaded.version().setStatus(SkillSuiteVersionStatus.REJECTED); + versionRepository.save(loaded.version()); + audit(context, "REJECT_SKILL_SUITE_REVIEW", "REVIEW_TASK", reviewTaskId, + AuditDetail.of("suiteVersionId", loaded.version().getId())); + log.info("Suite review rejected [reviewTaskId={}, suiteId={}, versionId={}, actorId={}, requestId={}]", + reviewTaskId, loaded.suite().getId(), loaded.version().getId(), + context.actorUserId(), context.requestId()); + return task; + } + + @Transactional + public void withdrawReview(Long reviewTaskId, SkillSuiteActionContext context) { + ReviewTask task = loadPendingSuiteReview(reviewTaskId); + Loaded loaded = load(task.getSubjectId(), task.getSubjectVersionId()); + assertCanManageDraft(loaded.suite(), loaded.version(), context); + if (loaded.version().getStatus() != SkillSuiteVersionStatus.PENDING_REVIEW) { + throw new DomainBadRequestException("error.suite.review.notPending", loaded.version().getVersion()); + } + loaded.version().setStatus(SkillSuiteVersionStatus.DRAFT); + versionRepository.save(loaded.version()); + reviewTaskRepository.delete(task); + audit(context, "WITHDRAW_SKILL_SUITE_REVIEW", "SKILL_SUITE_VERSION", + loaded.version().getId(), null); + log.info("Suite review withdrawn [reviewTaskId={}, suiteId={}, versionId={}, actorId={}, requestId={}]", + reviewTaskId, loaded.suite().getId(), loaded.version().getId(), + context.actorUserId(), context.requestId()); + } + + @Transactional + public SkillSuiteVersion reopenRejected(Long suiteId, Long versionId, SkillSuiteActionContext context) { + Loaded loaded = load(suiteId, versionId); + assertNamespaceWritable(loaded.namespace()); + assertCanManageDraft(loaded.suite(), loaded.version(), context); + if (loaded.version().getStatus() != SkillSuiteVersionStatus.REJECTED) { + throw new DomainBadRequestException("error.suite.review.notRejected", loaded.version().getVersion()); + } + loaded.version().setStatus(SkillSuiteVersionStatus.DRAFT); + versionRepository.save(loaded.version()); + audit(context, "REOPEN_SKILL_SUITE_VERSION", "SKILL_SUITE_VERSION", versionId, null); + log.info("Rejected Suite reopened [suiteId={}, versionId={}, actorId={}, requestId={}]", + suiteId, versionId, context.actorUserId(), context.requestId()); + return loaded.version(); + } + + @Transactional + public SkillSuiteVersion yank(Long suiteId, Long versionId, String reason, SkillSuiteActionContext context) { + Loaded loaded = load(suiteId, versionId); + assertCanAdminister(loaded.suite(), context); + if (loaded.version().getStatus() != SkillSuiteVersionStatus.PUBLISHED) { + throw new DomainBadRequestException("error.suite.version.notPublished", loaded.version().getVersion()); + } + loaded.version().setStatus(SkillSuiteVersionStatus.YANKED); + loaded.version().setYankedAt(Instant.now(clock)); + loaded.version().setYankedBy(context.actorUserId()); + loaded.version().setYankReason(reason); + versionRepository.save(loaded.version()); + if (versionId.equals(loaded.suite().getLatestVersionId())) { + SkillSuiteVersion latest = versionRepository + .findBySuiteIdAndStatus(suiteId, SkillSuiteVersionStatus.PUBLISHED) + .stream() + .max(Comparator.comparing(SkillSuiteVersion::getPublishedAt, + Comparator.nullsFirst(Comparator.naturalOrder()))) + .orElse(null); + loaded.suite().setLatestVersionId(latest == null ? null : latest.getId()); + if (latest != null) { + loaded.suite().setDisplayName(latest.getDisplayName()); + loaded.suite().setSummary(latest.getSummary()); + } + loaded.suite().setUpdatedBy(context.actorUserId()); + suiteRepository.save(loaded.suite()); + } + audit(context, "YANK_SKILL_SUITE_VERSION", "SKILL_SUITE_VERSION", versionId, + AuditDetail.of("reason", reason)); + log.info("Suite version yanked [suiteId={}, versionId={}, actorId={}, requestId={}]", + suiteId, versionId, context.actorUserId(), context.requestId()); + return loaded.version(); + } + + @Transactional + public SkillSuite setHidden(Long suiteId, boolean hidden, SkillSuiteActionContext context) { + SkillSuite suite = loadSuite(suiteId); + assertCanAdminister(suite, context); + if (suite.isHidden() == hidden) { + return suite; + } + suite.setHidden(hidden); + suite.setHiddenAt(hidden ? Instant.now(clock) : null); + suite.setHiddenBy(hidden ? context.actorUserId() : null); + suite.setUpdatedBy(context.actorUserId()); + suiteRepository.save(suite); + String action = hidden ? "HIDE_SKILL_SUITE" : "RESTORE_SKILL_SUITE"; + audit(context, action, "SKILL_SUITE", suiteId, null); + log.info("Suite visibility overlay changed [suiteId={}, hidden={}, actorId={}, requestId={}]", + suiteId, hidden, context.actorUserId(), context.requestId()); + return suite; + } + + @Transactional + public SkillSuite setArchived(Long suiteId, boolean archived, SkillSuiteActionContext context) { + SkillSuite suite = loadSuite(suiteId); + assertCanAdminister(suite, context); + SkillSuiteStatus target = archived ? SkillSuiteStatus.ARCHIVED : SkillSuiteStatus.ACTIVE; + if (suite.getStatus() == target) { + return suite; + } + suite.setStatus(target); + suite.setUpdatedBy(context.actorUserId()); + suiteRepository.save(suite); + String action = archived ? "ARCHIVE_SKILL_SUITE" : "UNARCHIVE_SKILL_SUITE"; + audit(context, action, "SKILL_SUITE", suiteId, null); + log.info("Suite container status changed [suiteId={}, status={}, actorId={}, requestId={}]", + suiteId, target, context.actorUserId(), context.requestId()); + return suite; + } + + @Transactional + public void delete(Long suiteId, SkillSuiteActionContext context) { + SkillSuite suite = loadSuite(suiteId); + assertCanAdminister(suite, context); + boolean pendingReview = versionRepository.findBySuiteId(suiteId).stream() + .anyMatch(version -> version.getStatus() == SkillSuiteVersionStatus.PENDING_REVIEW); + if (pendingReview) { + throw new DomainBadRequestException("error.suite.delete.pendingReview"); + } + audit(context, "DELETE_SKILL_SUITE", "SKILL_SUITE", suiteId, + AuditDetail.of("slug", suite.getSlug())); + // Review tasks are lifecycle-owned by the hard-deleted Suite. Keeping them would leave + // polymorphic subject IDs that can no longer be resolved by governance read models. + reviewTaskRepository.deleteBySubjectTypeAndSubjectId(ReviewSubjectType.SUITE_VERSION, suiteId); + suiteRepository.delete(suite); + log.info("Suite deleted [suiteId={}, namespaceId={}, actorId={}, requestId={}]", + suiteId, suite.getNamespaceId(), context.actorUserId(), context.requestId()); + } + + /** + * Returns state-aware actions for display. Command methods still authorize independently so a + * cached response cannot grant access after roles or lifecycle state change. + */ + @Transactional(readOnly = true) + public Set allowedActions( + SkillSuite suite, + SkillSuiteVersion version, + Namespace namespace, + SkillSuiteActionContext context + ) { + EnumSet actions = EnumSet.noneOf(SkillSuiteAllowedAction.class); + boolean namespaceWritable = namespace.getStatus() == NamespaceStatus.ACTIVE; + boolean canManageVersion = SkillSuiteAuthorizationPolicy.canManageVersion(suite, version, context); + boolean canCreateVersion = SkillSuiteAuthorizationPolicy.canCreateVersion(suite, context); + boolean canAdminister = SkillSuiteAuthorizationPolicy.canAdminister(suite, context); + + if (namespaceWritable && canManageVersion) { + if (version.getStatus() == SkillSuiteVersionStatus.DRAFT) { + actions.add(SkillSuiteAllowedAction.EDIT); + if (version.getVisibility() == SkillVisibility.PRIVATE) { + actions.add(SkillSuiteAllowedAction.PUBLISH_PRIVATE); + } else if (reviewWritesEnabled) { + actions.add(SkillSuiteAllowedAction.SUBMIT); + } + } else if (version.getStatus() == SkillSuiteVersionStatus.REJECTED) { + actions.add(SkillSuiteAllowedAction.REOPEN); + } + } + if (namespaceWritable + && suite.getStatus() == SkillSuiteStatus.ACTIVE + && canCreateVersion + && (version.getStatus() == SkillSuiteVersionStatus.PUBLISHED + || version.getStatus() == SkillSuiteVersionStatus.YANKED)) { + actions.add(SkillSuiteAllowedAction.CREATE_VERSION); + } + if (canAdminister) { + if (version.getStatus() == SkillSuiteVersionStatus.PUBLISHED) { + actions.add(SkillSuiteAllowedAction.YANK); + } + actions.add(suite.isHidden() + ? SkillSuiteAllowedAction.RESTORE + : SkillSuiteAllowedAction.HIDE); + actions.add(suite.getStatus() == SkillSuiteStatus.ARCHIVED + ? SkillSuiteAllowedAction.UNARCHIVE + : SkillSuiteAllowedAction.ARCHIVE); + boolean pendingReview = versionRepository.findBySuiteIdAndStatus( + suite.getId(), SkillSuiteVersionStatus.PENDING_REVIEW).stream().findAny().isPresent(); + if (!pendingReview) { + actions.add(SkillSuiteAllowedAction.DELETE); + } + } + return Set.copyOf(actions); + } + + private SkillSuite loadSuite(Long suiteId) { + return suiteRepository.findById(suiteId) + .orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteId)); + } + + private Loaded load(Long suiteId, Long versionId) { + SkillSuite suite = suiteRepository.findById(suiteId) + .orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteId)); + SkillSuiteVersion version = versionRepository.findById(versionId) + .orElseThrow(() -> new DomainNotFoundException("error.suite.version.notFound", versionId)); + if (!suiteId.equals(version.getSuiteId())) { + throw new DomainBadRequestException("error.suite.version.mismatch"); + } + Namespace namespace = namespaceRepository.findById(suite.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", suite.getNamespaceId())); + return new Loaded(suite, version, namespace); + } + + private void publish(SkillSuite suite, SkillSuiteVersion version, String actorUserId) { + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + version.setPublishedAt(Instant.now(clock)); + versionRepository.save(version); + suite.setLatestVersionId(version.getId()); + // The container is the searchable projection of the latest published immutable version. + suite.setDisplayName(version.getDisplayName()); + suite.setSummary(version.getSummary()); + suite.setUpdatedBy(actorUserId); + suiteRepository.save(suite); + } + + private ReviewTask loadPendingSuiteReview(Long reviewTaskId) { + ReviewTask task = reviewTaskRepository.findById(reviewTaskId) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId)); + if (task.getSubjectType() != ReviewSubjectType.SUITE_VERSION) { + throw new DomainBadRequestException("error.suite.review.subjectMismatch"); + } + if (task.getStatus() != ReviewTaskStatus.PENDING) { + throw new DomainBadRequestException("review.not_pending", reviewTaskId); + } + return task; + } + + private void completeTask(ReviewTask task, ReviewTaskStatus status, String reviewerId, String comment) { + int updated = reviewTaskRepository.updateStatusWithVersion( + task.getId(), status, reviewerId, comment, task.getVersion()); + if (updated == 0) { + throw new ConcurrentModificationException("Review task was modified concurrently"); + } + // The bulk update is the concurrency boundary. Keep this detached return value aligned + // with the committed decision without leaking persistence mechanics into the domain layer. + task.setStatus(status); + task.setReviewedBy(reviewerId); + task.setReviewComment(comment); + task.setReviewedAt(Instant.now(clock)); + } + + private void assertNamespaceWritable(Namespace namespace) { + if (namespace.getStatus() != NamespaceStatus.ACTIVE) { + throw new DomainBadRequestException("error.suite.namespace.notWritable", namespace.getStatus()); + } + } + + private void assertCanManageDraft( + SkillSuite suite, + SkillSuiteVersion version, + SkillSuiteActionContext context + ) { + if (!SkillSuiteAuthorizationPolicy.canManageVersion(suite, version, context)) { + throw new DomainForbiddenException("error.suite.lifecycle.noPermission"); + } + } + + private void assertCanAdminister(SkillSuite suite, SkillSuiteActionContext context) { + if (!SkillSuiteAuthorizationPolicy.canAdminister(suite, context)) { + throw new DomainForbiddenException("error.suite.lifecycle.noPermission"); + } + } + + private void assertCanReview(ReviewTask task, Namespace namespace, SkillSuiteActionContext context) { + if (!reviewPermissionChecker.canReview( + task, + context.actorUserId(), + namespace.getType(), + context.namespaceRoles(), + context.platformRoles())) { + throw new DomainForbiddenException("review.no_permission"); + } + } + + private void audit( + SkillSuiteActionContext context, + String action, + String targetType, + Long targetId, + String detail + ) { + auditLogService.record( + context.actorUserId(), action, targetType, targetId, context.requestId(), + context.clientIp(), context.userAgent(), detail); + } + + private record Loaded(SkillSuite suite, SkillSuiteVersion version, Namespace namespace) { + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberAvailability.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberAvailability.java new file mode 100644 index 00000000..fe32fb4e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberAvailability.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.domain.suite; + +/** Computed availability; it is deliberately not persisted as a Suite lifecycle state. */ +public record SkillSuiteMemberAvailability(boolean available, SkillSuiteMemberBlockingReason reason) { + + public static SkillSuiteMemberAvailability availableMember() { + return new SkillSuiteMemberAvailability(true, null); + } + + public static SkillSuiteMemberAvailability blocked(SkillSuiteMemberBlockingReason reason) { + return new SkillSuiteMemberAvailability(false, reason); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberBlockingReason.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberBlockingReason.java new file mode 100644 index 00000000..5c8808c0 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberBlockingReason.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.domain.suite; + +/** Stable reason codes used to explain why an exact Suite member cannot be installed. */ +public enum SkillSuiteMemberBlockingReason { + DELETED, + NAMESPACE_ARCHIVED, + NAMESPACE_FROZEN, + SKILL_HIDDEN, + SKILL_ARCHIVED, + VERSION_UNAVAILABLE, + VISIBILITY_INCOMPATIBLE +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberEligibilityPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberEligibilityPolicy.java new file mode 100644 index 00000000..e1ae9bba --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberEligibilityPolicy.java @@ -0,0 +1,57 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; + +/** Evaluates the current installability of an exact member against the Suite's approved audience. */ +public final class SkillSuiteMemberEligibilityPolicy { + + public SkillSuiteMemberAvailability evaluate( + Long suiteNamespaceId, + SkillVisibility suiteVisibility, + SkillSuiteMemberState member + ) { + if (member == null || member.tombstoned()) { + return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.DELETED); + } + if (member.hidden()) { + return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.SKILL_HIDDEN); + } + if (member.namespaceStatus() == NamespaceStatus.ARCHIVED) { + return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.NAMESPACE_ARCHIVED); + } + if (member.namespaceStatus() == NamespaceStatus.FROZEN) { + return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.NAMESPACE_FROZEN); + } + if (member.skillStatus() != SkillStatus.ACTIVE) { + return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.SKILL_ARCHIVED); + } + if (member.versionStatus() != SkillVersionStatus.PUBLISHED || !member.downloadReady() || member.yanked()) { + return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.VERSION_UNAVAILABLE); + } + if (!audienceCanReadMember(suiteNamespaceId, suiteVisibility, member.namespaceId(), member.visibility())) { + return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.VISIBILITY_INCOMPATIBLE); + } + return SkillSuiteMemberAvailability.availableMember(); + } + + private boolean audienceCanReadMember( + Long suiteNamespaceId, + SkillVisibility suiteVisibility, + Long memberNamespaceId, + SkillVisibility memberVisibility + ) { + if (memberVisibility == SkillVisibility.PUBLIC) { + return true; + } + if (suiteVisibility == SkillVisibility.PUBLIC || !suiteNamespaceId.equals(memberNamespaceId)) { + return false; + } + if (suiteVisibility == SkillVisibility.NAMESPACE_ONLY) { + return memberVisibility == SkillVisibility.NAMESPACE_ONLY; + } + return suiteVisibility == SkillVisibility.PRIVATE; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberSelection.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberSelection.java new file mode 100644 index 00000000..c70bd4ad --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberSelection.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.domain.suite; + +/** + * Exact published Skill version selected for a Suite draft. + */ +public record SkillSuiteMemberSelection( + Long skillId, + Long skillVersionId, + String namespaceSlug, + String skillSlug, + String version, + String fingerprint +) { +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberState.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberState.java new file mode 100644 index 00000000..0ed539c8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberState.java @@ -0,0 +1,33 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; + +/** Current state resolved for an immutable member reference. Null IDs represent a tombstone. */ +public record SkillSuiteMemberState( + Long skillId, + Long skillVersionId, + Long namespaceId, + String displayName, + String summary, + NamespaceStatus namespaceStatus, + SkillVisibility visibility, + SkillStatus skillStatus, + boolean hidden, + SkillVersionStatus versionStatus, + boolean downloadReady, + boolean yanked, + boolean viewerCanRead +) { + public static SkillSuiteMemberState deleted(Long skillId, Long skillVersionId) { + return new SkillSuiteMemberState( + skillId, skillVersionId, null, null, null, null, null, null, + false, null, false, false, false); + } + + public boolean tombstoned() { + return skillId == null || skillVersionId == null || namespaceId == null; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberStateResolver.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberStateResolver.java new file mode 100644 index 00000000..0809cedd --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberStateResolver.java @@ -0,0 +1,112 @@ +package com.iflytek.skillhub.domain.suite; + +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.VisibilityChecker; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** Resolves live Skill state for immutable Suite member references without coordinate relinking. */ +@Service +public class SkillSuiteMemberStateResolver { + + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final NamespaceRepository namespaceRepository; + private final VisibilityChecker visibilityChecker; + + public SkillSuiteMemberStateResolver( + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + NamespaceRepository namespaceRepository, + VisibilityChecker visibilityChecker + ) { + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.namespaceRepository = namespaceRepository; + this.visibilityChecker = visibilityChecker; + } + + public List resolve(List members) { + return resolve(members, skill -> false); + } + + /** Resolves live member state and applies the canonical Skill visibility policy for this viewer. */ + public List resolveForViewer( + List members, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + return resolve(members, skill -> visibilityChecker.canAccess( + skill, userId, namespaceRoles, platformRoles)); + } + + private List resolve( + List members, + Predicate viewerAccess + ) { + List versionIds = members.stream() + .map(SkillSuiteVersionMember::getSkillVersionId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + Map versions = skillVersionRepository.findByIdIn(versionIds).stream() + .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + + List skillIds = versions.values().stream() + .map(SkillVersion::getSkillId) + .distinct() + .toList(); + Map skills = skillRepository.findByIdIn(skillIds).stream() + .collect(Collectors.toMap(Skill::getId, Function.identity())); + + List namespaceIds = skills.values().stream() + .map(Skill::getNamespaceId) + .distinct() + .toList(); + Map namespaces = namespaceRepository.findByIdIn(namespaceIds).stream() + .collect(Collectors.toMap(Namespace::getId, Function.identity())); + + return members.stream() + .map(member -> resolveOne(member, versions, skills, namespaces, viewerAccess)) + .toList(); + } + + private SkillSuiteMemberState resolveOne( + SkillSuiteVersionMember member, + Map versions, + Map skills, + Map namespaces, + Predicate viewerAccess + ) { + SkillVersion version = versions.get(member.getSkillVersionId()); + if (version == null || !java.util.Objects.equals(version.getSkillId(), member.getSkillId())) { + return SkillSuiteMemberState.deleted(member.getSkillId(), member.getSkillVersionId()); + } + Skill skill = skills.get(version.getSkillId()); + if (skill == null) { + return SkillSuiteMemberState.deleted(member.getSkillId(), member.getSkillVersionId()); + } + Namespace namespace = namespaces.get(skill.getNamespaceId()); + if (namespace == null) { + return SkillSuiteMemberState.deleted(member.getSkillId(), member.getSkillVersionId()); + } + return new SkillSuiteMemberState( + skill.getId(), version.getId(), skill.getNamespaceId(), skill.getDisplayName(), skill.getSummary(), + namespace.getStatus(), skill.getVisibility(), + skill.getStatus(), skill.isHidden(), version.getStatus(), version.isDownloadReady(), + version.getYankedAt() != null, viewerAccess.test(skill)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidator.java new file mode 100644 index 00000000..00826dfa --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidator.java @@ -0,0 +1,39 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** Revalidates exact members immediately before each Suite publication transition. */ +@Service +public class SkillSuitePublicationValidator { + + private final SkillSuiteVersionMemberRepository memberRepository; + private final SkillSuiteMemberStateResolver stateResolver; + + public SkillSuitePublicationValidator( + SkillSuiteVersionMemberRepository memberRepository, + SkillSuiteMemberStateResolver stateResolver + ) { + this.memberRepository = memberRepository; + this.stateResolver = stateResolver; + } + + public SkillSuiteAvailability validate(SkillSuite suite, SkillSuiteVersion version) { + List members = + memberRepository.findBySuiteVersionIdOrderByPosition(version.getId()); + if (members.isEmpty()) { + throw new DomainBadRequestException("error.suite.members.empty"); + } + SkillSuiteAvailability availability = SkillSuiteAvailability.evaluate( + suite.getNamespaceId(), version.getVisibility(), stateResolver.resolve(members)); + if (!availability.available()) { + String reasons = availability.blockedMembers().stream() + .map(blocked -> blocked.skillVersionId() + ":" + blocked.reason()) + .collect(java.util.stream.Collectors.joining(",")); + throw new DomainBadRequestException("error.suite.members.unavailable", reasons); + } + return availability; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryService.java new file mode 100644 index 00000000..eb4c8710 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryService.java @@ -0,0 +1,232 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.ReviewPermissionChecker; +import com.iflytek.skillhub.domain.review.ReviewSubjectType; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Resolves viewer-specific Suite details while keeping persisted lifecycle and live availability separate. */ +@Service +public class SkillSuiteQueryService { + + private final NamespaceRepository namespaceRepository; + private final SkillSuiteRepository suiteRepository; + private final SkillSuiteVersionRepository versionRepository; + private final SkillSuiteVersionMemberRepository memberRepository; + private final SkillSuiteMemberStateResolver stateResolver; + private final ReviewTaskRepository reviewTaskRepository; + private final ReviewPermissionChecker reviewPermissionChecker; + + public SkillSuiteQueryService( + NamespaceRepository namespaceRepository, + SkillSuiteRepository suiteRepository, + SkillSuiteVersionRepository versionRepository, + SkillSuiteVersionMemberRepository memberRepository, + SkillSuiteMemberStateResolver stateResolver, + ReviewTaskRepository reviewTaskRepository, + ReviewPermissionChecker reviewPermissionChecker + ) { + this.namespaceRepository = namespaceRepository; + this.suiteRepository = suiteRepository; + this.versionRepository = versionRepository; + this.memberRepository = memberRepository; + this.stateResolver = stateResolver; + this.reviewTaskRepository = reviewTaskRepository; + this.reviewPermissionChecker = reviewPermissionChecker; + } + + @Transactional(readOnly = true) + public Detail getDetail( + String namespaceSlug, + String suiteSlug, + String requestedVersion, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceSlug)); + SkillSuite suite = suiteRepository.findByNamespaceIdAndSlug(namespace.getId(), suiteSlug) + .orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteSlug)); + SkillSuiteVersion version = resolveVersion(suite, requestedVersion); + return assembleDetail(namespace, suite, version, userId, namespaceRoles, platformRoles); + } + + /** Resolves the immutable Suite version captured by an earlier idempotent install operation. */ + @Transactional(readOnly = true) + public Detail getDetailByVersionId( + String namespaceSlug, + String suiteSlug, + Long versionId, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceSlug)); + SkillSuite suite = suiteRepository.findByNamespaceIdAndSlug(namespace.getId(), suiteSlug) + .orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteSlug)); + SkillSuiteVersion version = versionRepository.findById(versionId) + .filter(candidate -> suite.getId().equals(candidate.getSuiteId())) + .orElseThrow(() -> new DomainNotFoundException("error.suite.version.notFound", versionId)); + return assembleDetail(namespace, suite, version, userId, namespaceRoles, platformRoles); + } + + private Detail assembleDetail( + Namespace namespace, + SkillSuite suite, + SkillSuiteVersion version, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + if (!canRead(namespace, suite, version, userId, namespaceRoles, platformRoles)) { + throw new DomainForbiddenException("error.suite.access.denied"); + } + + List snapshots = + memberRepository.findBySuiteVersionIdOrderByPosition(version.getId()); + List states = stateResolver.resolveForViewer( + snapshots, userId, namespaceRoles, platformRoles); + List memberDetails = new ArrayList<>(snapshots.size()); + for (int index = 0; index < snapshots.size(); index++) { + SkillSuiteMemberAvailability availability = new SkillSuiteMemberEligibilityPolicy().evaluate( + suite.getNamespaceId(), version.getVisibility(), states.get(index)); + memberDetails.add(new MemberDetail(snapshots.get(index), states.get(index), availability)); + } + boolean available = version.getStatus() == SkillSuiteVersionStatus.PUBLISHED + && suite.getStatus() == SkillSuiteStatus.ACTIVE + && !suite.isHidden() + && memberDetails.stream().allMatch(member -> member.availability().available()); + return new Detail(namespace, suite, version, available, memberDetails); + } + + @Transactional(readOnly = true) + public List listVersions( + String namespaceSlug, + String suiteSlug, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceSlug)); + SkillSuite suite = suiteRepository.findByNamespaceIdAndSlug(namespace.getId(), suiteSlug) + .orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteSlug)); + return versionRepository.findBySuiteId(suite.getId()).stream() + .filter(version -> canRead(namespace, suite, version, userId, namespaceRoles, platformRoles)) + .sorted(Comparator.comparing( + SkillSuiteVersion::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))) + .map(version -> new VersionSummary( + version.getId(), version.getVersion(), version.getStatus(), + version.getVisibility(), version.getPublishedAt(), + version.getYankedAt(), version.getCreatedAt())) + .toList(); + } + + private SkillSuiteVersion resolveVersion(SkillSuite suite, String requestedVersion) { + if (requestedVersion != null && !requestedVersion.isBlank()) { + return versionRepository.findBySuiteIdAndVersion(suite.getId(), requestedVersion) + .orElseThrow(() -> new DomainNotFoundException( + "error.suite.version.notFound", requestedVersion)); + } + if (suite.getLatestVersionId() == null) { + throw new DomainNotFoundException("error.suite.version.notFound", "latest"); + } + return versionRepository.findById(suite.getLatestVersionId()) + .orElseThrow(() -> new DomainNotFoundException( + "error.suite.version.notFound", suite.getLatestVersionId())); + } + + private boolean canRead( + Namespace namespace, + SkillSuite suite, + SkillSuiteVersion version, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + if (platformRoles.contains("SUPER_ADMIN")) { + return true; + } + NamespaceRole role = namespaceRoles.get(suite.getNamespaceId()); + boolean namespaceAdmin = role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; + boolean currentCreator = userId != null && userId.equals(suite.getCreatedBy()) && role != null; + + if (version.getStatus() == SkillSuiteVersionStatus.PENDING_REVIEW) { + return reviewTaskRepository.findBySubjectTypeAndSubjectVersionIdAndStatus( + ReviewSubjectType.SUITE_VERSION, version.getId(), ReviewTaskStatus.PENDING) + .map(task -> reviewPermissionChecker.canReadReview( + task, userId, namespace.getType(), namespaceRoles, platformRoles)) + .orElse(namespaceAdmin || platformRoles.contains("SKILL_ADMIN")); + } + if (version.getStatus() != SkillSuiteVersionStatus.PUBLISHED + && version.getStatus() != SkillSuiteVersionStatus.YANKED) { + if (namespaceAdmin || currentCreator || platformRoles.contains("SKILL_ADMIN")) { + return true; + } + return false; + } + if (suite.getStatus() != SkillSuiteStatus.ACTIVE || suite.isHidden()) { + if (namespaceAdmin || currentCreator) { + return true; + } + return false; + } + if (version.getVisibility() == SkillVisibility.PUBLIC) { + return true; + } + if (version.getVisibility() == SkillVisibility.NAMESPACE_ONLY && role != null) { + return true; + } + if (version.getVisibility() == SkillVisibility.PRIVATE && (namespaceAdmin || currentCreator)) { + return true; + } + return false; + } + + public record MemberDetail( + SkillSuiteVersionMember snapshot, + SkillSuiteMemberState state, + SkillSuiteMemberAvailability availability + ) { + } + + public record Detail( + Namespace namespace, + SkillSuite suite, + SkillSuiteVersion version, + boolean available, + List members + ) { + public Detail { + members = List.copyOf(members); + } + } + + public record VersionSummary( + Long id, + String version, + SkillSuiteVersionStatus status, + SkillVisibility visibility, + java.time.Instant publishedAt, + java.time.Instant yankedAt, + java.time.Instant createdAt + ) { + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteRepository.java new file mode 100644 index 00000000..71e85844 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteRepository.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.domain.suite; + +import java.util.List; +import java.util.Optional; + +/** Persistence contract for Suite containers. */ +public interface SkillSuiteRepository { + Optional findById(Long id); + Optional findByNamespaceIdAndSlug(Long namespaceId, String slug); + List findByIdIn(List ids); + List findByNamespaceId(Long namespaceId); + SkillSuite save(SkillSuite suite); + void delete(SkillSuite suite); + void incrementInstallRequestCount(Long suiteId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteStatus.java new file mode 100644 index 00000000..af6cf832 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteStatus.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.suite; + +/** Container lifecycle for a Skill Suite. Version publication is tracked separately. */ +public enum SkillSuiteStatus { + ACTIVE, + ARCHIVED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersion.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersion.java new file mode 100644 index 00000000..0ee2f5bd --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersion.java @@ -0,0 +1,228 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.Clock; +import java.time.Instant; + +/** + * Immutable-after-publication snapshot of a Suite definition and its approved visibility. + */ +@Entity +@Table(name = "skill_suite_version") +public class SkillSuiteVersion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "suite_id", nullable = false) + private Long suiteId; + + @Column(nullable = false, length = 64) + private String version; + + @Column(name = "display_name", nullable = false, length = 256) + private String displayName; + + @Column(columnDefinition = "TEXT") + private String summary; + + @Column(columnDefinition = "TEXT") + private String overview; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private SkillSuiteVersionStatus status; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private SkillVisibility visibility; + + @Column(columnDefinition = "TEXT") + private String changelog; + + @Column(name = "entry_skill_version_id") + private Long entrySkillVersionId; + + @Column(name = "published_at") + private Instant publishedAt; + + @Column(name = "yanked_at") + private Instant yankedAt; + + @Column(name = "yanked_by", length = 128) + private String yankedBy; + + @Column(name = "yank_reason", columnDefinition = "TEXT") + private String yankReason; + + @Column(name = "created_by", nullable = false, length = 128) + private String createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected SkillSuiteVersion() { + } + + public SkillSuiteVersion(Long suiteId, String version, SkillVisibility visibility, String createdBy) { + this.suiteId = suiteId; + this.version = version; + this.displayName = version; + this.visibility = visibility; + this.createdBy = createdBy; + this.status = SkillSuiteVersionStatus.DRAFT; + } + + public SkillSuiteVersion( + Long suiteId, + String version, + String displayName, + String summary, + SkillVisibility visibility, + String createdBy + ) { + this(suiteId, version, visibility, createdBy); + this.displayName = displayName; + this.summary = summary; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(Clock.systemUTC()); + } + + /** + * Guards all definition changes. Review and published snapshots must not drift in place. + */ + public void assertEditable() { + if (status != SkillSuiteVersionStatus.DRAFT) { + throw new DomainBadRequestException("error.suite.version.immutable", version); + } + } + + public Long getId() { + return id; + } + + public Long getSuiteId() { + return suiteId; + } + + public String getVersion() { + return version; + } + + public String getDisplayName() { + return displayName; + } + + public String getSummary() { + return summary; + } + + public String getOverview() { + return overview; + } + + public SkillSuiteVersionStatus getStatus() { + return status; + } + + public SkillVisibility getVisibility() { + return visibility; + } + + public String getChangelog() { + return changelog; + } + + public Long getEntrySkillVersionId() { + return entrySkillVersionId; + } + + public Instant getPublishedAt() { + return publishedAt; + } + + public Instant getYankedAt() { + return yankedAt; + } + + public String getYankedBy() { + return yankedBy; + } + + public String getYankReason() { + return yankReason; + } + + public String getCreatedBy() { + return createdBy; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setStatus(SkillSuiteVersionStatus status) { + this.status = status; + } + + public void setVisibility(SkillVisibility visibility) { + assertEditable(); + this.visibility = visibility; + } + + public void setDisplayName(String displayName) { + assertEditable(); + this.displayName = displayName; + } + + public void setSummary(String summary) { + assertEditable(); + this.summary = summary; + } + + public void setOverview(String overview) { + assertEditable(); + this.overview = overview; + } + + public void setChangelog(String changelog) { + assertEditable(); + this.changelog = changelog; + } + + public void setEntrySkillVersionId(Long entrySkillVersionId) { + assertEditable(); + this.entrySkillVersionId = entrySkillVersionId; + } + + public void setPublishedAt(Instant publishedAt) { + this.publishedAt = publishedAt; + } + + public void setYankedAt(Instant yankedAt) { + this.yankedAt = yankedAt; + } + + public void setYankedBy(String yankedBy) { + this.yankedBy = yankedBy; + } + + public void setYankReason(String yankReason) { + this.yankReason = yankReason; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionMember.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionMember.java new file mode 100644 index 00000000..1e7dfad2 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionMember.java @@ -0,0 +1,70 @@ +package com.iflytek.skillhub.domain.suite; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * Exact Skill version reference captured by a Suite version. + * Snapshot fields remain populated when governance hard-deletes the referenced Skill or version. + */ +@Entity +@Table(name = "skill_suite_version_member") +public class SkillSuiteVersionMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "suite_version_id", nullable = false) + private Long suiteVersionId; + + @Column(name = "skill_id") + private Long skillId; + + @Column(name = "skill_version_id") + private Long skillVersionId; + + @Column(nullable = false) + private Integer position; + + @Column(name = "namespace_slug_snapshot", nullable = false, length = 128) + private String namespaceSlugSnapshot; + + @Column(name = "skill_slug_snapshot", nullable = false, length = 128) + private String skillSlugSnapshot; + + @Column(name = "skill_version_snapshot", nullable = false, length = 64) + private String skillVersionSnapshot; + + // Fingerprints include the algorithm prefix (for example, "sha256:") and the digest. + @Column(name = "fingerprint_snapshot", nullable = false, length = 255) + private String fingerprintSnapshot; + + protected SkillSuiteVersionMember() { + } + + public SkillSuiteVersionMember(Long suiteVersionId, SkillSuiteMemberSelection selection, int position) { + this.suiteVersionId = suiteVersionId; + this.skillId = selection.skillId(); + this.skillVersionId = selection.skillVersionId(); + this.position = position; + this.namespaceSlugSnapshot = selection.namespaceSlug(); + this.skillSlugSnapshot = selection.skillSlug(); + this.skillVersionSnapshot = selection.version(); + this.fingerprintSnapshot = selection.fingerprint(); + } + + public Long getId() { return id; } + public Long getSuiteVersionId() { return suiteVersionId; } + public Long getSkillId() { return skillId; } + public Long getSkillVersionId() { return skillVersionId; } + public Integer getPosition() { return position; } + public String getNamespaceSlugSnapshot() { return namespaceSlugSnapshot; } + public String getSkillSlugSnapshot() { return skillSlugSnapshot; } + public String getSkillVersionSnapshot() { return skillVersionSnapshot; } + public String getFingerprintSnapshot() { return fingerprintSnapshot; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionMemberRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionMemberRepository.java new file mode 100644 index 00000000..81d6b69e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionMemberRepository.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.suite; + +import java.util.List; + +/** Persistence contract for ordered Suite member snapshots. */ +public interface SkillSuiteVersionMemberRepository { + List findBySuiteVersionIdOrderByPosition(Long suiteVersionId); + List saveAll(List members); + void deleteBySuiteVersionId(Long suiteVersionId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionRepository.java new file mode 100644 index 00000000..072925d0 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionRepository.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.domain.suite; + +import java.util.List; +import java.util.Optional; + +/** Persistence contract for immutable Suite version snapshots. */ +public interface SkillSuiteVersionRepository { + Optional findById(Long id); + Optional findBySuiteIdAndVersion(Long suiteId, String version); + List findByIdIn(List ids); + List findBySuiteId(Long suiteId); + List findBySuiteIdAndStatus(Long suiteId, SkillSuiteVersionStatus status); + SkillSuiteVersion save(SkillSuiteVersion version); + void delete(SkillSuiteVersion version); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionStatus.java new file mode 100644 index 00000000..29672bf7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionStatus.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.domain.suite; + +/** + * Lifecycle states for Suite definitions. Suite versions never enter package scanning states. + */ +public enum SkillSuiteVersionStatus { + DRAFT, + PENDING_REVIEW, + PUBLISHED, + REJECTED, + YANKED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/package-info.java new file mode 100644 index 00000000..d8628f4c --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/package-info.java @@ -0,0 +1,4 @@ +/** + * Skill Suite aggregates, version snapshots, composition policies, and repository contracts. + */ +package com.iflytek.skillhub.domain.suite; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewTaskSubjectTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewTaskSubjectTest.java new file mode 100644 index 00000000..9d3ba38b --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewTaskSubjectTest.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.domain.review; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ReviewTaskSubjectTest { + + @Test + void existingSkillConstructorPopulatesTypedSubject() { + ReviewTask task = new ReviewTask(20L, 10L, 1L, "1.0.0", "author"); + + assertThat(task.getSubjectType()).isEqualTo(ReviewSubjectType.SKILL_VERSION); + assertThat(task.getSubjectId()).isEqualTo(10L); + assertThat(task.getSubjectVersionId()).isEqualTo(20L); + assertThat(task.getSubjectVersion()).isEqualTo("1.0.0"); + } + + @Test + void suiteFactoryDoesNotPretendSuiteIsASkill() { + ReviewTask task = ReviewTask.forSuiteVersion(40L, 30L, 1L, "2.0.0", "author"); + + assertThat(task.getSubjectType()).isEqualTo(ReviewSubjectType.SUITE_VERSION); + assertThat(task.getSubjectId()).isEqualTo(30L); + assertThat(task.getSubjectVersionId()).isEqualTo(40L); + assertThat(task.getSkillId()).isNull(); + assertThat(task.getSkillVersionId()).isNull(); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteAvailabilityTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteAvailabilityTest.java new file mode 100644 index 00000000..2b1a904a --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteAvailabilityTest.java @@ -0,0 +1,34 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SkillSuiteAvailabilityTest { + + @Test + void suiteIsDegradedWhenAnyExactMemberIsBlocked() { + SkillSuiteMemberState available = member(10L, 100L, true); + SkillSuiteMemberState unavailable = member(20L, 200L, false); + + SkillSuiteAvailability result = SkillSuiteAvailability.evaluate( + 1L, SkillVisibility.PUBLIC, List.of(available, unavailable)); + + assertThat(result.available()).isFalse(); + assertThat(result.blockedMembers()).containsExactly( + new SkillSuiteBlockedMember(200L, SkillSuiteMemberBlockingReason.VERSION_UNAVAILABLE)); + } + + private SkillSuiteMemberState member(Long skillId, Long versionId, boolean downloadReady) { + return new SkillSuiteMemberState( + skillId, versionId, 1L, null, null, + NamespaceStatus.ACTIVE, SkillVisibility.PUBLIC, SkillStatus.ACTIVE, false, + SkillVersionStatus.PUBLISHED, downloadReady, false, false); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicyTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicyTest.java new file mode 100644 index 00000000..d99ca266 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicyTest.java @@ -0,0 +1,66 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.stream.LongStream; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SkillSuiteCompositionPolicyTest { + + @Test + void rejectsEmptySuite() { + assertThatThrownBy(() -> SkillSuiteCompositionPolicy.validate(List.of(), null)) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.members.empty"); + } + + @Test + void rejectsTwoVersionsOfTheSameSkill() { + List members = List.of( + member(10L, 101L, "1.0.0"), + member(10L, 102L, "2.0.0") + ); + + assertThatThrownBy(() -> SkillSuiteCompositionPolicy.validate(members, null)) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.members.duplicate"); + } + + @Test + void rejectsMoreThanOneHundredMembers() { + List members = LongStream.rangeClosed(1, 101) + .mapToObj(id -> member(id, id + 1000, "1.0.0")) + .toList(); + + assertThatThrownBy(() -> SkillSuiteCompositionPolicy.validate(members, null)) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.members.limit"); + } + + @Test + void rejectsEntryVersionThatIsNotAMember() { + List members = List.of(member(10L, 101L, "1.0.0")); + + assertThatThrownBy(() -> SkillSuiteCompositionPolicy.validate(members, 999L)) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.entry.notMember"); + } + + private SkillSuiteMemberSelection member(Long skillId, Long versionId, String version) { + return new SkillSuiteMemberSelection( + skillId, + versionId, + "global", + "writer", + version, + "sha256-" + versionId + ); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java new file mode 100644 index 00000000..0ba27b51 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java @@ -0,0 +1,130 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.junit.jupiter.api.BeforeEach; +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.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SkillSuiteDraftServiceTest { + + @Mock private SkillSuiteRepository suiteRepository; + @Mock private SkillSuiteVersionRepository versionRepository; + @Mock private SkillSuiteVersionMemberRepository memberRepository; + @Mock private NamespaceRepository namespaceRepository; + @Mock private SkillSuitePublicationValidator publicationValidator; + @Mock private AuditLogService auditLogService; + + private SkillSuiteDraftService service; + + @BeforeEach + void setUp() { + service = new SkillSuiteDraftService( + suiteRepository, versionRepository, memberRepository, namespaceRepository, + publicationValidator, auditLogService); + } + + @Test + void createsOrderedExactMemberSnapshots() { + Namespace namespace = new Namespace("team", "Team", "owner"); + setId(namespace, 1L); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(namespace)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "writers")).thenReturn(Optional.empty()); + when(suiteRepository.save(any())).thenAnswer(invocation -> { + SkillSuite suite = invocation.getArgument(0); + setId(suite, 10L); + return suite; + }); + when(versionRepository.save(any())).thenAnswer(invocation -> { + SkillSuiteVersion version = invocation.getArgument(0); + setId(version, 20L); + return version; + }); + when(memberRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + SkillSuiteMemberSelection member = new SkillSuiteMemberSelection( + 30L, 40L, "global", "writer", "1.0.0", "sha256:abc"); + SkillSuiteDraftService.CreatedDraft result = service.create( + new CreateSkillSuiteDraftCommand( + 1L, "writers", "Writers", "Writing tools", "## Start here", "1.0.0", + SkillVisibility.PRIVATE, null, 40L, List.of(member)), + new SkillSuiteActionContext( + "author", Map.of(1L, NamespaceRole.MEMBER), Set.of(), + "request-1", "127.0.0.1", "test")); + + assertThat(result.members()).singleElement().satisfies(saved -> { + assertThat(saved.getSuiteVersionId()).isEqualTo(20L); + assertThat(saved.getSkillVersionId()).isEqualTo(40L); + assertThat(saved.getPosition()).isZero(); + assertThat(saved.getFingerprintSnapshot()).isEqualTo("sha256:abc"); + }); + assertThat(result.version().getOverview()).isEqualTo("## Start here"); + verify(publicationValidator).validate(result.suite(), result.version()); + } + + @Test + void updatesOnlyAnEditableDraftAndReplacesItsExactMemberSnapshots() { + Namespace namespace = new Namespace("team", "Team", "owner"); + setId(namespace, 1L); + SkillSuite suite = new SkillSuite(1L, "writers", "Writers", "author"); + setId(suite, 10L); + SkillSuiteVersion version = new SkillSuiteVersion( + 10L, "2.0.0", SkillVisibility.PUBLIC, "author"); + setId(version, 20L); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(namespace)); + when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite)); + when(versionRepository.findById(20L)).thenReturn(Optional.of(version)); + when(versionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(memberRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + SkillSuiteMemberSelection replacement = new SkillSuiteMemberSelection( + 31L, 41L, "global", "editor", "2.1.0", "sha256:def"); + SkillSuiteDraftService.CreatedDraft result = service.updateDraft( + 10L, + 20L, + new CreateSkillSuiteDraftCommand( + 1L, "writers", "Writing Suite", "Updated", "## Updated flow", "2.0.0", + SkillVisibility.PRIVATE, "Changed members", 41L, List.of(replacement)), + new SkillSuiteActionContext( + "author", Map.of(1L, NamespaceRole.MEMBER), Set.of(), + "request-2", "127.0.0.1", "test")); + + assertThat(result.suite().getDisplayName()).isEqualTo("Writers"); + assertThat(result.version().getDisplayName()).isEqualTo("Writing Suite"); + assertThat(result.version().getSummary()).isEqualTo("Updated"); + assertThat(result.version().getOverview()).isEqualTo("## Updated flow"); + assertThat(result.version().getVisibility()).isEqualTo(SkillVisibility.PRIVATE); + assertThat(result.members()).singleElement().satisfies(saved -> { + assertThat(saved.getSkillVersionId()).isEqualTo(41L); + assertThat(saved.getPosition()).isZero(); + }); + verify(memberRepository).deleteBySuiteVersionId(20L); + verify(publicationValidator).validate(suite, version); + } + + private void setId(Object target, Long id) { + try { + var field = target.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(target, id); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java new file mode 100644 index 00000000..98f9a065 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java @@ -0,0 +1,423 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.review.ReviewPermissionChecker; +import com.iflytek.skillhub.domain.review.ReviewSubjectType; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.junit.jupiter.api.BeforeEach; +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.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +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.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SkillSuiteLifecycleServiceTest { + + @Mock private SkillSuiteRepository suiteRepository; + @Mock private SkillSuiteVersionRepository versionRepository; + @Mock private ReviewTaskRepository reviewTaskRepository; + @Mock private NamespaceRepository namespaceRepository; + @Mock private SkillSuitePublicationValidator publicationValidator; + @Mock private AuditLogService auditLogService; + + private SkillSuiteLifecycleService service; + private SkillSuite suite; + private SkillSuiteVersion version; + + @BeforeEach + void setUp() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-09-07T08:00:00Z"), ZoneOffset.UTC); + service = new SkillSuiteLifecycleService( + suiteRepository, versionRepository, reviewTaskRepository, new ReviewPermissionChecker(), namespaceRepository, + publicationValidator, auditLogService, clock, true); + suite = new SkillSuite(1L, "writers", "Writers", "author"); + version = new SkillSuiteVersion(10L, "1.0.0", SkillVisibility.PRIVATE, "author"); + setId(suite, 10L); + setId(version, 20L); + } + + @Test + void privateDraftPublishesDirectlyAndUpdatesLatestVersion() { + stubLoaded(); + stubVersionAndSuiteSaves(); + service.confirmPrivatePublish(10L, 20L, context()); + + assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.PUBLISHED); + assertThat(version.getPublishedAt()).isEqualTo(Instant.parse("2026-09-07T08:00:00Z")); + assertThat(suite.getLatestVersionId()).isEqualTo(20L); + verify(publicationValidator).validate(suite, version); + verify(auditLogService).record( + "author", "PUBLISH_SKILL_SUITE_VERSION", "SKILL_SUITE_VERSION", 20L, + "request-1", "127.0.0.1", "test", null); + } + + @Test + void publicDraftCreatesTypedReviewAndBecomesPending() { + version.setVisibility(SkillVisibility.PUBLIC); + stubLoaded(); + when(versionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(reviewTaskRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + ReviewTask task = service.submitForReview(10L, 20L, context()); + + assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.PENDING_REVIEW); + assertThat(task.getSubjectType()).isEqualTo(ReviewSubjectType.SUITE_VERSION); + assertThat(task.getSubjectId()).isEqualTo(10L); + assertThat(task.getSubjectVersionId()).isEqualTo(20L); + verify(publicationValidator).validate(suite, version); + } + + @Test + void teamNamespaceOwnerCanApproveAndPublishSubmittedSuite() throws Exception { + version.setVisibility(SkillVisibility.PUBLIC); + version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + ReviewTask task = pendingReview("author"); + stubLoaded(); + stubVersionAndSuiteSaves(); + when(reviewTaskRepository.findById(30L)).thenReturn(Optional.of(task)); + when(reviewTaskRepository.updateStatusWithVersion( + 30L, ReviewTaskStatus.APPROVED, "owner", "Looks good", 1)).thenReturn(1); + + service.approveReview(30L, "Looks good", adminContext()); + + assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.PUBLISHED); + assertThat(suite.getLatestVersionId()).isEqualTo(20L); + assertThat(task.getStatus()).isEqualTo(ReviewTaskStatus.APPROVED); + } + + @Test + void approvalFailureKeepsTheReviewAndSuiteVersionPending() throws Exception { + version.setVisibility(SkillVisibility.PUBLIC); + version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + ReviewTask task = pendingReview("author"); + stubLoaded(); + when(reviewTaskRepository.findById(30L)).thenReturn(Optional.of(task)); + doThrow(new DomainBadRequestException("error.suite.member.unavailable")) + .when(publicationValidator).validate(suite, version); + + assertThatThrownBy(() -> service.approveReview(30L, "Looks good", adminContext())) + .isInstanceOf(DomainBadRequestException.class); + + assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.PENDING_REVIEW); + assertThat(task.getStatus()).isEqualTo(ReviewTaskStatus.PENDING); + verify(reviewTaskRepository, never()).updateStatusWithVersion(any(), any(), any(), any(), any()); + verify(versionRepository, never()).save(any()); + verify(suiteRepository, never()).save(any()); + } + + @Test + void rejectRequiresTheSuiteVersionToRemainPending() throws Exception { + version.setVisibility(SkillVisibility.PUBLIC); + ReviewTask task = pendingReview("author"); + stubLoaded(); + when(reviewTaskRepository.findById(30L)).thenReturn(Optional.of(task)); + + assertThatThrownBy(() -> service.rejectReview(30L, "Outdated task", adminContext())) + .isInstanceOf(DomainBadRequestException.class); + + assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.DRAFT); + assertThat(task.getStatus()).isEqualTo(ReviewTaskStatus.PENDING); + verify(reviewTaskRepository, never()).updateStatusWithVersion(any(), any(), any(), any(), any()); + verify(versionRepository, never()).save(any()); + } + + @Test + void globalNamespaceReviewerCannotApproveOwnSubmission() throws Exception { + version.setVisibility(SkillVisibility.PUBLIC); + version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + ReviewTask task = pendingReview("owner"); + Namespace namespace = namespace(NamespaceType.GLOBAL, NamespaceStatus.ACTIVE); + stubLoaded(namespace); + when(reviewTaskRepository.findById(30L)).thenReturn(Optional.of(task)); + + assertThatThrownBy(() -> service.approveReview(30L, "self review", adminContext())) + .isInstanceOf(DomainForbiddenException.class); + + verify(reviewTaskRepository, never()).updateStatusWithVersion( + any(), any(), any(), any(), any()); + } + + @Test + void frozenNamespaceCannotAcceptNewReviewSubmissions() { + version.setVisibility(SkillVisibility.PUBLIC); + stubLoaded(namespace(NamespaceType.TEAM, NamespaceStatus.FROZEN)); + + assertThatThrownBy(() -> service.submitForReview(10L, 20L, context())) + .isInstanceOf(DomainBadRequestException.class); + + verify(reviewTaskRepository, never()).save(any()); + } + + @Test + void rolloutGateRejectsTypedReviewWritesBeforeLoadingTheSuite() { + SkillSuiteLifecycleService disabled = new SkillSuiteLifecycleService( + suiteRepository, versionRepository, reviewTaskRepository, new ReviewPermissionChecker(), + namespaceRepository, publicationValidator, auditLogService, + Clock.systemUTC(), false); + + assertThatThrownBy(() -> disabled.submitForReview(10L, 20L, context())) + .isInstanceOf(DomainBadRequestException.class); + + verify(suiteRepository, never()).findById(any()); + } + + @Test + void rolloutGateOmitsSubmitFromAllowedActions() { + version.setVisibility(SkillVisibility.PUBLIC); + SkillSuiteLifecycleService disabled = new SkillSuiteLifecycleService( + suiteRepository, versionRepository, reviewTaskRepository, new ReviewPermissionChecker(), + namespaceRepository, publicationValidator, auditLogService, + Clock.systemUTC(), false); + + Set actions = disabled.allowedActions( + suite, version, namespace(NamespaceType.TEAM, NamespaceStatus.ACTIVE), context()); + + assertThat(actions).containsExactly(SkillSuiteAllowedAction.EDIT); + } + + @Test + void yankingLatestVersionRecalculatesLatestPublishedSnapshot() throws Exception { + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + version.setPublishedAt(Instant.parse("2026-09-07T07:00:00Z")); + suite.setLatestVersionId(20L); + SkillSuiteVersion previous = new SkillSuiteVersion( + 10L, "0.9.0", "Previous", "Previous summary", SkillVisibility.PUBLIC, "author"); + setId(previous, 19L); + previous.setStatus(SkillSuiteVersionStatus.PUBLISHED); + previous.setPublishedAt(Instant.parse("2026-09-06T07:00:00Z")); + stubLoaded(); + stubVersionAndSuiteSaves(); + when(versionRepository.findBySuiteIdAndStatus(10L, SkillSuiteVersionStatus.PUBLISHED)) + .thenReturn(List.of(previous)); + + service.yank(10L, 20L, "superseded", adminContext()); + + assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.YANKED); + assertThat(suite.getLatestVersionId()).isEqualTo(19L); + assertThat(suite.getDisplayName()).isEqualTo("Previous"); + assertThat(suite.getSummary()).isEqualTo("Previous summary"); + } + + @Test + void hideAndRestoreChangeOnlyTheSuiteGovernanceOverlay() { + when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite)); + when(suiteRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.setHidden(10L, true, adminContext()); + assertThat(suite.isHidden()).isTrue(); + assertThat(suite.getHiddenAt()).isEqualTo(Instant.parse("2026-09-07T08:00:00Z")); + + service.setHidden(10L, false, adminContext()); + assertThat(suite.isHidden()).isFalse(); + assertThat(suite.getHiddenAt()).isNull(); + verify(suiteRepository, org.mockito.Mockito.times(2)).save(suite); + } + + @Test + void deleteRejectsSuiteWithPendingReview() { + version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite)); + when(versionRepository.findBySuiteId(10L)).thenReturn(List.of(version)); + + assertThatThrownBy(() -> service.delete(10L, adminContext())) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class); + + verify(suiteRepository, never()).delete(any()); + verify(reviewTaskRepository, never()).deleteBySubjectTypeAndSubjectId(any(), any()); + } + + @Test + void deleteRemovesSuiteReviewTasksBeforeHardDeletingContainer() { + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite)); + when(versionRepository.findBySuiteId(10L)).thenReturn(List.of(version)); + + service.delete(10L, adminContext()); + + var ordered = org.mockito.Mockito.inOrder(reviewTaskRepository, suiteRepository); + ordered.verify(reviewTaskRepository) + .deleteBySubjectTypeAndSubjectId(ReviewSubjectType.SUITE_VERSION, 10L); + ordered.verify(suiteRepository).delete(suite); + } + + @Test + void deleteDoesNotMutatePersistenceWhenAuditWriteFails() { + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite)); + when(versionRepository.findBySuiteId(10L)).thenReturn(List.of(version)); + doThrow(new IllegalStateException("audit unavailable")) + .when(auditLogService).record(any(), any(), any(), any(), any(), any(), any(), any()); + + assertThatThrownBy(() -> service.delete(10L, adminContext())) + .isInstanceOf(IllegalStateException.class); + + verify(reviewTaskRepository, never()).deleteBySubjectTypeAndSubjectId(any(), any()); + verify(suiteRepository, never()).delete(any()); + } + + @Test + void allowedActionsExposeOnlyEditableDraftCommandsToItsCreator() { + version.setVisibility(SkillVisibility.PUBLIC); + + Set actions = service.allowedActions( + suite, version, namespace(NamespaceType.TEAM, NamespaceStatus.ACTIVE), context()); + + assertThat(actions).containsExactlyInAnyOrder( + SkillSuiteAllowedAction.EDIT, + SkillSuiteAllowedAction.SUBMIT); + } + + @Test + void allowedActionsExposePublishedVersionGovernanceToNamespaceOwner() { + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + when(versionRepository.findBySuiteIdAndStatus(10L, SkillSuiteVersionStatus.PENDING_REVIEW)) + .thenReturn(List.of()); + + Set actions = service.allowedActions( + suite, version, namespace(NamespaceType.TEAM, NamespaceStatus.ACTIVE), adminContext()); + + assertThat(actions).containsExactlyInAnyOrder( + SkillSuiteAllowedAction.CREATE_VERSION, + SkillSuiteAllowedAction.YANK, + SkillSuiteAllowedAction.HIDE, + SkillSuiteAllowedAction.ARCHIVE, + SkillSuiteAllowedAction.DELETE); + } + + @Test + void allowedActionsRespectContainerStateAndPendingReview() { + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + suite.setHidden(true); + suite.setStatus(SkillSuiteStatus.ARCHIVED); + SkillSuiteVersion pending = new SkillSuiteVersion( + 10L, "2.0.0", SkillVisibility.PUBLIC, "author"); + pending.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + when(versionRepository.findBySuiteIdAndStatus(10L, SkillSuiteVersionStatus.PENDING_REVIEW)) + .thenReturn(List.of(pending)); + + Set actions = service.allowedActions( + suite, version, namespace(NamespaceType.TEAM, NamespaceStatus.ACTIVE), adminContext()); + + assertThat(actions).containsExactlyInAnyOrder( + SkillSuiteAllowedAction.YANK, + SkillSuiteAllowedAction.RESTORE, + SkillSuiteAllowedAction.UNARCHIVE); + } + + @Test + void allowedActionsAreEmptyAfterCreatorLeavesNamespace() { + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + SkillSuiteActionContext formerCreator = new SkillSuiteActionContext( + "author", Map.of(), Set.of(), null, null, null); + + Set actions = service.allowedActions( + suite, version, namespace(NamespaceType.TEAM, NamespaceStatus.ACTIVE), formerCreator); + + assertThat(actions).isEmpty(); + } + + @Test + void formerCreatorCannotReopenARejectedVersionAfterLeavingTheNamespace() { + version.setStatus(SkillSuiteVersionStatus.REJECTED); + stubLoaded(); + SkillSuiteActionContext formerCreator = new SkillSuiteActionContext( + "author", Map.of(), Set.of(), null, null, null); + + assertThatThrownBy(() -> service.reopenRejected(10L, 20L, formerCreator)) + .isInstanceOf(DomainForbiddenException.class); + + assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.REJECTED); + verify(versionRepository, never()).save(any()); + verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void suiteCreatorKeepsDraftManagementWhenAnAdministratorCreatedTheVersion() throws Exception { + version = new SkillSuiteVersion(10L, "1.0.0", SkillVisibility.PUBLIC, "delegated-admin"); + setId(version, 20L); + + Set creatorActions = service.allowedActions( + suite, version, namespace(NamespaceType.TEAM, NamespaceStatus.ACTIVE), context()); + SkillSuiteActionContext delegatedMember = new SkillSuiteActionContext( + "delegated-admin", Map.of(1L, NamespaceRole.MEMBER), Set.of(), null, null, null); + Set delegatedActions = service.allowedActions( + suite, version, namespace(NamespaceType.TEAM, NamespaceStatus.ACTIVE), delegatedMember); + + assertThat(creatorActions).containsExactlyInAnyOrder( + SkillSuiteAllowedAction.EDIT, + SkillSuiteAllowedAction.SUBMIT); + assertThat(delegatedActions).isEmpty(); + } + + private SkillSuiteActionContext context() { + return new SkillSuiteActionContext( + "author", Map.of(1L, NamespaceRole.MEMBER), Set.of(), + "request-1", "127.0.0.1", "test"); + } + + private SkillSuiteActionContext adminContext() { + return new SkillSuiteActionContext( + "owner", Map.of(1L, NamespaceRole.OWNER), Set.of(), + "request-2", "127.0.0.1", "test"); + } + + private void stubLoaded() { + stubLoaded(namespace(NamespaceType.TEAM, NamespaceStatus.ACTIVE)); + } + + private void stubLoaded(Namespace namespace) { + when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite)); + when(versionRepository.findById(20L)).thenReturn(Optional.of(version)); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(namespace)); + } + + private void stubVersionAndSuiteSaves() { + when(versionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(suiteRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + } + + private Namespace namespace(NamespaceType type, NamespaceStatus status) { + Namespace namespace = new Namespace("team", "Team", "owner"); + namespace.setType(type); + namespace.setStatus(status); + return namespace; + } + + private ReviewTask pendingReview(String submittedBy) throws Exception { + ReviewTask task = ReviewTask.forSuiteVersion(20L, 10L, 1L, "1.0.0", submittedBy); + setId(task, 30L); + return task; + } + + private void setId(Object target, Long id) throws Exception { + var field = target.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(target, id); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberEligibilityPolicyTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberEligibilityPolicyTest.java new file mode 100644 index 00000000..fee935fe --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberEligibilityPolicyTest.java @@ -0,0 +1,87 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SkillSuiteMemberEligibilityPolicyTest { + + private final SkillSuiteMemberEligibilityPolicy policy = new SkillSuiteMemberEligibilityPolicy(); + + @Test + void publicSuiteRejectsNonPublicMember() { + SkillSuiteMemberState member = member(2L, SkillVisibility.NAMESPACE_ONLY); + + SkillSuiteMemberAvailability result = policy.evaluate(1L, SkillVisibility.PUBLIC, member); + + assertThat(result.available()).isFalse(); + assertThat(result.reason()).isEqualTo(SkillSuiteMemberBlockingReason.VISIBILITY_INCOMPATIBLE); + } + + @Test + void privateSuiteAcceptsPrivateMemberFromSameNamespace() { + SkillSuiteMemberState member = member(1L, SkillVisibility.PRIVATE); + + SkillSuiteMemberAvailability result = policy.evaluate(1L, SkillVisibility.PRIVATE, member); + + assertThat(result.available()).isTrue(); + } + + @Test + void deletedReferenceProducesTombstoneInsteadOfRelinkingByCoordinate() { + SkillSuiteMemberState member = SkillSuiteMemberState.deleted(10L, 20L); + + SkillSuiteMemberAvailability result = policy.evaluate(1L, SkillVisibility.PRIVATE, member); + + assertThat(result.available()).isFalse(); + assertThat(result.reason()).isEqualTo(SkillSuiteMemberBlockingReason.DELETED); + } + + @Test + void unpublishedOrNotDownloadReadyVersionIsUnavailable() { + SkillSuiteMemberState member = new SkillSuiteMemberState( + 10L, 20L, 1L, null, null, + NamespaceStatus.ACTIVE, SkillVisibility.PUBLIC, SkillStatus.ACTIVE, false, + SkillVersionStatus.PUBLISHED, false, false, false); + + SkillSuiteMemberAvailability result = policy.evaluate(1L, SkillVisibility.PUBLIC, member); + + assertThat(result.available()).isFalse(); + assertThat(result.reason()).isEqualTo(SkillSuiteMemberBlockingReason.VERSION_UNAVAILABLE); + } + + @Test + void archivedMemberNamespaceMakesExactMemberUnavailable() { + SkillSuiteMemberState member = new SkillSuiteMemberState( + 10L, 20L, 1L, null, null, NamespaceStatus.ARCHIVED, SkillVisibility.PUBLIC, + SkillStatus.ACTIVE, false, SkillVersionStatus.PUBLISHED, true, false, false); + + SkillSuiteMemberAvailability result = policy.evaluate(1L, SkillVisibility.PUBLIC, member); + + assertThat(result.available()).isFalse(); + assertThat(result.reason()).isEqualTo(SkillSuiteMemberBlockingReason.NAMESPACE_ARCHIVED); + } + + @Test + void frozenMemberNamespaceHasItsOwnBlockingReason() { + SkillSuiteMemberState member = new SkillSuiteMemberState( + 10L, 20L, 1L, null, null, NamespaceStatus.FROZEN, SkillVisibility.PUBLIC, + SkillStatus.ACTIVE, false, SkillVersionStatus.PUBLISHED, true, false, false); + + SkillSuiteMemberAvailability result = policy.evaluate(1L, SkillVisibility.PUBLIC, member); + + assertThat(result.available()).isFalse(); + assertThat(result.reason()).isEqualTo(SkillSuiteMemberBlockingReason.NAMESPACE_FROZEN); + } + + private SkillSuiteMemberState member(Long namespaceId, SkillVisibility visibility) { + return new SkillSuiteMemberState( + 10L, 20L, namespaceId, null, null, + NamespaceStatus.ACTIVE, visibility, SkillStatus.ACTIVE, false, + SkillVersionStatus.PUBLISHED, true, false, false); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberStateResolverTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberStateResolverTest.java new file mode 100644 index 00000000..146bd541 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberStateResolverTest.java @@ -0,0 +1,72 @@ +package com.iflytek.skillhub.domain.suite; + +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.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.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SkillSuiteMemberStateResolverTest { + + @Test + void appliesCanonicalSkillVisibilityRulesToMemberBrowsing() { + SkillRepository skillRepository = mock(SkillRepository.class); + SkillVersionRepository versionRepository = mock(SkillVersionRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillSuiteMemberStateResolver resolver = new SkillSuiteMemberStateResolver( + skillRepository, versionRepository, namespaceRepository, new VisibilityChecker()); + + Namespace namespace = new Namespace("team", "Team", "owner"); + Skill skill = new Skill(1L, "private-helper", "skill-owner", SkillVisibility.PRIVATE); + skill.setDisplayName("Private Helper"); + skill.setSummary("Private metadata"); + skill.setLatestVersionId(40L); + SkillVersion version = new SkillVersion(30L, "1.0.0", "skill-owner"); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + setId(namespace, 1L); + setId(skill, 30L); + setId(version, 40L); + + SkillSuiteVersionMember member = new SkillSuiteVersionMember( + 20L, + new SkillSuiteMemberSelection( + 30L, 40L, "team", "private-helper", "1.0.0", "sha256:private"), + 0); + when(versionRepository.findByIdIn(List.of(40L))).thenReturn(List.of(version)); + when(skillRepository.findByIdIn(List.of(30L))).thenReturn(List.of(skill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); + + SkillSuiteMemberState ordinaryMember = resolver.resolveForViewer( + List.of(member), "suite-author", Map.of(1L, NamespaceRole.MEMBER), Set.of()).getFirst(); + SkillSuiteMemberState skillOwner = resolver.resolveForViewer( + List.of(member), "skill-owner", Map.of(1L, NamespaceRole.MEMBER), Set.of()).getFirst(); + + assertThat(ordinaryMember.viewerCanRead()).isFalse(); + assertThat(skillOwner.viewerCanRead()).isTrue(); + } + + private void setId(Object target, Long id) { + try { + var field = target.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(target, id); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java new file mode 100644 index 00000000..f40545e1 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java @@ -0,0 +1,227 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.review.ReviewPermissionChecker; +import com.iflytek.skillhub.domain.review.ReviewSubjectType; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.junit.jupiter.api.BeforeEach; +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.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SkillSuiteQueryServiceTest { + + @Mock private NamespaceRepository namespaceRepository; + @Mock private SkillSuiteRepository suiteRepository; + @Mock private SkillSuiteVersionRepository versionRepository; + @Mock private SkillSuiteVersionMemberRepository memberRepository; + @Mock private SkillSuiteMemberStateResolver stateResolver; + @Mock private ReviewTaskRepository reviewTaskRepository; + + private SkillSuiteQueryService service; + + @BeforeEach + void setUp() { + service = new SkillSuiteQueryService( + namespaceRepository, suiteRepository, versionRepository, memberRepository, stateResolver, + reviewTaskRepository, new ReviewPermissionChecker()); + } + + @Test + void anonymousUserReadsPublishedPublicSuiteWithOrderedAvailability() { + Namespace namespace = new Namespace("global", "Global", "system"); + SkillSuite suite = new SkillSuite(1L, "writers", "Writers", "author"); + SkillSuiteVersion version = new SkillSuiteVersion(10L, "1.0.0", SkillVisibility.PUBLIC, "author"); + SkillSuiteVersionMember member = new SkillSuiteVersionMember( + 20L, + new SkillSuiteMemberSelection(30L, 40L, "global", "writer", "2.0.0", "sha256:abc"), + 0); + setId(namespace, 1L); + setId(suite, 10L); + setId(version, 20L); + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + version.setPublishedAt(Instant.parse("2026-09-07T08:00:00Z")); + suite.setLatestVersionId(20L); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "writers")).thenReturn(Optional.of(suite)); + when(versionRepository.findById(20L)).thenReturn(Optional.of(version)); + when(memberRepository.findBySuiteVersionIdOrderByPosition(20L)).thenReturn(List.of(member)); + when(stateResolver.resolveForViewer(List.of(member), null, Map.of(), Set.of())) + .thenReturn(List.of(new SkillSuiteMemberState( + 30L, 40L, 1L, "Writer", "Writing helper", + NamespaceStatus.ACTIVE, SkillVisibility.PUBLIC, SkillStatus.ACTIVE, false, + SkillVersionStatus.PUBLISHED, true, false, true))); + + SkillSuiteQueryService.Detail result = service.getDetail( + "global", "writers", null, null, Map.of(), Set.of()); + + assertThat(result.available()).isTrue(); + assertThat(result.version().getVersion()).isEqualTo("1.0.0"); + assertThat(result.members()).singleElement().satisfies(item -> { + assertThat(item.snapshot().getSkillVersionId()).isEqualTo(40L); + assertThat(item.availability().available()).isTrue(); + }); + } + + @Test + void privatePublishedSuiteUsesTheSuiteCreatorInsteadOfTheVersionAuthor() { + Namespace namespace = new Namespace("team", "Team", "owner"); + SkillSuite suite = new SkillSuite(1L, "private-suite", "Private Suite", "suite-author"); + SkillSuiteVersion version = new SkillSuiteVersion( + 10L, "2.0.0", SkillVisibility.PRIVATE, "delegated-version-author"); + setId(namespace, 1L); + setId(suite, 10L); + setId(version, 20L); + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + suite.setLatestVersionId(20L); + + when(namespaceRepository.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "private-suite")).thenReturn(Optional.of(suite)); + when(versionRepository.findById(20L)).thenReturn(Optional.of(version)); + when(memberRepository.findBySuiteVersionIdOrderByPosition(20L)).thenReturn(List.of()); + + SkillSuiteQueryService.Detail creatorView = service.getDetail( + "team", "private-suite", null, "suite-author", + Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(creatorView.version().getVersion()).isEqualTo("2.0.0"); + assertThatThrownBy(() -> service.getDetail( + "team", "private-suite", null, "delegated-version-author", + Map.of(1L, NamespaceRole.MEMBER), Set.of())) + .isInstanceOf(DomainForbiddenException.class); + } + + @Test + void unpublishedSuiteUsesTheSuiteCreatorInsteadOfTheVersionAuthor() { + Namespace namespace = new Namespace("team", "Team", "owner"); + SkillSuite suite = new SkillSuite(1L, "draft-suite", "Draft Suite", "suite-author"); + SkillSuiteVersion version = new SkillSuiteVersion( + 10L, "2.0.0", SkillVisibility.PUBLIC, "delegated-version-author"); + setId(namespace, 1L); + setId(suite, 10L); + setId(version, 20L); + + when(namespaceRepository.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "draft-suite")).thenReturn(Optional.of(suite)); + when(versionRepository.findBySuiteIdAndVersion(10L, "2.0.0")).thenReturn(Optional.of(version)); + when(memberRepository.findBySuiteVersionIdOrderByPosition(20L)).thenReturn(List.of()); + + for (SkillSuiteVersionStatus status : List.of( + SkillSuiteVersionStatus.DRAFT, SkillSuiteVersionStatus.REJECTED)) { + version.setStatus(status); + assertThat(service.getDetail( + "team", "draft-suite", "2.0.0", "suite-author", + Map.of(1L, NamespaceRole.MEMBER), Set.of()).version().getStatus()) + .isEqualTo(status); + assertThatThrownBy(() -> service.getDetail( + "team", "draft-suite", "2.0.0", "delegated-version-author", + Map.of(1L, NamespaceRole.MEMBER), Set.of())) + .isInstanceOf(DomainForbiddenException.class); + } + } + + @Test + void suiteCreatorCanInspectHiddenAndArchivedPublishedSnapshots() { + Namespace namespace = new Namespace("team", "Team", "owner"); + SkillSuite suite = new SkillSuite(1L, "restricted-suite", "Restricted Suite", "suite-author"); + SkillSuiteVersion version = new SkillSuiteVersion( + 10L, "1.0.0", SkillVisibility.PUBLIC, "delegated-version-author"); + setId(namespace, 1L); + setId(suite, 10L); + setId(version, 20L); + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + suite.setLatestVersionId(20L); + + when(namespaceRepository.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "restricted-suite")).thenReturn(Optional.of(suite)); + when(versionRepository.findById(20L)).thenReturn(Optional.of(version)); + when(memberRepository.findBySuiteVersionIdOrderByPosition(20L)).thenReturn(List.of()); + + suite.setHidden(true); + assertThat(service.getDetail( + "team", "restricted-suite", null, "suite-author", + Map.of(1L, NamespaceRole.MEMBER), Set.of()).suite().isHidden()).isTrue(); + assertThatThrownBy(() -> service.getDetail( + "team", "restricted-suite", null, "delegated-version-author", + Map.of(1L, NamespaceRole.MEMBER), Set.of())) + .isInstanceOf(DomainForbiddenException.class); + + suite.setHidden(false); + suite.setStatus(SkillSuiteStatus.ARCHIVED); + assertThat(service.getDetail( + "team", "restricted-suite", null, "suite-author", + Map.of(1L, NamespaceRole.MEMBER), Set.of()).suite().getStatus()) + .isEqualTo(SkillSuiteStatus.ARCHIVED); + assertThatThrownBy(() -> service.getDetail( + "team", "restricted-suite", null, "delegated-version-author", + Map.of(1L, NamespaceRole.MEMBER), Set.of())) + .isInstanceOf(DomainForbiddenException.class); + } + + @Test + void pendingReviewUsesTheActualSubmitterAndReviewPermissions() { + Namespace namespace = new Namespace("team", "Team", "owner"); + SkillSuite suite = new SkillSuite(1L, "pending-suite", "Pending Suite", "suite-author"); + SkillSuiteVersion version = new SkillSuiteVersion( + 10L, "1.0.0", SkillVisibility.PUBLIC, "delegated-version-author"); + setId(namespace, 1L); + setId(suite, 10L); + setId(version, 20L); + version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + ReviewTask task = ReviewTask.forSuiteVersion( + 20L, 10L, 1L, "1.0.0", "actual-submitter"); + + when(namespaceRepository.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "pending-suite")).thenReturn(Optional.of(suite)); + when(versionRepository.findBySuiteIdAndVersion(10L, "1.0.0")).thenReturn(Optional.of(version)); + when(reviewTaskRepository.findBySubjectTypeAndSubjectVersionIdAndStatus( + ReviewSubjectType.SUITE_VERSION, 20L, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.of(task)); + when(memberRepository.findBySuiteVersionIdOrderByPosition(20L)).thenReturn(List.of()); + + assertThat(service.getDetail( + "team", "pending-suite", "1.0.0", "actual-submitter", + Map.of(1L, NamespaceRole.MEMBER), Set.of()).version().getStatus()) + .isEqualTo(SkillSuiteVersionStatus.PENDING_REVIEW); + assertThat(service.getDetail( + "team", "pending-suite", "1.0.0", "owner", + Map.of(1L, NamespaceRole.OWNER), Set.of()).version().getStatus()) + .isEqualTo(SkillSuiteVersionStatus.PENDING_REVIEW); + assertThatThrownBy(() -> service.getDetail( + "team", "pending-suite", "1.0.0", "suite-author", + Map.of(1L, NamespaceRole.MEMBER), Set.of())) + .isInstanceOf(DomainForbiddenException.class); + } + + private void setId(Object target, Long id) { + try { + var field = target.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(target, id); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionTest.java new file mode 100644 index 00000000..bd268d6d --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionTest.java @@ -0,0 +1,27 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SkillSuiteVersionTest { + + @Test + void draftIsEditableButPublishedVersionIsImmutable() { + SkillSuiteVersion version = new SkillSuiteVersion(1L, "1.0.0", SkillVisibility.PUBLIC, "user-1"); + + assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.DRAFT); + assertThatCode(version::assertEditable).doesNotThrowAnyException(); + + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + + assertThatThrownBy(version::assertEditable) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.version.immutable"); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java index d742064c..02348701 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.review.ReviewSubjectType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -23,6 +24,10 @@ public interface ReviewTaskJpaRepository extends JpaRepository Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); + @Override + Optional findBySubjectTypeAndSubjectVersionIdAndStatus( + ReviewSubjectType subjectType, Long subjectVersionId, ReviewTaskStatus status); + Page findByStatus(ReviewTaskStatus status, Pageable pageable); Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); @@ -35,13 +40,27 @@ public interface ReviewTaskJpaRepository extends JpaRepository List findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( Long skillId, String skillVersion); + List findBySubmittedByAndSubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc( + String submittedBy, + ReviewSubjectType subjectType, + Long subjectId, + String subjectVersion); + + List findBySubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc( + ReviewSubjectType subjectType, + Long subjectId, + String subjectVersion); + boolean existsByNamespaceId(Long namespaceId); void deleteBySkillVersionIdIn(Collection skillVersionIds); void deleteBySkillId(Long skillId); - @Modifying + @Override + void deleteBySubjectTypeAndSubjectId(ReviewSubjectType subjectType, Long subjectId); + + @Modifying(clearAutomatically = true) @Query(""" UPDATE ReviewTask t SET t.status = :status, diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteInstallOperationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteInstallOperationJpaRepository.java new file mode 100644 index 00000000..21a18a8b --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteInstallOperationJpaRepository.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperation; +import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperationRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; + +/** PostgreSQL-backed atomic claim for Suite install-plan client retry keys. */ +@Repository +public interface SkillSuiteInstallOperationJpaRepository + extends JpaRepository, SkillSuiteInstallOperationRepository { + + @Override + @Modifying + @Query(value = """ + INSERT INTO skill_suite_install_operation( + operation_id, client_request_id, actor_key, suite_id, suite_version_id + ) + VALUES (:operationId, :clientRequestId, :actorKey, :suiteId, :suiteVersionId) + ON CONFLICT (client_request_id, actor_key) DO NOTHING + """, nativeQuery = true) + int insertIfAbsent( + @Param("operationId") String operationId, + @Param("clientRequestId") String clientRequestId, + @Param("actorKey") String actorKey, + @Param("suiteId") Long suiteId, + @Param("suiteVersionId") Long suiteVersionId); + + @Override + java.util.Optional findByClientRequestIdAndActorKey( + String clientRequestId, String actorKey); + + @Override + @Modifying + @Query("DELETE FROM SkillSuiteInstallOperation operation WHERE operation.createdAt < :threshold") + int deleteCreatedBefore(@Param("threshold") Instant threshold); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteJpaRepository.java new file mode 100644 index 00000000..5c0147d3 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteJpaRepository.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** JPA adapter for Suite containers. */ +@Repository +public interface SkillSuiteJpaRepository extends JpaRepository, SkillSuiteRepository { + + @Override + @Modifying + @Transactional + @Query("UPDATE SkillSuite suite SET suite.installRequestCount = suite.installRequestCount + 1 " + + "WHERE suite.id = :suiteId") + void incrementInstallRequestCount(@Param("suiteId") Long suiteId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteVersionJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteVersionJpaRepository.java new file mode 100644 index 00000000..f6525a1d --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteVersionJpaRepository.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** JPA adapter for Suite version snapshots. */ +@Repository +public interface SkillSuiteVersionJpaRepository + extends JpaRepository, SkillSuiteVersionRepository { +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteVersionMemberJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteVersionMemberJpaRepository.java new file mode 100644 index 00000000..5ae461a4 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteVersionMemberJpaRepository.java @@ -0,0 +1,27 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMemberRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** JPA adapter for ordered Suite member snapshots. */ +@Repository +public interface SkillSuiteVersionMemberJpaRepository + extends JpaRepository, SkillSuiteVersionMemberRepository { + + @Override + @Modifying(flushAutomatically = true) + @Query("delete from SkillSuiteVersionMember member where member.suiteVersionId = :suiteVersionId") + void deleteBySuiteVersionId(@Param("suiteVersionId") Long suiteVersionId); + + @Override + default List saveAll(List members) { + return saveAllAndFlush(members); + } +} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/ResourceDiscoveryQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/ResourceDiscoveryQueryService.java new file mode 100644 index 00000000..be9ab9ad --- /dev/null +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/ResourceDiscoveryQueryService.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.search; + +import java.time.Instant; +import java.util.List; +import java.util.Set; + +/** Replaceable read-side boundary for searching Skills and Suites together. */ +public interface ResourceDiscoveryQueryService { + + ResourcePage search(ResourceQuery query); + + record ResourceQuery( + String keyword, + String namespace, + String resourceType, + String sort, + int page, + int size, + Set memberNamespaceIds + ) {} + + record ResourceHit( + String resourceType, + Long id, + String namespace, + String slug, + String displayName, + String summary, + String version, + String visibility, + long installCount, + boolean available, + Instant updatedAt + ) {} + + record ResourcePage(List items, long total, int page, int size) {} +} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresResourceDiscoveryQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresResourceDiscoveryQueryService.java new file mode 100644 index 00000000..141e0b6e --- /dev/null +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresResourceDiscoveryQueryService.java @@ -0,0 +1,204 @@ +package com.iflytek.skillhub.search.postgres; + +import com.iflytek.skillhub.search.ResourceDiscoveryQueryService; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * PostgreSQL implementation of typed Skill and Suite discovery. + * + *

The native query is intentional: one stable page must union two independently versioned + * resource tables, compute current Suite member availability, and sort/count the combined result + * without loading member aggregates or introducing N+1 queries.

+ */ +@Service +public class PostgresResourceDiscoveryQueryService implements ResourceDiscoveryQueryService { + + private static final String RESOURCE_CTE = """ + WITH resources AS ( + SELECT 'SKILL' AS resource_type, + skill.id AS resource_id, + namespace.id AS namespace_id, + namespace.slug AS namespace_slug, + skill.slug, + skill.display_name, + skill.summary, + version.version, + skill.visibility, + skill.download_count AS install_count, + TRUE AS available, + skill.updated_at + FROM skill + JOIN namespace ON namespace.id = skill.namespace_id + JOIN skill_version version ON version.id = skill.latest_version_id + WHERE skill.status = 'ACTIVE' + AND skill.hidden = FALSE + AND namespace.status <> 'ARCHIVED' + AND version.status = 'PUBLISHED' + AND version.download_ready = TRUE + AND version.yanked_at IS NULL + AND (skill.visibility = 'PUBLIC' + OR (skill.visibility = 'NAMESPACE_ONLY' AND skill.namespace_id IN (:memberNamespaceIds))) + + UNION ALL + + SELECT 'SUITE' AS resource_type, + suite.id AS resource_id, + namespace.id AS namespace_id, + namespace.slug AS namespace_slug, + suite.slug, + version.display_name, + version.summary, + version.version, + version.visibility, + suite.install_request_count AS install_count, + NOT EXISTS ( + SELECT 1 + FROM skill_suite_version_member member + LEFT JOIN skill_version member_version ON member_version.id = member.skill_version_id + LEFT JOIN skill member_skill ON member_skill.id = member.skill_id + LEFT JOIN namespace member_namespace ON member_namespace.id = member_skill.namespace_id + WHERE member.suite_version_id = version.id + AND ( + member.skill_id IS NULL + OR member.skill_version_id IS NULL + OR member_version.skill_id <> member.skill_id + OR member_skill.status <> 'ACTIVE' + OR member_namespace.status <> 'ACTIVE' + OR member_skill.hidden = TRUE + OR member_version.status <> 'PUBLISHED' + OR member_version.download_ready = FALSE + OR member_version.yanked_at IS NOT NULL + OR (version.visibility = 'PUBLIC' AND member_skill.visibility <> 'PUBLIC') + OR (version.visibility = 'NAMESPACE_ONLY' AND NOT ( + member_skill.visibility = 'PUBLIC' + OR (member_skill.namespace_id = suite.namespace_id + AND member_skill.visibility = 'NAMESPACE_ONLY') + )) + OR (version.visibility = 'PRIVATE' AND NOT ( + member_skill.visibility = 'PUBLIC' + OR member_skill.namespace_id = suite.namespace_id + )) + ) + ) AS available, + suite.updated_at + FROM skill_suite suite + JOIN namespace ON namespace.id = suite.namespace_id + JOIN skill_suite_version version ON version.id = suite.latest_version_id + WHERE suite.status = 'ACTIVE' + AND suite.hidden = FALSE + AND namespace.status <> 'ARCHIVED' + AND version.status = 'PUBLISHED' + AND (version.visibility = 'PUBLIC' + OR (version.visibility = 'NAMESPACE_ONLY' AND suite.namespace_id IN (:memberNamespaceIds))) + ) + """; + + private static final String FILTERS = """ + WHERE (:resourceType = '' OR resource_type = :resourceType) + AND (:namespace = '' OR namespace_slug = :namespace) + AND (:query = '' + OR LOWER(slug) LIKE :queryPattern + OR LOWER(display_name) LIKE :queryPattern + OR LOWER(COALESCE(summary, '')) LIKE :queryPattern) + """; + + private final EntityManager entityManager; + + public PostgresResourceDiscoveryQueryService(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @Transactional(readOnly = true) + public ResourcePage search(ResourceQuery input) { + String queryText = normalize(input.keyword()); + String namespaceSlug = normalize(input.namespace()); + String type = normalize(input.resourceType()).toUpperCase(Locale.ROOT); + if (!type.isEmpty() && !Set.of("SKILL", "SUITE").contains(type)) { + type = ""; + } + String order = switch (input.sort() == null ? "newest" : input.sort()) { + case "downloads" -> "install_count DESC, updated_at DESC, resource_type, resource_id DESC"; + case "relevance" -> "CASE WHEN LOWER(slug) = :query THEN 3 " + + "WHEN LOWER(display_name) = :query THEN 2 " + + "WHEN LOWER(slug) LIKE :queryPrefix THEN 1 ELSE 0 END DESC, " + + "updated_at DESC, resource_type, resource_id DESC"; + default -> "updated_at DESC, resource_type, resource_id DESC"; + }; + String selectSql = RESOURCE_CTE + """ + SELECT resource_type, resource_id, namespace_slug, slug, display_name, summary, + version, visibility, install_count, available, updated_at + FROM resources + """ + FILTERS + " ORDER BY " + order + " OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY"; + String countSql = RESOURCE_CTE + "SELECT COUNT(*) FROM resources " + FILTERS; + Query select = bind(entityManager.createNativeQuery(selectSql), input, queryText, namespaceSlug, + type, "relevance".equals(input.sort())); + select.setParameter("offset", (long) input.page() * input.size()).setParameter("size", input.size()); + Query count = bind(entityManager.createNativeQuery(countSql), input, queryText, namespaceSlug, + type, false); + + @SuppressWarnings("unchecked") + List rows = select.getResultList(); + List items = rows.stream().map(this::map).toList(); + long total = ((Number) count.getSingleResult()).longValue(); + return new ResourcePage(items, total, input.page(), input.size()); + } + + private Query bind( + Query query, + ResourceQuery input, + String keyword, + String namespace, + String resourceType, + boolean relevance + ) { + Set memberNamespaceIds = input.memberNamespaceIds(); + query.setParameter("query", keyword) + .setParameter("queryPattern", "%" + keyword + "%") + .setParameter("namespace", namespace) + .setParameter("resourceType", resourceType) + .setParameter("memberNamespaceIds", + memberNamespaceIds == null || memberNamespaceIds.isEmpty() + ? Set.of(-1L) + : memberNamespaceIds); + if (relevance) { + query.setParameter("queryPrefix", keyword + "%"); + } + return query; + } + + private ResourceHit map(Object[] row) { + return new ResourceHit( + String.valueOf(row[0]), + ((Number) row[1]).longValue(), + (String) row[2], + (String) row[3], + (String) row[4], + (String) row[5], + (String) row[6], + String.valueOf(row[7]), + ((Number) row[8]).longValue(), + (Boolean) row[9], + instant(row[10])); + } + + private String normalize(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } + + private Instant instant(Object value) { + if (value instanceof Instant instant) return instant; + if (value instanceof OffsetDateTime offsetDateTime) return offsetDateTime.toInstant(); + if (value instanceof Timestamp timestamp) return timestamp.toInstant(); + throw new IllegalStateException("Expected resource discovery timestamp, got " + value); + } +} diff --git a/web/e2e/namespace-search.spec.ts b/web/e2e/namespace-search.spec.ts index 39a48ec1..51fff876 100644 --- a/web/e2e/namespace-search.spec.ts +++ b/web/e2e/namespace-search.spec.ts @@ -2,9 +2,9 @@ import { expect, test, type Page } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' import { E2eTestDataBuilder } from './helpers/test-data-builder' -function waitForSkillSearch(page: Page, options: { namespace?: string; q?: string; sort?: string }) { +function waitForResourceSearch(page: Page, options: { namespace?: string; q?: string; sort?: string }) { return page.waitForResponse((response) => { - if (!response.ok() || !response.url().includes('/api/web/skills?')) { + if (!response.ok() || !response.url().includes('/api/web/resources?')) { return false } @@ -47,7 +47,7 @@ test.describe('Namespace Search (Real API)', () => { await page.goto('/search') await page.getByPlaceholder('Search skills...').fill(`@${namespace.slug} roadmap`) - const filteredSearch = waitForSkillSearch(page, { namespace: namespace.slug, q: 'roadmap' }) + const filteredSearch = waitForResourceSearch(page, { namespace: namespace.slug, q: 'roadmap' }) await page.getByRole('button', { name: 'Search', exact: true }).click() await filteredSearch @@ -60,7 +60,7 @@ test.describe('Namespace Search (Real API)', () => { await page.goto(`/search?q=roadmap&namespace=${namespace.slug}&sort=downloads&page=1&starredOnly=false`) await expect(page.getByRole('button', { name: `@${namespace.slug}` })).toBeVisible() - const unfilteredSearch = waitForSkillSearch(page, { q: 'roadmap', sort: 'downloads' }) + const unfilteredSearch = waitForResourceSearch(page, { q: 'roadmap', sort: 'downloads' }) await page.getByRole('button', { name: `@${namespace.slug}` }).click() await unfilteredSearch @@ -91,7 +91,7 @@ test.describe('Namespace Search (Real API)', () => { await page.goto('/search') await page.getByPlaceholder('Search skills...').fill(`@${namespace.slug} boundary`) - const filteredSearch = waitForSkillSearch(page, { namespace: namespace.slug, q: 'boundary' }) + const filteredSearch = waitForResourceSearch(page, { namespace: namespace.slug, q: 'boundary' }) await page.getByRole('button', { name: 'Search', exact: true }).click() await filteredSearch diff --git a/web/e2e/search-page-full.spec.ts b/web/e2e/search-page-full.spec.ts index 9f18bb24..78afc2ae 100644 --- a/web/e2e/search-page-full.spec.ts +++ b/web/e2e/search-page-full.spec.ts @@ -42,14 +42,13 @@ test.describe('Search Input (Real API)', () => { // TC_SEARCH_INPUT_003 P0 - empty search shows the default discovery list test('TC_SEARCH_INPUT_003: empty search shows the default discovery list', async ({ page }) => { const emptyQueryResponse = page.waitForResponse((response) => { - if (!response.url().includes('/api/web/skills?')) { + if (!response.url().includes('/api/web/resources?')) { return false } const url = new URL(response.url()) return response.status() === 200 - && url.searchParams.has('q') - && url.searchParams.get('q') === '' + && (url.searchParams.get('q') ?? '') === '' }) await page.goto(searchUrl('')) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c503257f..f29a381c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -48,6 +48,8 @@ import type { LabelDefinition, LabelItem, BatchMemberResponse, + SkillSuite, + SkillSuiteDraftInput, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -149,6 +151,19 @@ function isApiEnvelope(value: unknown): value is ApiEnvelope { return typeof value === 'object' && value !== null && 'code' in value && 'msg' in value && 'data' in value } +function unwrapOpenApiResponse(data: unknown, error: unknown, response: Response): T { + const envelope = isApiEnvelope(data) + ? data + : isApiEnvelope(error) + ? error + : null + if (!response.ok || error || !envelope || envelope.code !== 0) { + const message = envelope?.msg || `HTTP ${response.status}` + throw new ApiError(message, response.status, envelope?.msg, envelope?.msg) + } + return envelope.data +} + export function getCsrfHeaders(headers?: HeadersInit): HeadersInit { return withCsrf(headers) } @@ -864,6 +879,68 @@ export const tokenApi = { }, } +export const suiteApi = { + async createVersion(suiteId: number, input: SkillSuiteDraftInput): Promise { + const { data, error, response } = await client.POST('/api/web/suites/{suiteId}/versions', { + params: { path: { suiteId } }, + body: input, + headers: await ensureCsrfHeaders(), + }) + return unwrapOpenApiResponse(data, error, response) + }, + + async reopen(suiteId: number, versionId: number): Promise { + const { data, error, response } = await client.POST('/api/web/suites/{suiteId}/versions/{versionId}/reopen', { + params: { path: { suiteId, versionId } }, + headers: await ensureCsrfHeaders(), + }) + unwrapOpenApiResponse(data, error, response) + }, + + async yank(suiteId: number, versionId: number, reason: string): Promise { + const { data, error, response } = await client.POST('/api/web/suites/{suiteId}/versions/{versionId}/yank', { + params: { path: { suiteId, versionId } }, + body: { reason }, + headers: await ensureCsrfHeaders(), + }) + unwrapOpenApiResponse(data, error, response) + }, + + async setHidden(suiteId: number, hidden: boolean): Promise { + const result = hidden + ? await client.POST('/api/web/suites/{suiteId}/hide', { + params: { path: { suiteId } }, + headers: await ensureCsrfHeaders(), + }) + : await client.POST('/api/web/suites/{suiteId}/restore', { + params: { path: { suiteId } }, + headers: await ensureCsrfHeaders(), + }) + unwrapOpenApiResponse(result.data, result.error, result.response) + }, + + async setArchived(suiteId: number, archived: boolean): Promise { + const result = archived + ? await client.POST('/api/web/suites/{suiteId}/archive', { + params: { path: { suiteId } }, + headers: await ensureCsrfHeaders(), + }) + : await client.POST('/api/web/suites/{suiteId}/unarchive', { + params: { path: { suiteId } }, + headers: await ensureCsrfHeaders(), + }) + unwrapOpenApiResponse(result.data, result.error, result.response) + }, + + async delete(suiteId: number): Promise { + const { data, error, response } = await client.DELETE('/api/web/suites/{suiteId}', { + params: { path: { suiteId } }, + headers: await ensureCsrfHeaders(), + }) + unwrapOpenApiResponse(data, error, response) + }, +} + export const reviewApi = { async list(params: { status: string; namespaceId?: number; page?: number; size?: number; sortDirection?: 'ASC' | 'DESC' }) { const searchParams = new URLSearchParams() diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 5fc821ed..42a1485b 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -4,6 +4,40 @@ */ export interface paths { + "/api/web/suites/{suiteId}/versions/{versionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update an editable Suite draft */ + put: operations["updateDraft"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/{suiteId}/versions/{versionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update an editable Suite draft */ + put: operations["updateDraft_1"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{skillId}/subscription": { parameters: { query?: never; @@ -452,6 +486,448 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/suites/{suiteId}/versions/{versionId}/yank": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Yank a published Suite version */ + post: operations["yank"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/{suiteId}/versions/{versionId}/yank": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Yank a published Suite version */ + post: operations["yank_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/{suiteId}/versions/{versionId}/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Submit a public or namespace Suite draft for review */ + post: operations["submit"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/{suiteId}/versions/{versionId}/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Submit a public or namespace Suite draft for review */ + post: operations["submit_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/{suiteId}/versions/{versionId}/reopen": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reopen a rejected Suite version as a draft */ + post: operations["reopen"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/{suiteId}/versions/{versionId}/reopen": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reopen a rejected Suite version as a draft */ + post: operations["reopen_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/{suiteId}/versions/{versionId}/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Publish a private Suite draft directly */ + post: operations["publishPrivate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/{suiteId}/versions/{versionId}/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Publish a private Suite draft directly */ + post: operations["publishPrivate_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/{suiteId}/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a new draft version for a Suite */ + post: operations["createVersion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/{suiteId}/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a new draft version for a Suite */ + post: operations["createVersion_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/{suiteId}/unarchive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Restore an archived Suite container */ + post: operations["unarchive"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/{suiteId}/unarchive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Restore an archived Suite container */ + post: operations["unarchive_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/{suiteId}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Restore a hidden Suite */ + post: operations["restore"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/{suiteId}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Restore a hidden Suite */ + post: operations["restore_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/{suiteId}/hide": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Hide a Suite from discovery */ + post: operations["hide"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/{suiteId}/hide": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Hide a Suite from discovery */ + post: operations["hide_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/{suiteId}/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Archive a Suite container */ + post: operations["archive"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/{suiteId}/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Archive a Suite container */ + post: operations["archive_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/{namespace}/{slug}/install-plan": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Issue an idempotent exact-member Suite install plan */ + post: operations["createInstallPlan"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/{namespace}/{slug}/install-plan": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Issue an idempotent exact-member Suite install plan */ + post: operations["createInstallPlan_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/reviews/{reviewTaskId}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reject a pending Suite review */ + post: operations["reject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/reviews/{reviewTaskId}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reject a pending Suite review */ + post: operations["reject_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/reviews/{reviewTaskId}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Approve a pending Suite review */ + post: operations["approve"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/reviews/{reviewTaskId}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Approve a pending Suite review */ + post: operations["approve_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a Suite and its first draft version */ + post: operations["create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a Suite and its first draft version */ + post: operations["create_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{namespace}/{slug}/versions/{version}/withdraw-review": { parameters: { query?: never; @@ -1227,9 +1703,9 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["list_4"]; + get: operations["list_6"]; put?: never; - post: operations["create"]; + post: operations["create_2"]; delete?: never; options?: never; head?: never; @@ -1597,7 +2073,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["restore"]; + post: operations["restore_2"]; delete?: never; options?: never; head?: never; @@ -1613,7 +2089,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["hide"]; + post: operations["hide_2"]; delete?: never; options?: never; head?: never; @@ -1677,7 +2153,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["reject"]; + post: operations["reject_2"]; delete?: never; options?: never; head?: never; @@ -1693,7 +2169,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["approve"]; + post: operations["approve_2"]; delete?: never; options?: never; head?: never; @@ -1924,6 +2400,108 @@ export interface paths { patch: operations["updateProfile"]; trace?: never; }; + "/api/web/suites/{namespace}/{slug}/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List visible Suite versions */ + get: operations["listVersions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/{namespace}/{slug}/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List visible Suite versions */ + get: operations["listVersions_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/{namespace}/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one visible Suite version */ + get: operations["getDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/{namespace}/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one visible Suite version */ + get: operations["getDetail_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/member-candidates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search exact Skill versions eligible for a Suite draft */ + get: operations["searchCandidates"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/member-candidates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search exact Skill versions eligible for a Suite draft */ + get: operations["searchCandidates_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/skills": { parameters: { query?: never; @@ -2139,7 +2717,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["listVersions"]; + get: operations["listVersions_2"]; put?: never; post?: never; delete?: never; @@ -2155,7 +2733,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["listVersions_1"]; + get: operations["listVersions_3"]; put?: never; post?: never; delete?: never; @@ -2708,6 +3286,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/resources": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search Skills and Suites with explicit resource types */ + get: operations["search_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search Skills and Suites with explicit resource types */ + get: operations["search_2"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/promotions/{id}": { parameters: { query?: never; @@ -2868,6 +3480,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/me/suites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Suite versions manageable by the current user */ + get: operations["list_4"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/me/suites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Suite versions manageable by the current user */ + get: operations["list_5"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/me/subscriptions": { parameters: { query?: never; @@ -3211,7 +3857,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["search_1"]; + get: operations["search_3"]; put?: never; post?: never; delete?: never; @@ -3387,7 +4033,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["list_5"]; + get: operations["list_7"]; put?: never; post?: never; delete?: never; @@ -3515,7 +4161,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["search_2"]; + get: operations["search_4"]; put?: never; post?: never; delete?: never; @@ -3572,6 +4218,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/suites/{suiteId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete a Suite without changing member Skills */ + delete: operations["delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/{suiteId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete a Suite without changing member Skills */ + delete: operations["delete_1"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/skills/id/{skillId}": { parameters: { query?: never; @@ -3720,6 +4400,70 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + SkillSuiteCreateRequest: { + namespace: string; + slug: string; + displayName: string; + summary?: string; + overview?: string; + version: string; + /** @enum {string} */ + visibility: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + changelog?: string; + entrySkill?: components["schemas"]["SkillSuiteMemberRequest"]; + members: components["schemas"]["SkillSuiteMemberRequest"][]; + }; + SkillSuiteMemberRequest: { + namespace: string; + slug: string; + version: string; + }; + ApiResponseSkillSuiteResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillSuiteResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillSuiteMemberResponse: { + /** Format: int64 */ + skillId?: number; + /** Format: int64 */ + skillVersionId?: number; + namespace?: string; + slug?: string; + displayName?: string; + summary?: string; + version?: string; + fingerprint?: string; + /** Format: int32 */ + position?: number; + entry?: boolean; + browsable?: boolean; + blockingReason?: string; + }; + SkillSuiteResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + versionId?: number; + namespace?: string; + slug?: string; + displayName?: string; + summary?: string; + overview?: string; + version?: string; + status?: string; + /** @enum {string} */ + visibility?: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + suiteStatus?: string; + hidden?: boolean; + allowedActions?: ("EDIT" | "SUBMIT" | "PUBLISH_PRIVATE" | "REOPEN" | "CREATE_VERSION" | "YANK" | "HIDE" | "RESTORE" | "ARCHIVE" | "UNARCHIVE" | "DELETE")[]; + available?: boolean; + members?: components["schemas"]["SkillSuiteMemberResponse"][]; + }; ApiResponseVoid: { /** Format: int32 */ code?: number; @@ -3983,6 +4727,55 @@ export interface components { timestamp?: string; requestId?: string; }; + SkillSuiteReasonRequest: { + reason: string; + }; + ApiResponseMessageResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MessageResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MessageResponse: { + message?: string; + }; + ApiResponseSkillSuiteInstallPlanResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillSuiteInstallPlanResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillSuiteInstallMemberResponse: { + /** Format: int64 */ + skillId?: number; + /** Format: int64 */ + skillVersionId?: number; + namespace?: string; + slug?: string; + version?: string; + fingerprint?: string; + downloadUrl?: string; + /** Format: int32 */ + position?: number; + entry?: boolean; + }; + SkillSuiteInstallPlanResponse: { + operationId?: string; + namespace?: string; + slug?: string; + version?: string; + fingerprint?: string; + members?: components["schemas"]["SkillSuiteInstallMemberResponse"][]; + }; + SkillSuiteReviewRequest: { + comment?: string; + }; ApiResponseSkillLifecycleMutationResponse: { /** Format: int32 */ code?: number; @@ -4083,6 +4876,12 @@ export interface components { submittedAt?: string; /** Format: date-time */ reviewedAt?: string; + subjectType?: string; + /** Format: int64 */ + subjectId?: number; + /** Format: int64 */ + subjectVersionId?: number; + subjectSlug?: string; }; ReviewTaskRequest: { /** Format: int64 */ @@ -4143,18 +4942,6 @@ export interface components { TransferOwnershipRequest: { newOwnerId: string; }; - ApiResponseMessageResponse: { - /** Format: int32 */ - code?: number; - msg?: string; - data?: components["schemas"]["MessageResponse"]; - /** Format: date-time */ - timestamp?: string; - requestId?: string; - }; - MessageResponse: { - message?: string; - }; BatchMemberRequest: { members: components["schemas"]["MemberRequest"][]; }; @@ -4537,6 +5324,51 @@ export interface components { [key: string]: string; }; }; + ApiResponseListSkillSuiteVersionSummaryResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillSuiteVersionSummaryResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillSuiteVersionSummaryResponse: { + /** Format: int64 */ + id?: number; + version?: string; + status?: string; + /** @enum {string} */ + visibility?: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + /** Format: date-time */ + publishedAt?: string; + /** Format: date-time */ + yankedAt?: string; + /** Format: date-time */ + createdAt?: string; + }; + ApiResponseListSkillSuiteMemberCandidateResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillSuiteMemberCandidateResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillSuiteMemberCandidateResponse: { + /** Format: int64 */ + skillId?: number; + /** Format: int64 */ + skillVersionId?: number; + namespace?: string; + slug?: string; + displayName?: string; + version?: string; + /** @enum {string} */ + visibility?: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + recommended?: boolean; + }; ApiResponseSearchResponse: { /** Format: int32 */ code?: number; @@ -4943,6 +5775,12 @@ export interface components { latestReviewedAt?: string; /** Format: int64 */ attemptCount?: number; + subjectType?: string; + /** Format: int64 */ + subjectId?: number; + /** Format: int64 */ + subjectVersionId?: number; + subjectSlug?: string; }; ReviewProgressStatusCounts: { /** Format: int64 */ @@ -4952,6 +5790,41 @@ export interface components { /** Format: int64 */ rejected?: number; }; + ApiResponseResourceSearchResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ResourceSearchResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ResourceSearchResponse: { + items?: components["schemas"]["ResourceSummaryResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ResourceSummaryResponse: { + resourceType?: string; + detailUrl?: string; + /** Format: int64 */ + id?: number; + namespace?: string; + slug?: string; + displayName?: string; + summary?: string; + version?: string; + visibility?: string; + /** Format: int64 */ + installCount?: number; + available?: boolean; + /** Format: date-time */ + updatedAt?: string; + }; ApiResponsePageResponsePromotionResponseDto: { /** Format: int32 */ code?: number; @@ -5075,6 +5948,41 @@ export interface components { /** Format: int32 */ size?: number; }; + ApiResponsePageResponseMySkillSuiteSummaryResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseMySkillSuiteSummaryResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MySkillSuiteSummaryResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + versionId?: number; + namespace?: string; + slug?: string; + displayName?: string; + summary?: string; + version?: string; + versionStatus?: string; + suiteStatus?: string; + visibility?: string; + hidden?: boolean; + /** Format: date-time */ + updatedAt?: string; + }; + PageResponseMySkillSuiteSummaryResponse: { + items?: components["schemas"]["MySkillSuiteSummaryResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; ApiResponsePageResponseSkillSummaryResponse: { /** Format: int32 */ code?: number; @@ -5182,6 +6090,8 @@ export interface components { timestamp?: string; namespace?: string; skillSlug?: string; + resourceType?: string; + resourceSlug?: string; }; PageResponseGovernanceInboxItemResponse: { items?: components["schemas"]["GovernanceInboxItemResponse"][]; @@ -5735,6 +6645,60 @@ export interface components { } export type $defs = Record; export interface operations { + updateDraft: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteCreateRequest"]; + }; + }; + responses: { + /** @description Suite draft updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + }; + }; + }; + }; + updateDraft_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteCreateRequest"]; + }; + }; + responses: { + /** @description Suite draft updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + }; + }; + }; + }; checkSubscribed: { parameters: { query?: never; @@ -6978,6 +7942,632 @@ export interface operations { }; }; }; + yank: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteReasonRequest"]; + }; + }; + responses: { + /** @description Suite version yanked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + yank_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteReasonRequest"]; + }; + }; + responses: { + /** @description Suite version yanked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + submit: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite draft submitted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + submit_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite draft submitted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + reopen: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite draft reopened */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + reopen_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite draft reopened */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + publishPrivate: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Private Suite published */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + publishPrivate_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Private Suite published */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + createVersion: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteCreateRequest"]; + }; + }; + responses: { + /** @description Suite version draft created */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + }; + }; + }; + }; + createVersion_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteCreateRequest"]; + }; + }; + responses: { + /** @description Suite version draft created */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + }; + }; + }; + }; + unarchive: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite unarchived */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + unarchive_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite unarchived */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + restore: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite restored */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + restore_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite restored */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + hide: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite hidden */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + hide_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite hidden */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + archive: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite archived */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + archive_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite archived */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + createInstallPlan: { + parameters: { + query?: { + version?: string; + }; + header?: { + "Idempotency-Key"?: string; + }; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Install plan issued */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteInstallPlanResponse"]; + }; + }; + }; + }; + createInstallPlan_1: { + parameters: { + query?: { + version?: string; + }; + header?: { + "Idempotency-Key"?: string; + }; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Install plan issued */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteInstallPlanResponse"]; + }; + }; + }; + }; + reject: { + parameters: { + query?: never; + header?: never; + path: { + reviewTaskId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SkillSuiteReviewRequest"]; + }; + }; + responses: { + /** @description Suite review rejected */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + reject_1: { + parameters: { + query?: never; + header?: never; + path: { + reviewTaskId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SkillSuiteReviewRequest"]; + }; + }; + responses: { + /** @description Suite review rejected */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + approve: { + parameters: { + query?: never; + header?: never; + path: { + reviewTaskId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SkillSuiteReviewRequest"]; + }; + }; + responses: { + /** @description Suite review approved */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + approve_1: { + parameters: { + query?: never; + header?: never; + path: { + reviewTaskId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SkillSuiteReviewRequest"]; + }; + }; + responses: { + /** @description Suite review approved */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteCreateRequest"]; + }; + }; + responses: { + /** @description Suite draft created */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + }; + }; + }; + }; + create_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteCreateRequest"]; + }; + }; + responses: { + /** @description Suite draft created */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + }; + }; + }; + }; withdrawReview: { parameters: { query?: never; @@ -8392,7 +9982,7 @@ export interface operations { }; }; }; - list_4: { + list_6: { parameters: { query?: { page?: number; @@ -8415,7 +10005,7 @@ export interface operations { }; }; }; - create: { + create_2: { parameters: { query?: never; header?: never; @@ -9006,7 +10596,7 @@ export interface operations { }; }; }; - restore: { + restore_2: { parameters: { query?: never; header?: never; @@ -9028,7 +10618,7 @@ export interface operations { }; }; }; - hide: { + hide_2: { parameters: { query?: never; header?: never; @@ -9126,7 +10716,7 @@ export interface operations { }; }; }; - reject: { + reject_2: { parameters: { query?: never; header?: never; @@ -9152,7 +10742,7 @@ export interface operations { }; }; }; - approve: { + approve_2: { parameters: { query?: never; header?: never; @@ -9593,6 +11183,152 @@ export interface operations { }; }; }; + listVersions: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite version history returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSuiteVersionSummaryResponse"]; + }; + }; + }; + }; + listVersions_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite version history returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSuiteVersionSummaryResponse"]; + }; + }; + }; + }; + getDetail: { + parameters: { + query?: { + version?: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite version returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + }; + }; + }; + }; + getDetail_1: { + parameters: { + query?: { + version?: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite version returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + }; + }; + }; + }; + searchCandidates: { + parameters: { + query: { + suiteNamespace: string; + visibility: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + q?: string; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Eligible member candidates returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSuiteMemberCandidateResponse"]; + }; + }; + }; + }; + searchCandidates_1: { + parameters: { + query: { + suiteNamespace: string; + visibility: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + q?: string; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Eligible member candidates returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSuiteMemberCandidateResponse"]; + }; + }; + }; + }; search: { parameters: { query?: { @@ -9968,7 +11704,7 @@ export interface operations { }; }; }; - listVersions: { + listVersions_2: { parameters: { query?: { page?: number; @@ -9994,7 +11730,7 @@ export interface operations { }; }; }; - listVersions_1: { + listVersions_3: { parameters: { query?: { page?: number; @@ -10868,6 +12604,60 @@ export interface operations { }; }; }; + search_1: { + parameters: { + query?: { + q?: string; + namespace?: string; + resourceType?: string; + sort?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Resource page returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseResourceSearchResponse"]; + }; + }; + }; + }; + search_2: { + parameters: { + query?: { + q?: string; + namespace?: string; + resourceType?: string; + sort?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Resource page returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseResourceSearchResponse"]; + }; + }; + }; + }; getPromotionDetail: { parameters: { query?: never; @@ -11096,6 +12886,54 @@ export interface operations { }; }; }; + list_4: { + parameters: { + query?: { + q?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Manageable Suite page returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMySkillSuiteSummaryResponse"]; + }; + }; + }; + }; + list_5: { + parameters: { + query?: { + q?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Manageable Suite page returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMySkillSuiteSummaryResponse"]; + }; + }; + }; + }; listMySubscriptions: { parameters: { query?: { @@ -11589,7 +13427,7 @@ export interface operations { }; }; }; - search_1: { + search_3: { parameters: { query: { q: string; @@ -11837,7 +13675,7 @@ export interface operations { }; }; }; - list_5: { + list_7: { parameters: { query?: { status?: string; @@ -12038,7 +13876,7 @@ export interface operations { }; }; }; - search_2: { + search_4: { parameters: { query?: { q?: string; @@ -12122,12 +13960,56 @@ export interface operations { }; content: { "*/*": { - [key: string]: string; + [key: string]: Record; }; }; }; }; }; + delete: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + delete_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; deleteSkillById: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 4db8e8db..442fa69b 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -407,6 +407,51 @@ export interface PagedResponse { size: number } +export type ResourceType = 'SKILL' | 'SUITE' + +type RequiredGenerated = + Required> & Pick + +type GeneratedResourceSummary = components['schemas']['ResourceSummaryResponse'] +export type ResourceSummary = Omit, 'resourceType'> & { + resourceType: ResourceType +} + +export interface ResourceSearchParams { + q?: string + namespace?: string + resourceType?: ResourceType + sort?: string + page?: number + size?: number +} + +type GeneratedSuiteMember = components['schemas']['SkillSuiteMemberResponse'] +export type SkillSuiteMember = RequiredGenerated< + GeneratedSuiteMember, + 'skillId' | 'skillVersionId' | 'displayName' | 'summary' | 'blockingReason' +> + +type GeneratedSuite = components['schemas']['SkillSuiteResponse'] +export type SkillSuite = Omit, 'members'> & { + members: SkillSuiteMember[] +} + +type GeneratedSuiteVersion = components['schemas']['SkillSuiteVersionSummaryResponse'] +export type SkillSuiteVersion = RequiredGenerated + +export type SkillSuiteMemberCandidate = RequiredGenerated< + components['schemas']['SkillSuiteMemberCandidateResponse'] +> + +export type SkillSuiteMemberInput = components['schemas']['SkillSuiteMemberRequest'] +export type SkillSuiteDraftInput = components['schemas']['SkillSuiteCreateRequest'] + +export type MySkillSuiteSummary = RequiredGenerated< + components['schemas']['MySkillSuiteSummaryResponse'], + 'summary' +> + // Publish export interface PublishResult { skillId: number @@ -429,7 +474,7 @@ export interface ReviewTask { id: number skillVersionId: number | null namespace: string - skillSlug: string + skillSlug?: string | null version: string status: 'PENDING' | 'APPROVED' | 'REJECTED' submittedBy: string @@ -439,6 +484,10 @@ export interface ReviewTask { reviewComment?: string submittedAt: string reviewedAt?: string + subjectType?: 'SKILL_VERSION' | 'SUITE_VERSION' + subjectId?: number | null + subjectVersionId?: number | null + subjectSlug?: string | null } export interface ReviewProgress { diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 7d5000a8..9f70fb20 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -66,8 +66,10 @@ export function Layout() { { label: t('nav.landing'), to: '/', exact: true }, { label: t('nav.publish'), to: '/dashboard/publish', auth: true }, { label: t('nav.search'), to: '/search' }, + { label: t('nav.suites', { defaultValue: '技能套件' }), to: '/suites' }, { label: t('nav.dashboard'), to: '/dashboard', auth: true }, { label: t('nav.mySkills'), to: '/dashboard/skills', auth: true }, + { label: t('nav.mySuites'), to: '/dashboard/suites', auth: true }, ] const isActive = (to: string, exact?: boolean) => { diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 64df10cf..9e35f45e 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -78,6 +78,8 @@ const RegisterPage = createLazyRouteComponent(() => import('@/pages/register'), const ResetPasswordPage = createLazyRouteComponent(() => import('@/pages/reset-password'), 'ResetPasswordPage') const PrivacyPolicyPage = createLazyRouteComponent(() => import('@/pages/privacy'), 'PrivacyPolicyPage') const SearchPage = createLazyRouteComponent(() => import('@/pages/search'), 'SearchPage') +const SuitesPage = createLazyRouteComponent(() => import('@/pages/suites'), 'SuitesPage') +const SuiteDetailPage = createLazyRouteComponent(() => import('@/pages/suite-detail'), 'SuiteDetailPage') const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage') const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage') const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage') @@ -85,6 +87,13 @@ const SkillVersionComparePage = createLazyRouteComponent(() => import('@/pages/s const DashboardPage = createLazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage') const MySkillsPage = createLazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage') const PublishPage = createLazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage') +const SuiteCreatePage = createLazyRouteComponent(() => import('@/pages/dashboard/suite-editor'), 'SuiteCreatePage') +const SuiteEditPage = createLazyRouteComponent(() => import('@/pages/dashboard/suite-editor'), 'SuiteEditPage') +const SuiteVersionCreatePage = createLazyRouteComponent( + () => import('@/pages/dashboard/suite-editor'), + 'SuiteVersionCreatePage', +) +const MySuitesPage = createLazyRouteComponent(() => import('@/pages/dashboard/my-suites'), 'MySuitesPage') const MyNamespacesPage = createLazyRouteComponent( () => import('@/pages/dashboard/my-namespaces'), 'MyNamespacesPage', @@ -219,11 +228,12 @@ const searchRoute = createRoute({ getParentRoute: () => rootRoute, path: 'search', component: SearchPage, - validateSearch: (search: Record): { q: string; namespace?: string; label?: string; sort: string; page: number; starredOnly: boolean } => { + validateSearch: (search: Record): { q: string; namespace?: string; label?: string; resourceType?: 'SKILL' | 'SUITE'; sort: string; page: number; starredOnly: boolean } => { return { q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''), namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace.replace(/^@/, '') : undefined, label: typeof search.label === 'string' && search.label ? search.label : undefined, + resourceType: search.resourceType === 'SKILL' || search.resourceType === 'SUITE' ? search.resourceType : undefined, sort: (search.sort as string) || 'newest', page: Number(search.page) || 0, starredOnly: search.starredOnly === true || search.starredOnly === 'true', @@ -231,6 +241,21 @@ const searchRoute = createRoute({ }, }) +const suitesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'suites', + component: SuitesPage, +}) + +const suiteDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/suite/$namespace/$slug', + validateSearch: (search: Record): { version?: string } => ({ + version: typeof search.version === 'string' && search.version ? search.version : undefined, + }), + component: SuiteDetailPage, +}) + const termsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'terms', @@ -305,6 +330,42 @@ const dashboardPublishRoute = createRoute({ component: PublishPage, }) +const dashboardSuiteCreateRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/suites/new', + beforeLoad: requireAuth, + component: SuiteCreatePage, +}) + +const dashboardSuitesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/suites', + beforeLoad: requireAuth, + component: MySuitesPage, +}) + +const dashboardSuiteEditRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/suites/$namespace/$slug/edit', + beforeLoad: requireAuth, + validateSearch: (search: Record): { version: string } => ({ + version: typeof search.version === 'string' ? search.version : '', + }), + component: SuiteEditPage, +}) + +const dashboardSuiteVersionCreateRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/suites/$namespace/$slug/new-version', + beforeLoad: requireAuth, + validateSearch: (search: Record): { sourceVersion?: string } => ({ + sourceVersion: typeof search.sourceVersion === 'string' && search.sourceVersion + ? search.sourceVersion + : undefined, + }), + component: SuiteVersionCreatePage, +}) + const dashboardNamespacesRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/namespaces', @@ -504,6 +565,8 @@ const routeTree = rootRoute.addChildren([ resetPasswordRoute, privacyRoute, searchRoute, + suitesRoute, + suiteDetailRoute, termsRoute, namespaceRoute, skillDetailRoute, @@ -511,6 +574,10 @@ const routeTree = rootRoute.addChildren([ dashboardRoute, dashboardSkillsRoute, dashboardPublishRoute, + dashboardSuitesRoute, + dashboardSuiteCreateRoute, + dashboardSuiteEditRoute, + dashboardSuiteVersionCreateRoute, dashboardNamespacesRoute, dashboardNamespaceMembersRoute, dashboardNamespaceReviewsRoute, diff --git a/web/src/features/review/use-review-detail.ts b/web/src/features/review/use-review-detail.ts index 88700518..4b9b9350 100644 --- a/web/src/features/review/use-review-detail.ts +++ b/web/src/features/review/use-review-detail.ts @@ -39,11 +39,11 @@ export function useReviewDetail(taskId: number) { }) } -export function useReviewSkillDetail(taskId: number) { +export function useReviewSkillDetail(taskId: number, enabled = true) { return useQuery({ queryKey: ['reviews', taskId, 'skill-detail'], queryFn: () => getReviewSkillDetail(taskId), - enabled: !!taskId, + enabled: enabled && !!taskId, }) } diff --git a/web/src/features/suite/resource-card.test.tsx b/web/src/features/suite/resource-card.test.tsx new file mode 100644 index 00000000..bf579793 --- /dev/null +++ b/web/src/features/suite/resource-card.test.tsx @@ -0,0 +1,62 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { ResourceCard } from './resource-card' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock('@/shared/components/namespace-badge', () => ({ + NamespaceBadge: ({ name }: { name: string }) => {name}, +})) + +describe('ResourceCard', () => { + it('renders an explicit Suite type independently from a same-slug Skill', () => { + const html = renderToStaticMarkup( + undefined} + />, + ) + + expect(html).toContain('suite.resourceTypeSuite') + expect(html).toContain('Starter Suite') + expect(html).toContain('@team-ai') + expect(html).toContain('v2.0.0') + }) + + it('shows the computed unavailable state', () => { + const html = renderToStaticMarkup( + undefined} + />, + ) + + expect(html).toContain('suite.unavailable') + }) +}) diff --git a/web/src/features/suite/resource-card.tsx b/web/src/features/suite/resource-card.tsx new file mode 100644 index 00000000..077e382f --- /dev/null +++ b/web/src/features/suite/resource-card.tsx @@ -0,0 +1,56 @@ +import { Boxes, Download, Wrench } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import type { ResourceSummary } from '@/api/types' +import { NamespaceBadge } from '@/shared/components/namespace-badge' +import { Card } from '@/shared/ui/card' +import { formatCompactCount } from '@/shared/lib/number-format' + +export function ResourceCard({ resource, onClick }: { resource: ResourceSummary; onClick: () => void }) { + const { t } = useTranslation() + const isSuite = resource.resourceType === 'SUITE' + return ( + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onClick() + } + }} + > +
+
+
+ + {isSuite ? : } + {t(isSuite ? 'suite.resourceTypeSuite' : 'suite.resourceTypeSkill')} + +

{resource.displayName}

+
+ +
+

+ {resource.summary || t('suite.noSummary')} +

+
+ + v{resource.version} + + {formatCompactCount(resource.installCount)} + {!resource.available ? {t('suite.unavailable')} : null} +
+
+
+ ) +} diff --git a/web/src/features/suite/suite-labels.ts b/web/src/features/suite/suite-labels.ts new file mode 100644 index 00000000..b4ec774b --- /dev/null +++ b/web/src/features/suite/suite-labels.ts @@ -0,0 +1,29 @@ +import type { TFunction } from 'i18next' + +const STATUS_KEYS: Record = { + DRAFT: 'skillDetail.versionStatusDraft', + PENDING_REVIEW: 'skillDetail.versionStatusPendingReview', + PUBLISHED: 'skillDetail.versionStatusPublished', + REJECTED: 'skillDetail.versionStatusRejected', + YANKED: 'skillDetail.versionStatusYanked', +} + +const VISIBILITY_KEYS: Record = { + PUBLIC: 'publish.visibilityOptions.public', + NAMESPACE_ONLY: 'publish.visibilityOptions.namespaceOnly', + PRIVATE: 'publish.visibilityOptions.private', +} + +export function suiteStatusLabel(t: TFunction, status: string): string { + const key = STATUS_KEYS[status] + return key ? t(key) : status +} + +export function suiteVisibilityLabel(t: TFunction, visibility: string): string { + const key = VISIBILITY_KEYS[visibility] + return key ? t(key) : visibility +} + +export function suiteBlockingReasonLabel(t: TFunction, reason: string): string { + return t(`suite.blockingReasons.${reason}`, { defaultValue: reason }) +} diff --git a/web/src/features/suite/suite-management-actions.test.tsx b/web/src/features/suite/suite-management-actions.test.tsx new file mode 100644 index 00000000..ad2344b2 --- /dev/null +++ b/web/src/features/suite/suite-management-actions.test.tsx @@ -0,0 +1,210 @@ +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SkillSuite } from '@/api/types' +import { SuiteManagementActions } from './suite-management-actions' + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), + reopen: { mutateAsync: vi.fn(), isPending: false }, + yank: { mutateAsync: vi.fn(), isPending: false }, + hidden: { mutateAsync: vi.fn(), isPending: false }, + archived: { mutateAsync: vi.fn(), isPending: false }, + remove: { mutateAsync: vi.fn(), isPending: false }, + toast: { success: vi.fn(), error: vi.fn() }, +})) + +vi.mock('@tanstack/react-router', () => ({ useNavigate: () => mocks.navigate })) +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) +vi.mock('@/shared/hooks/use-suite-queries', () => ({ + useReopenSuiteVersion: () => mocks.reopen, + useYankSuiteVersion: () => mocks.yank, + useSetSuiteHidden: () => mocks.hidden, + useSetSuiteArchived: () => mocks.archived, + useDeleteSuite: () => mocks.remove, +})) +vi.mock('@/shared/lib/toast', () => ({ toast: mocks.toast })) +vi.mock('@/shared/components/confirm-dialog', () => ({ + ConfirmDialog: ({ open, confirmText, onConfirm }: { + open: boolean + confirmText?: string + onConfirm: () => void | Promise + }) => open ? : null, +})) +vi.mock('@/shared/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) => open ? <>{children} : null, + DialogContent: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children?: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children?: ReactNode }) =>

{children}

, +})) + +function suite(overrides: Partial = {}): SkillSuite { + return { + id: 7, + versionId: 70, + namespace: 'global', + slug: 'starter', + displayName: 'Starter suite', + version: '1.0.0', + status: 'PUBLISHED', + visibility: 'PUBLIC', + suiteStatus: 'ACTIVE', + hidden: false, + allowedActions: [], + available: true, + members: [], + ...overrides, + } +} + +describe('SuiteManagementActions', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('does not expose management controls to a public reader', () => { + const { container } = render() + + expect(container.innerHTML).toBe('') + }) + + it('does not render an empty management card for draft-only actions', () => { + const { container } = render() + + expect(container.innerHTML).toBe('') + }) + + it('shows only rejected-version recovery to its author', () => { + render() + + expect(screen.getByRole('button', { name: 'suite.reopenDraft' })).not.toBeNull() + expect(screen.queryByRole('button', { name: 'suite.delete' })).toBeNull() + }) + + it('reopens a rejected version before navigating to its editor', async () => { + mocks.reopen.mutateAsync.mockResolvedValue(undefined) + render() + + fireEvent.click(screen.getByRole('button', { name: 'suite.reopenDraft' })) + + await waitFor(() => expect(mocks.reopen.mutateAsync).toHaveBeenCalledWith({ suiteId: 7, versionId: 70 })) + expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/dashboard/suites/global/starter/edit', + search: { version: '1.0.0' }, + }) + }) + + it('offers a new immutable version and governance actions to an administrator', () => { + render() + + expect(screen.getByRole('button', { name: 'suite.createVersion' })).not.toBeNull() + expect(screen.getByRole('button', { name: 'suite.yankVersion' })).not.toBeNull() + expect(screen.getByRole('button', { name: 'suite.hide' })).not.toBeNull() + expect(screen.getByRole('button', { name: 'suite.archive' })).not.toBeNull() + expect(screen.getByRole('button', { name: 'suite.delete' })).not.toBeNull() + }) + + it('prefills a new version from the selected immutable version', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'suite.createVersion' })) + + expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/dashboard/suites/global/starter/new-version', + search: { sourceVersion: '1.0.0' }, + }) + }) + + it('requires a reason and yanks the exact version', async () => { + mocks.yank.mutateAsync.mockResolvedValue(undefined) + render() + + fireEvent.click(screen.getByRole('button', { name: 'suite.yankVersion' })) + const confirm = screen.getAllByRole('button', { name: 'suite.yankVersion' })[1] + expect(confirm.hasAttribute('disabled')).toBe(true) + + fireEvent.change(screen.getByPlaceholderText('suite.yankReasonPlaceholder'), { + target: { value: 'superseded' }, + }) + fireEvent.click(confirm) + + await waitFor(() => expect(mocks.yank.mutateAsync).toHaveBeenCalledWith({ + suiteId: 7, + versionId: 70, + reason: 'superseded', + })) + expect(mocks.toast.success).toHaveBeenCalledWith('suite.yanked') + }) + + it('confirms hiding without inferring the opposite action', async () => { + mocks.hidden.mutateAsync.mockResolvedValue(undefined) + render() + + fireEvent.click(screen.getByRole('button', { name: 'suite.hide' })) + fireEvent.click(screen.getByRole('button', { name: 'confirm:suite.hide' })) + + await waitFor(() => expect(mocks.hidden.mutateAsync).toHaveBeenCalledWith({ suiteId: 7, hidden: true })) + expect(screen.queryByRole('button', { name: 'suite.restore' })).toBeNull() + }) + + it('confirms restoring a hidden Suite', async () => { + mocks.hidden.mutateAsync.mockResolvedValue(undefined) + render() + + fireEvent.click(screen.getByRole('button', { name: 'suite.restore' })) + fireEvent.click(screen.getByRole('button', { name: 'confirm:suite.restore' })) + + await waitFor(() => expect(mocks.hidden.mutateAsync).toHaveBeenCalledWith({ suiteId: 7, hidden: false })) + }) + + it('confirms archive and unarchive with the server-provided action', async () => { + mocks.archived.mutateAsync.mockResolvedValue(undefined) + const first = render() + + fireEvent.click(screen.getByRole('button', { name: 'suite.archive' })) + fireEvent.click(screen.getByRole('button', { name: 'confirm:suite.archive' })) + await waitFor(() => expect(mocks.archived.mutateAsync).toHaveBeenCalledWith({ suiteId: 7, archived: true })) + + first.unmount() + render() + fireEvent.click(screen.getByRole('button', { name: 'suite.unarchive' })) + fireEvent.click(screen.getByRole('button', { name: 'confirm:suite.unarchive' })) + await waitFor(() => expect(mocks.archived.mutateAsync).toHaveBeenCalledWith({ suiteId: 7, archived: false })) + }) + + it('deletes only after confirmation and returns to the Suite dashboard', async () => { + mocks.remove.mutateAsync.mockResolvedValue(undefined) + render() + + fireEvent.click(screen.getByRole('button', { name: 'suite.delete' })) + fireEvent.click(screen.getByRole('button', { name: 'confirm:suite.delete' })) + + await waitFor(() => expect(mocks.remove.mutateAsync).toHaveBeenCalledWith({ suiteId: 7 })) + expect(mocks.navigate).toHaveBeenCalledWith({ to: '/dashboard/suites' }) + }) + + it('shows an operation error without navigating', async () => { + mocks.reopen.mutateAsync.mockRejectedValue(new Error('forbidden')) + render() + + fireEvent.click(screen.getByRole('button', { name: 'suite.reopenDraft' })) + + await waitFor(() => expect(mocks.toast.error).toHaveBeenCalledWith('suite.actionFailed', 'forbidden')) + expect(mocks.navigate).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/features/suite/suite-management-actions.tsx b/web/src/features/suite/suite-management-actions.tsx new file mode 100644 index 00000000..6ec97d13 --- /dev/null +++ b/web/src/features/suite/suite-management-actions.tsx @@ -0,0 +1,189 @@ +import { useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import type { SkillSuite } from '@/api/types' +import { + useDeleteSuite, + useReopenSuiteVersion, + useSetSuiteArchived, + useSetSuiteHidden, + useYankSuiteVersion, +} from '@/shared/hooks/use-suite-queries' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { toast } from '@/shared/lib/toast' + +type ConfirmAction = 'hide' | 'restore' | 'archive' | 'unarchive' | 'delete' + +export function SuiteManagementActions({ suite }: { suite: SkillSuite }) { + const { t } = useTranslation() + const navigate = useNavigate() + const [confirmAction, setConfirmAction] = useState(null) + const [yankOpen, setYankOpen] = useState(false) + const [yankReason, setYankReason] = useState('') + const reopenMutation = useReopenSuiteVersion() + const yankMutation = useYankSuiteVersion() + const hiddenMutation = useSetSuiteHidden() + const archivedMutation = useSetSuiteArchived() + const deleteMutation = useDeleteSuite() + + const navigateToEditor = (newVersion: boolean) => { + if (newVersion) { + navigate({ + to: `/dashboard/suites/${suite.namespace}/${encodeURIComponent(suite.slug)}/new-version`, + search: { sourceVersion: suite.version }, + }) + return + } + navigate({ + to: `/dashboard/suites/${suite.namespace}/${encodeURIComponent(suite.slug)}/edit`, + search: { version: suite.version }, + }) + } + + const reopen = async () => { + try { + await reopenMutation.mutateAsync({ suiteId: suite.id, versionId: suite.versionId }) + toast.success(t('suite.reopened')) + navigateToEditor(false) + } catch (error) { + toast.error(t('suite.actionFailed'), error instanceof Error ? error.message : '') + } + } + + const confirmLifecycleAction = async () => { + if (!confirmAction) return + try { + if (confirmAction === 'hide' || confirmAction === 'restore') { + await hiddenMutation.mutateAsync({ suiteId: suite.id, hidden: confirmAction === 'hide' }) + } else if (confirmAction === 'archive' || confirmAction === 'unarchive') { + await archivedMutation.mutateAsync({ suiteId: suite.id, archived: confirmAction === 'archive' }) + } else { + await deleteMutation.mutateAsync({ suiteId: suite.id }) + toast.success(t('suite.deleted')) + navigate({ to: '/dashboard/suites' }) + return + } + toast.success(t(`suite.${confirmAction}Success`)) + } catch (error) { + toast.error(t('suite.actionFailed'), error instanceof Error ? error.message : '') + } + } + + const yank = async () => { + if (!yankReason.trim()) return + try { + await yankMutation.mutateAsync({ + suiteId: suite.id, + versionId: suite.versionId, + reason: yankReason.trim(), + }) + toast.success(t('suite.yanked')) + setYankOpen(false) + setYankReason('') + } catch (error) { + toast.error(t('suite.actionFailed'), error instanceof Error ? error.message : '') + } + } + + const allowed = new Set(suite.allowedActions) + const managementActions: SkillSuite['allowedActions'] = [ + 'REOPEN', + 'CREATE_VERSION', + 'YANK', + 'HIDE', + 'RESTORE', + 'ARCHIVE', + 'UNARCHIVE', + 'DELETE', + ] + if (!managementActions.some((action) => allowed.has(action))) return null + + const confirmTitle = confirmAction ? t(`suite.${confirmAction}ConfirmTitle`) : '' + const confirmDescription = confirmAction + ? t(`suite.${confirmAction}ConfirmDescription`, { name: suite.displayName }) + : '' + + return ( + +

{t('suite.managementTitle')}

+

{t('suite.managementDescription')}

+
+ {allowed.has('REOPEN') ? ( + + ) : null} + {allowed.has('CREATE_VERSION') ? ( + + ) : null} + {allowed.has('YANK') ? ( + + ) : null} + {allowed.has('HIDE') || allowed.has('RESTORE') ? ( + + ) : null} + {allowed.has('ARCHIVE') || allowed.has('UNARCHIVE') ? ( + + ) : null} + {allowed.has('DELETE') ? ( + + ) : null} +
+ + { if (!open) setConfirmAction(null) }} + title={confirmTitle} + description={confirmDescription} + confirmText={confirmAction ? t(`suite.${confirmAction}`) : undefined} + variant={confirmAction === 'delete' || confirmAction === 'archive' ? 'destructive' : 'default'} + onConfirm={confirmLifecycleAction} + /> + + + + + {t('suite.yankConfirmTitle')} + {t('suite.yankConfirmDescription', { version: suite.version })} + + setYankReason(event.target.value)} + placeholder={t('suite.yankReasonPlaceholder')} + /> + + + + + + +
+ ) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ae77f032..e843fc60 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -17,7 +17,9 @@ "explore": "Explore Skills", "dashboard": "Dashboard", "mySkills": "My Skills", + "mySuites": "My Suites", "publish": "Publish", + "suites": "Skill Suites", "login": "Login" }, "landing": { @@ -1737,5 +1739,124 @@ "unfreezeErrorTitle": "Failed to unfreeze namespace", "archiveErrorTitle": "Failed to archive namespace", "restoreErrorTitle": "Failed to restore namespace" + }, + "suite": { + "listTitle": "Skill Suites", + "listDescription": "Install a version-pinned collection of skills in one operation.", + "create": "Create suite", + "searchPlaceholder": "Search suite names or descriptions", + "emptyTitle": "No suites available", + "emptyDescription": "Create the first suite to package a reusable skill workflow.", + "noSummary": "No description", + "unavailable": "Unavailable", + "notFound": "The suite does not exist or your account cannot access it.", + "published": "Suite published", + "submitted": "Suite submitted for review", + "actionFailed": "Action failed", + "editDraft": "Edit draft", + "publishDirectly": "Publish", + "submitReview": "Submit for review", + "version": "Version", + "status": "Status", + "visibility": "Visibility", + "installStatus": "Install status", + "available": "Available", + "degraded": "Degraded", + "installCommand": "Install command", + "commandCopied": "Install command copied", + "degradedDescription": "A member or its access changed. Restore every member before installing this suite.", + "membersTitle": "Included skills ({{count}})", + "overviewTab": "Overview", + "membersTab": "Skill modules ({{count}})", + "overviewTitle": "Suite overview", + "noOverview": "The author has not provided a detailed overview.", + "viewMember": "View skill: {{name}}", + "pinnedVersion": "Pinned version v{{version}}", + "entrySkill": "Entry skill", + "versionHistory": "Version history", + "validationRequired": "Complete the required fields and select at least one skill", + "draftUpdated": "Suite draft updated", + "draftCreated": "Suite draft created", + "saveFailed": "Save failed", + "editTitle": "Edit skill suite", + "createTitle": "Create skill suite", + "newVersionTitle": "Create suite version", + "newVersionPlaceholder": "Enter a new version, for example 1.1.0", + "sourceLoadFailed": "Unable to load the source suite version. Return to its detail page and try again.", + "editorAccessDenied": "Your account cannot edit this Suite version.", + "editorDescription": "Select exact published skill versions. Published suites never drift to newer versions automatically.", + "namespace": "Namespace", + "selectNamespace": "Select a namespace", + "name": "Name", + "namePlaceholder": "Marketing event workflow", + "overview": "Overview (Markdown)", + "overviewHint": "Explain the use case, member sequence, inputs, outputs, and important constraints.", + "visibilityPublic": "Public", + "visibilityNamespace": "Namespace only", + "visibilityPrivate": "Private", + "summary": "Description", + "changelog": "Release notes", + "selectSkills": "Select skills", + "searchSkills": "Search eligible skills", + "noCandidates": "No published versions satisfy the selected visibility.", + "selectedMembers": "Suite members ({{count}}/100)", + "setEntry": "Set as optional entry skill", + "addMemberHint": "Add at least one skill from the left.", + "cancel": "Cancel", + "saveDraft": "Save draft", + "resourceType": "Type", + "allTypes": "All", + "reviewSnapshot": "Suite member snapshot", + "reviewSnapshotDescription": "Review these exact Skill versions. They will not upgrade automatically after publication.", + "publishable": "Publishable", + "reviewSnapshotFailed": "Unable to load the suite review snapshot.", + "myTitle": "My skill suites", + "myDescription": "Manage draft, pending-review, and published suite versions.", + "view": "View", + "myEmpty": "No skill suites yet", + "myEmptyDescription": "Create a suite to continue editing it and track its review status here.", + "updatePinnedVersion": "Update pinned version", + "resourceTypeSuite": "Suite", + "resourceTypeSkill": "Skill", + "managementTitle": "Suite management", + "managementDescription": "Manage versions, publication state, and the suite container. Member skills are never changed.", + "reopenDraft": "Reopen and edit draft", + "reopened": "Suite version reopened as a draft", + "createVersion": "Create new version", + "yankVersion": "Yank current version", + "yanked": "Suite version yanked", + "yankConfirmTitle": "Yank suite version", + "yankConfirmDescription": "After v{{version}} is yanked, new users cannot install it. Member skills are unaffected.", + "yankReasonPlaceholder": "Reason for yanking this version", + "hide": "Hide suite", + "restore": "Restore visibility", + "archive": "Archive suite", + "unarchive": "Restore suite", + "delete": "Delete suite", + "deleted": "Suite deleted", + "hideSuccess": "Suite hidden", + "restoreSuccess": "Suite visibility restored", + "archiveSuccess": "Suite archived", + "unarchiveSuccess": "Suite restored", + "hideConfirmTitle": "Hide suite", + "hideConfirmDescription": "“{{name}}” will no longer appear in normal discovery results.", + "restoreConfirmTitle": "Restore suite visibility", + "restoreConfirmDescription": "“{{name}}” will be discoverable again according to its visibility.", + "archiveConfirmTitle": "Archive suite", + "archiveConfirmDescription": "Archiving “{{name}}” prevents new versions. Member skills are unaffected.", + "unarchiveConfirmTitle": "Restore archived suite", + "unarchiveConfirmDescription": "Restoring “{{name}}” allows new versions again.", + "deleteConfirmTitle": "Delete suite", + "deleteConfirmDescription": "Permanently delete “{{name}}” and its versions without deleting member skills. This cannot be undone.", + "deletePendingReviewHint": "A suite under review cannot be deleted", + "blockingReasons": { + "DELETED": "Deleted member", + "NAMESPACE_ARCHIVED": "Member namespace archived", + "NAMESPACE_FROZEN": "Member namespace frozen", + "SKILL_HIDDEN": "Skill hidden", + "SKILL_ARCHIVED": "Skill archived", + "VERSION_UNAVAILABLE": "Version unavailable", + "VISIBILITY_INCOMPATIBLE": "Visibility incompatible" + } } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index cb32b413..cb03f5c3 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -17,7 +17,9 @@ "explore": "Обзор скиллов", "dashboard": "Панель управления", "mySkills": "Мои скиллы", + "mySuites": "Мои наборы", "publish": "Опубликовать", + "suites": "Наборы навыков", "login": "Вход" }, "landing": { @@ -1735,6 +1737,125 @@ "logout": "Выйти" } }, + "suite": { + "listTitle": "Наборы навыков", + "listDescription": "Установите набор навыков с зафиксированными версиями одной операцией.", + "create": "Создать набор", + "searchPlaceholder": "Поиск по названию или описанию", + "emptyTitle": "Нет доступных наборов", + "emptyDescription": "Создайте первый набор для повторно используемого рабочего процесса.", + "noSummary": "Описание отсутствует", + "unavailable": "Недоступно", + "notFound": "Набор не существует или недоступен вашей учётной записи.", + "published": "Набор опубликован", + "submitted": "Набор отправлен на проверку", + "actionFailed": "Не удалось выполнить действие", + "editDraft": "Изменить черновик", + "publishDirectly": "Опубликовать", + "submitReview": "Отправить на проверку", + "version": "Версия", + "status": "Статус", + "visibility": "Видимость", + "installStatus": "Статус установки", + "available": "Доступно", + "degraded": "Ограничено", + "installCommand": "Команда установки", + "commandCopied": "Команда установки скопирована", + "degradedDescription": "Участник или права доступа изменились. Восстановите всех участников перед установкой.", + "membersTitle": "Включённые навыки ({{count}})", + "overviewTab": "Обзор", + "membersTab": "Модули навыков ({{count}})", + "overviewTitle": "Обзор набора", + "noOverview": "Автор пока не добавил подробный обзор.", + "viewMember": "Открыть навык: {{name}}", + "pinnedVersion": "Зафиксированная версия v{{version}}", + "entrySkill": "Входной навык", + "versionHistory": "История версий", + "validationRequired": "Заполните обязательные поля и выберите хотя бы один навык", + "draftUpdated": "Черновик набора обновлён", + "draftCreated": "Черновик набора создан", + "saveFailed": "Не удалось сохранить", + "editTitle": "Изменить набор навыков", + "createTitle": "Создать набор навыков", + "newVersionTitle": "Создать версию набора", + "newVersionPlaceholder": "Введите новую версию, например 1.1.0", + "sourceLoadFailed": "Не удалось загрузить исходную версию набора. Вернитесь на страницу набора и повторите попытку.", + "editorAccessDenied": "Эта учётная запись не может редактировать данную версию набора.", + "editorDescription": "Выберите точные опубликованные версии. Опубликованный набор не обновляется автоматически.", + "namespace": "Пространство имён", + "selectNamespace": "Выберите пространство имён", + "name": "Название", + "namePlaceholder": "Рабочий процесс маркетингового события", + "overview": "Обзор (Markdown)", + "overviewHint": "Опишите сценарий использования, порядок навыков, входные и выходные данные и важные ограничения.", + "visibilityPublic": "Публичный", + "visibilityNamespace": "Только пространство имён", + "visibilityPrivate": "Приватный", + "summary": "Описание", + "changelog": "Примечания к версии", + "selectSkills": "Выберите навыки", + "searchSkills": "Поиск доступных навыков", + "noCandidates": "Нет опубликованных версий с выбранной видимостью.", + "selectedMembers": "Участники набора ({{count}}/100)", + "setEntry": "Назначить необязательным входным навыком", + "addMemberHint": "Добавьте хотя бы один навык слева.", + "cancel": "Отмена", + "saveDraft": "Сохранить черновик", + "resourceType": "Тип", + "allTypes": "Все", + "reviewSnapshot": "Снимок участников набора", + "reviewSnapshotDescription": "Проверяются точные версии Skill; после публикации они не обновятся автоматически.", + "publishable": "Можно опубликовать", + "reviewSnapshotFailed": "Не удалось загрузить снимок набора.", + "myTitle": "Мои наборы навыков", + "myDescription": "Управление черновиками, проверяемыми и опубликованными версиями наборов.", + "view": "Открыть", + "myEmpty": "Наборов навыков пока нет", + "myEmptyDescription": "Создайте набор, чтобы редактировать его и отслеживать проверку здесь.", + "updatePinnedVersion": "Обновить зафиксированную версию", + "resourceTypeSuite": "Набор навыков", + "resourceTypeSkill": "Навык", + "managementTitle": "Управление набором", + "managementDescription": "Управляйте версиями, публикацией и контейнером набора. Навыки-участники не изменяются.", + "reopenDraft": "Вернуть в черновик и изменить", + "reopened": "Версия возвращена в черновик", + "createVersion": "Создать новую версию", + "yankVersion": "Снять текущую версию", + "yanked": "Версия набора снята", + "yankConfirmTitle": "Снять версию набора", + "yankConfirmDescription": "После снятия v{{version}} новые пользователи не смогут её установить. Навыки не изменятся.", + "yankReasonPlaceholder": "Причина снятия версии", + "hide": "Скрыть набор", + "restore": "Вернуть видимость", + "archive": "Архивировать набор", + "unarchive": "Восстановить набор", + "delete": "Удалить набор", + "deleted": "Набор удалён", + "hideSuccess": "Набор скрыт", + "restoreSuccess": "Видимость набора восстановлена", + "archiveSuccess": "Набор архивирован", + "unarchiveSuccess": "Набор восстановлен", + "hideConfirmTitle": "Скрыть набор", + "hideConfirmDescription": "«{{name}}» больше не будет отображаться в обычном поиске.", + "restoreConfirmTitle": "Вернуть видимость набора", + "restoreConfirmDescription": "«{{name}}» снова будет доступен согласно настройкам видимости.", + "archiveConfirmTitle": "Архивировать набор", + "archiveConfirmDescription": "После архивации «{{name}}» нельзя создавать новые версии. Навыки не изменятся.", + "unarchiveConfirmTitle": "Восстановить набор", + "unarchiveConfirmDescription": "После восстановления «{{name}}» снова можно создавать версии.", + "deleteConfirmTitle": "Удалить набор", + "deleteConfirmDescription": "Безвозвратно удалить «{{name}}» и его версии, не удаляя навыки. Отменить действие нельзя.", + "deletePendingReviewHint": "Набор на проверке нельзя удалить", + "blockingReasons": { + "DELETED": "Компонент удалён", + "NAMESPACE_ARCHIVED": "Пространство имён компонента архивировано", + "NAMESPACE_FROZEN": "Пространство имён компонента заморожено", + "SKILL_HIDDEN": "Навык скрыт", + "SKILL_ARCHIVED": "Навык архивирован", + "VERSION_UNAVAILABLE": "Версия недоступна", + "VISIBILITY_INCOMPATIBLE": "Несовместимая видимость" + } + }, "routeError": { "title": "Что-то пошло не так", "description": "На этой странице произошла ошибка. Можно вернуться назад или перезагрузить раздел.", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 5e67b612..41c97c2f 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -17,7 +17,9 @@ "explore": "探索", "dashboard": "控制台", "mySkills": "我的技能", + "mySuites": "我的套件", "publish": "发布", + "suites": "技能套件", "login": "登录" }, "landing": { @@ -1736,5 +1738,124 @@ "unfreezeErrorTitle": "解冻命名空间失败", "archiveErrorTitle": "归档命名空间失败", "restoreErrorTitle": "恢复命名空间失败" + }, + "suite": { + "listTitle": "技能套件", + "listDescription": "一次安装一组经过版本锁定的技能。", + "create": "创建套件", + "searchPlaceholder": "搜索套件名称或说明", + "emptyTitle": "暂无可用套件", + "emptyDescription": "创建第一个套件,组合可复用的技能工作流。", + "noSummary": "暂无简介", + "unavailable": "当前不可安装", + "notFound": "套件不存在或当前账号无权查看。", + "published": "套件已发布", + "submitted": "套件已提交审核", + "actionFailed": "操作失败", + "editDraft": "编辑草稿", + "publishDirectly": "直接发布", + "submitReview": "提交审核", + "version": "版本", + "status": "状态", + "visibility": "可见性", + "installStatus": "安装状态", + "available": "可用", + "degraded": "已降级", + "installCommand": "安装命令", + "commandCopied": "已复制安装命令", + "degradedDescription": "成员失效或权限发生变化,恢复所有成员后才能安装。", + "membersTitle": "包含的技能({{count}})", + "overviewTab": "概述", + "membersTab": "技能模块({{count}})", + "overviewTitle": "套件概述", + "noOverview": "作者暂未提供详细概述。", + "viewMember": "查看技能:{{name}}", + "pinnedVersion": "固定版本 v{{version}}", + "entrySkill": "入口技能", + "versionHistory": "版本历史", + "validationRequired": "请填写必填项并至少选择一个技能", + "draftUpdated": "套件草稿已更新", + "draftCreated": "套件草稿已创建", + "saveFailed": "保存失败", + "editTitle": "编辑技能套件", + "createTitle": "创建技能套件", + "newVersionTitle": "创建套件新版本", + "newVersionPlaceholder": "输入新版本号,例如 1.1.0", + "sourceLoadFailed": "无法读取套件源版本,请返回详情页后重试。", + "editorAccessDenied": "当前账号不能编辑此套件版本。", + "editorDescription": "选择已发布的精确技能版本;套件发布后不会自动漂移到最新版本。", + "namespace": "命名空间", + "selectNamespace": "选择命名空间", + "name": "名称", + "namePlaceholder": "营销活动工作流", + "overview": "概述(Markdown)", + "overviewHint": "说明套件的使用场景、成员协作顺序、输入输出和注意事项。", + "visibilityPublic": "公开", + "visibilityNamespace": "仅命名空间", + "visibilityPrivate": "私有", + "summary": "简介", + "changelog": "版本说明", + "selectSkills": "选择技能", + "searchSkills": "搜索可用技能", + "noCandidates": "没有符合当前可见性要求的已发布版本。", + "selectedMembers": "套件成员({{count}}/100)", + "setEntry": "设为入口技能(可选)", + "addMemberHint": "从左侧添加至少一个技能。", + "cancel": "取消", + "saveDraft": "保存草稿", + "resourceType": "类型", + "allTypes": "全部", + "reviewSnapshot": "套件成员快照", + "reviewSnapshotDescription": "审核的是以下精确 Skill 版本,发布后不会自动升级。", + "publishable": "可发布", + "reviewSnapshotFailed": "无法读取套件审核快照。", + "myTitle": "我的技能套件", + "myDescription": "管理草稿、审核中和已发布的套件版本。", + "view": "查看", + "myEmpty": "还没有技能套件", + "myEmptyDescription": "创建套件后,可在这里继续编辑和查看审核状态。", + "updatePinnedVersion": "更新固定版本", + "resourceTypeSuite": "技能套件", + "resourceTypeSkill": "技能", + "managementTitle": "套件管理", + "managementDescription": "管理新版本、发布状态和套件容器;这些操作不会改变成员技能。", + "reopenDraft": "退回草稿并编辑", + "reopened": "已恢复为草稿", + "createVersion": "创建新版本", + "yankVersion": "下架当前版本", + "yanked": "当前版本已下架", + "yankConfirmTitle": "下架套件版本", + "yankConfirmDescription": "下架 v{{version}} 后,新用户不能再安装该版本。成员技能不会受到影响。", + "yankReasonPlaceholder": "填写下架原因", + "hide": "隐藏套件", + "restore": "恢复展示", + "archive": "归档套件", + "unarchive": "恢复套件", + "delete": "删除套件", + "deleted": "套件已删除", + "hideSuccess": "套件已隐藏", + "restoreSuccess": "套件已恢复展示", + "archiveSuccess": "套件已归档", + "unarchiveSuccess": "套件已恢复", + "hideConfirmTitle": "隐藏套件", + "hideConfirmDescription": "“{{name}}”将不再出现在普通发现结果中。", + "restoreConfirmTitle": "恢复套件展示", + "restoreConfirmDescription": "“{{name}}”将重新按其可见性规则展示。", + "archiveConfirmTitle": "归档套件", + "archiveConfirmDescription": "归档“{{name}}”后不能创建新版本,已有成员技能不受影响。", + "unarchiveConfirmTitle": "恢复已归档套件", + "unarchiveConfirmDescription": "恢复“{{name}}”后可继续创建新版本。", + "deleteConfirmTitle": "删除套件", + "deleteConfirmDescription": "永久删除“{{name}}”及其版本记录,但不会删除成员技能。此操作不可撤销。", + "deletePendingReviewHint": "审核中的套件不能删除", + "blockingReasons": { + "DELETED": "成员已删除", + "NAMESPACE_ARCHIVED": "成员命名空间已归档", + "NAMESPACE_FROZEN": "成员命名空间已冻结", + "SKILL_HIDDEN": "技能已隐藏", + "SKILL_ARCHIVED": "技能已归档", + "VERSION_UNAVAILABLE": "版本不可用", + "VISIBILITY_INCOMPATIBLE": "可见性不兼容" + } } } diff --git a/web/src/i18n/suite-locale.test.ts b/web/src/i18n/suite-locale.test.ts new file mode 100644 index 00000000..1d078d60 --- /dev/null +++ b/web/src/i18n/suite-locale.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import en from './locales/en.json' +import ru from './locales/ru.json' +import zh from './locales/zh.json' + +describe('suite locales', () => { + it('keeps Suite keys aligned in every supported locale', () => { + const expected = Object.keys(zh.suite).sort() + expect(Object.keys(en.suite).sort()).toEqual(expected) + expect(Object.keys(ru.suite).sort()).toEqual(expected) + expect(en.nav.suites).toBeTruthy() + expect(ru.nav.suites).toBeTruthy() + expect(zh.nav.suites).toBeTruthy() + }) +}) diff --git a/web/src/pages/dashboard/my-suites.tsx b/web/src/pages/dashboard/my-suites.tsx new file mode 100644 index 00000000..9eda4d6d --- /dev/null +++ b/web/src/pages/dashboard/my-suites.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { Boxes } from 'lucide-react' +import { useMySuites } from '@/shared/hooks/use-suite-queries' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { EmptyState } from '@/shared/components/empty-state' +import { Pagination } from '@/shared/components/pagination' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { suiteStatusLabel } from '@/features/suite/suite-labels' + +const PAGE_SIZE = 12 + +export function MySuitesPage() { + const { t } = useTranslation() + const navigate = useNavigate() + const [query, setQuery] = useState('') + const [page, setPage] = useState(0) + const { data, isLoading } = useMySuites(query.trim(), page, PAGE_SIZE) + + return ( +
+ navigate({ to: '/dashboard/suites/new' })}>{t('suite.create')}} + /> + { + setQuery(event.target.value) + setPage(0) + }} + /> + {isLoading ? ( +
+ ) : data?.items.length ? ( + <> +
+ {data.items.map((suite) => ( + +
+
+

{suite.displayName}

+

@{suite.namespace}/{suite.slug}@{suite.version}

+
+ {suiteStatusLabel(t, suite.versionStatus)} +
+

{suite.summary || t('suite.noSummary')}

+
+ +
+
+ ))} +
+ + + ) : ( + + )} +
+ ) +} diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index fdc5fd6c..8f5b8119 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -24,7 +24,10 @@ import { SecurityAuditSection } from '@/features/security-audit/security-audit-s import { FileTree } from '@/features/skill/file-tree' import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' import type { FileTreeNode } from '@/features/skill/file-tree-builder' +import { MarkdownRenderer } from '@/features/skill/markdown-renderer' import { useReviewFile } from '@/features/review/use-review-file' +import { useSuiteDetail } from '@/shared/hooks/use-suite-queries' +import { suiteBlockingReasonLabel } from '@/features/suite/suite-labels' import { buildApiUrl, WEB_API_PREFIX } from '@/api/client' import { useReviewAttempts, @@ -58,11 +61,18 @@ function ReviewDetailScreen({ isLoading: isLoadingReviewAttempts, isError: isReviewAttemptsError, } = useReviewAttempts(taskId) + const isSuiteReview = review?.subjectType === 'SUITE_VERSION' const { data: reviewSkillDetail, isLoading: isLoadingReviewSkillDetail, error: reviewSkillDetailError, - } = useReviewSkillDetail(taskId) + } = useReviewSkillDetail(taskId, Boolean(review) && !isSuiteReview) + const { data: reviewSuiteDetail, isLoading: isLoadingReviewSuiteDetail } = useSuiteDetail( + review?.namespace || '', + review?.subjectSlug || '', + review?.version, + isSuiteReview, + ) const approveMutation = useApproveReview({ onSuccess: () => { toast.success(t('review.approveSuccess')) @@ -209,7 +219,12 @@ function ReviewDetailScreen({
-

{review.namespace}/{review.skillSlug}

+

+ + {t(isSuiteReview ? 'suite.resourceTypeSuite' : 'suite.resourceTypeSkill')} + + {review.namespace}/{review.subjectSlug || review.skillSlug} +

@@ -347,7 +362,7 @@ function ReviewDetailScreen({ )} - {(() => { + {!isSuiteReview && (() => { const skillId = reviewSkillDetail?.skill?.id const versionId = reviewSkillDetail?.versions?.find((v) => v.version === review.version)?.id ?? @@ -357,12 +372,46 @@ function ReviewDetailScreen({ ) : null })()} - + {isSuiteReview ? ( + +
+

{t('suite.reviewSnapshot')}

+

{t('suite.reviewSnapshotDescription')}

+
+ {isLoadingReviewSuiteDetail ? ( +
+ ) : reviewSuiteDetail ? ( +
+ {reviewSuiteDetail.overview ? ( +
+

{t('suite.overviewTitle')}

+ +
+ ) : null} +
+ {reviewSuiteDetail.members.map((member) => ( +
+
+

{member.displayName || `@${member.namespace}/${member.slug}`}

+

@{member.namespace}/{member.slug} · {t('suite.pinnedVersion', { version: member.version })}{member.entry ? ` · ${t('suite.entrySkill')}` : ''}

+
+ {member.blockingReason ? {suiteBlockingReasonLabel(t, member.blockingReason)} : {t('suite.publishable')}} +
+ ))} +
+
+ ) : ( +

{t('suite.reviewSnapshotFailed')}

+ )} + + ) : ( + + )} - {reviewFiles && reviewFiles.length > 0 && ( + {!isSuiteReview && reviewFiles && reviewFiles.length > 0 && (